diff --git a/app/library/Archiver.py b/app/library/Archiver.py index 185eb9e2..977db85f 100644 --- a/app/library/Archiver.py +++ b/app/library/Archiver.py @@ -26,6 +26,7 @@ class _Entry: self.size: int = -1 self.mtime: float = -1.0 self.loaded: bool = False + self.last_check: float = 0.0 class Archiver(metaclass=ThreadSafe): @@ -45,7 +46,8 @@ class Archiver(metaclass=ThreadSafe): self._cache: dict[str, _Entry] = {} self._locks: dict[str, threading.RLock] = {} self._global_lock = threading.RLock() - self._stats_check: bool = False + self._stats_check: bool = True + self._stats_ttl: float = 0.2 self._initialized = True @classmethod @@ -130,7 +132,13 @@ class Archiver(metaclass=ThreadSafe): if not self._stats_check: return entry + if self._stats_ttl > 0: + now = time.monotonic() + if (now - (entry.last_check or 0.0)) < self._stats_ttl: + return entry + st: os.stat_result | None = self._stat(key) + entry.last_check = time.monotonic() if not st: self._cache[key] = _Entry() @@ -148,6 +156,7 @@ class Archiver(metaclass=ThreadSafe): entry.size = -1 entry.mtime = -1 entry.loaded = True + entry.last_check = time.monotonic() self._cache[key] = entry return entry @@ -178,6 +187,7 @@ class Archiver(metaclass=ThreadSafe): entry.size = st.st_size entry.mtime = st.st_mtime entry.loaded = True + entry.last_check = time.monotonic() self._cache[key] = entry return entry @@ -284,6 +294,7 @@ class Archiver(metaclass=ThreadSafe): st: os.stat_result | None = self._stat(key) if st: entry.size, entry.mtime = st.st_size, st.st_mtime + entry.last_check = time.monotonic() return True @@ -353,12 +364,12 @@ class Archiver(metaclass=ThreadSafe): st: os.stat_result | None = self._stat(key) if st: entry.size, entry.mtime = st.st_size, st.st_mtime + entry.last_check = time.monotonic() self._cache[key] = entry return True - # Global configuration @classmethod def set_skip_read_stat_checks(cls, skip: bool = True) -> None: """ @@ -375,3 +386,17 @@ class Archiver(metaclass=ThreadSafe): inst = cls.get_instance() with inst._global_lock: inst._stats_check = not skip + + @classmethod + def set_read_stat_ttl(cls, seconds: float = 0.0) -> None: + """ + Set a short TTL to throttle os.stat checks on reads. + + Args: + seconds (float): Minimum time between successive stat() calls per file + when checking for external changes. Use 0 to disable throttling. + + """ + inst = cls.get_instance() + with inst._global_lock: + inst._stats_ttl = max(0.0, float(seconds)) diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index 02d9ae17..a35ccd85 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -151,11 +151,7 @@ class Item: from .Utils import arg_converter try: - removed_options: list = [] - arg_converter(args=cli, level=True, removed_options=removed_options) - if len(removed_options) > 0: - LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url) - + arg_converter(args=cli, level=True) data["cli"] = cli except Exception as e: msg = f"Failed to parse command options for yt-dlp. {e!s}" diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index cd33bfb1..ee421357 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -76,18 +76,12 @@ class YTDLPOpts: if from_user: bad_options: dict[str, str] = {k: v for d in REMOVE_KEYS for k, v in d.items()} - removed_options: list = [] - for key, value in config.items(): if from_user and key in bad_options: - removed_options.append(bad_options[key]) continue self._item_opts[key] = value - if len(removed_options) > 0: - LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options)) - return self def preset(self, name: str) -> "YTDLPOpts": @@ -175,15 +169,7 @@ class YTDLPOpts: if len(self._item_cli) > 0: try: - removed_options: list = [] - user_cli: dict = arg_converter( - args="\n".join(self._item_cli), - level=True, - removed_options=removed_options, - ) - - if len(removed_options) > 0: - LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options)) + user_cli: dict = arg_converter(args="\n".join(self._item_cli), level=True) except Exception as e: msg = f"Invalid command options for yt-dlp were given. '{e!s}'." raise ValueError(msg) from e diff --git a/app/library/ytdlp.py b/app/library/ytdlp.py index e263d057..c67ce607 100644 --- a/app/library/ytdlp.py +++ b/app/library/ytdlp.py @@ -1,12 +1,9 @@ -import logging from typing import Any import yt_dlp import app.postprocessors # noqa: F401 -LOG: logging.Logger = logging.getLogger(__name__) - class _ArchiveProxy: """ @@ -26,7 +23,6 @@ class _ArchiveProxy: from app.library.Archiver import Archiver status: bool = item in Archiver.get_instance().read(self._file, [item]) - LOG.debug(f"ArchiveProxy: '{item}' in '{self._file}': {'yes' if status else 'no'}.") return status except Exception: return False @@ -39,7 +35,6 @@ class _ArchiveProxy: from app.library.Archiver import Archiver status: bool = Archiver.get_instance().add(self._file, [item]) - LOG.debug(f"ArchiveProxy: Added '{item}' to '{self._file}': {'yes' if status else 'no'}.") return status except Exception: return False diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 1d09876a..42075f85 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -135,7 +135,24 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re req_path: str = request.match_info.get("path") req_path: str = "/" if not req_path else unquote_plus(req_path) - test: Path = Path(config.download_path).joinpath(req_path) + # Normalize requested path to always be inside download root. + raw_req: str = (req_path or "").strip() + root_dir: Path = Path(config.download_path).resolve() + if raw_req in ("", "/"): + test: Path = root_dir + rel_for_listing = "/" + else: + # Strip leading slash so joinpath doesn't ignore the base path. + test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False) + rel_for_listing = raw_req.lstrip("/") + + try: + test.relative_to(root_dir) + except Exception: + return web.json_response( + data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code + ) + if not test.exists(): return web.json_response( data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code @@ -149,8 +166,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re try: return web.json_response( data={ - "path": req_path, - "contents": get_files(base_path=Path(config.download_path), dir=req_path), + "path": rel_for_listing, + "contents": get_files(base_path=root_dir, dir=rel_for_listing), }, status=web.HTTPOk.status_code, dumps=encoder.encode, @@ -160,8 +177,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) -@route("POST", "api/file/action/{path:.*}", "browser.actions") -async def path_action(request: Request, config: Config) -> Response: +@route("POST", "api/file/actions", "browser.file.actions") +async def path_actions(request: Request, config: Config) -> Response: """ Browser actions. @@ -184,134 +201,254 @@ async def path_action(request: Request, config: Config) -> Response: rootPath: Path = Path(config.download_path) try: - params = await request.json() - if not params or not isinstance(params, dict): - return web.json_response(data={"error": "Invalid parameters."}, status=web.HTTPBadRequest.status_code) + actions = await request.json() + if not actions or not isinstance(actions, list): + return web.json_response( + data={"error": "Invalid parameters expecting list of dicts."}, status=web.HTTPBadRequest.status_code + ) except Exception as e: LOG.exception(e) return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code) - action = params.get("action").lower() - if not action: - return web.json_response(data={"error": "Action is required."}, status=web.HTTPBadRequest.status_code) - - req_path: str = request.match_info.get("path") - req_path: str = "/" if not req_path else unquote_plus(req_path) - - test: Path = Path(config.download_path) - if req_path and "/" != req_path: - test = test.joinpath(req_path) - - if not test.exists(): - return web.json_response( - data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code - ) - - try: - path, status = get_file(download_path=config.download_path, file=str(test.relative_to(config.download_path))) - if web.HTTPOk.status_code != status: + # validate each action before performing any operations + for params in actions: + action = params.get("action").lower() + if not action or action not in ["rename", "delete", "move", "directory"]: return web.json_response( - data={"error": f"File {status}: '{test}' does not exist."}, status=web.HTTPNotFound.status_code - ) - if not path.is_relative_to(rootPath): - return web.json_response( - data={"error": "Cannot perform actions on files outside the download path."}, - status=web.HTTPBadRequest.status_code, - ) - except Exception as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) - - if "directory" != action: - if path == rootPath: - return web.json_response( - data={"error": "Cannot perform actions on the root directory."}, status=web.HTTPBadRequest.status_code - ) - - if not path.is_relative_to(rootPath): - return web.json_response( - data={"error": "Cannot perform actions on files outside the download path."}, + data={"error": f"Invalid action '{action}'. Must be one of rename, delete, move, directory."}, status=web.HTTPBadRequest.status_code, ) - if "rename" == action: - new_name = params.get("new_name") - if not new_name: - return web.json_response(data={"error": "New name is required."}, status=web.HTTPBadRequest.status_code) - - new_path = path.parent.joinpath(new_name) - if new_path.exists(): + if "rename" == action and not params.get("new_name"): return web.json_response( - data={"error": f"File '{new_name}' already exists."}, status=web.HTTPConflict.status_code + data={"error": "New name is required for rename action."}, status=web.HTTPBadRequest.status_code ) - try: - path.rename(new_path) - LOG.info( - f"Renamed '{path.relative_to(config.download_path)}' to '{test.relative_to(config.download_path)}'" + if "move" == action and not params.get("new_path"): + return web.json_response( + data={"error": "New path is required for move action."}, status=web.HTTPBadRequest.status_code ) - except OSError as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) - if "delete" == action: + if "directory" == action and not params.get("new_dir"): + return web.json_response( + data={"error": "New directory name is required for directory action."}, + status=web.HTTPBadRequest.status_code, + ) + + operations_status: list[dict[str, Any]] = [] + + def record( + op_path: str | Path, + *, + ok: bool, + error: str | None = None, + action: str | None = None, + extra: dict | None = None, + ) -> None: try: - if not path.exists(): - return web.json_response( - data={"error": f"Path '{path}' does not exist."}, status=web.HTTPNotFound.status_code - ) - - if path.is_dir(): - delete_dir(path) + if isinstance(op_path, Path): + rel: str = str(op_path.relative_to(rootPath)).strip("/") else: - path.unlink(missing_ok=True) + rel: str = str(op_path).strip("/") + except Exception: + rel = str(op_path) + if not rel or rel == ".": + rel = "/" - LOG.info(f"Deleted '{path.relative_to(config.download_path)}'") - except OSError as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + entry: dict = {"path": rel, "status": ok, "error": error} - if "move" == action: - new_path = params.get("new_path") - if not new_path: - return web.json_response(data={"error": "New path is required."}, status=web.HTTPBadRequest.status_code) + if action: + entry["action"] = action - new_path = Path(config.download_path).joinpath(unquote_plus(new_path)) - if not new_path.exists() or not new_path.is_dir(): - return web.json_response( - data={"error": f"New path '{new_path}' does not exist or is not a directory."}, - status=web.HTTPNotFound.status_code, - ) + if extra: + norm_extra: dict = {} + for k, v in extra.items(): + if isinstance(v, Path): + try: + norm_extra[k] = str(v.relative_to(rootPath)).strip("/") or "/" + except Exception: + norm_extra[k] = str(v) + else: + norm_extra[k] = v + entry.update(norm_extra) + + operations_status.append(entry) + + # perform each action + for params in actions: + req_path: str = params.get("path") + if not req_path: + record("no_path", ok=False, error="Path is required.", extra={"item": params}) + continue + + action: str = params.get("action", "").lower() + if not action: + record(req_path, ok=False, error="Action is required.", extra={"item": params}) + continue + + req_path: str = "/" if not req_path else unquote_plus(req_path) + + test: Path = Path(config.download_path) + if req_path and "/" != req_path: + test = test.joinpath(req_path) + + if not test.exists(): + record(req_path, ok=False, error=f"path '{req_path}' does not exist.", action=action) + continue try: - path.rename(new_path.joinpath(path.name)) - except OSError as e: + path, status = get_file( + download_path=config.download_path, file=str(test.relative_to(config.download_path)) + ) + if web.HTTPOk.status_code != status: + record(req_path, ok=False, error="Invalid path.", action=action) + continue + if not path.is_relative_to(rootPath): + record(req_path, ok=False, error="Path outside download root.", action=action) + continue + except Exception as e: LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + record(req_path, ok=False, error=str(e), action=action, extra={"item": params}) + continue - if "directory" == action: - new_dir = params.get("new_dir").lstrip("/").strip() - if not new_dir: - return web.json_response( - data={"error": "New directory name is required."}, status=web.HTTPBadRequest.status_code + if "directory" != action: + if path == rootPath: + record(path, ok=False, error="Cannot operate on root directory.", action=action) + continue + + if not path.is_relative_to(rootPath): + record(path, ok=False, error="Path outside download root.", action=action) + continue + + if "directory" == action: + new_dir = params.get("new_dir").lstrip("/").strip() + if not new_dir: + record(path, ok=False, error="New directory name is required.", action=action) + continue + + new_path = path.joinpath(*new_dir.split("/")) + try: + new_path = new_path.resolve(strict=False) + except Exception: + record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir}) + continue + + root_real = Path(config.download_path).resolve() + if not new_path.is_relative_to(root_real): + record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path}) + continue + + if new_path.exists(): + dst = new_path.relative_to(config.download_path) + record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst}) + continue + + try: + new_path.mkdir(parents=True, exist_ok=True) + record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)}) + LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'") + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"item": params}) + continue + + if "rename" == action: + new_name: str = params.get("new_name") + if not new_name: + record(path, ok=False, error="New name is required for rename action.", action=action) + continue + + new_path: Path = path.parent.joinpath(new_name) + if new_path.exists(): + record( + new_path, ok=False, error="Destination already exists.", action=action, extra={"new_path": new_path} + ) + continue + + try: + renamed = path.rename(new_path) + LOG.info( + f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'" + ) + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"item": params}) + continue + else: + record(path, ok=True, action=action, extra={"new_path": renamed}) + + if "delete" == action: + try: + if not path.exists(): + record(path, ok=False, error="Path does not exist.", action=action) + continue + + if path.is_dir(): + delete_dir(path) + else: + path.unlink(missing_ok=True) + + LOG.info(f"Deleted '{path.relative_to(config.download_path)}'") + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"item": params}) + continue + else: + record(path, ok=True, action=action, extra={"deleted": True}) + + if "move" == action: + new_path = params.get("new_path") + if not new_path: + record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params}) + continue + + raw_new: str = unquote_plus(str(new_path)).strip() + target_dir = ( + Path(config.download_path) + if not raw_new or raw_new in ("/", ".") + else Path(config.download_path).joinpath(raw_new.lstrip("/")) ) - new_path = path.joinpath(*new_dir.split("/")) - if new_path.exists(): - return web.json_response( - data={"error": f"Directory '{new_dir}' already exists."}, status=web.HTTPConflict.status_code - ) + try: + target_dir = target_dir.resolve() + except Exception: + record( + path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir} + ) + continue - try: - new_path.mkdir(parents=True, exist_ok=True) - LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'") - except OSError as e: - LOG.exception(e) - return web.json_response( - data={"error": str(e), "path": str(test)}, status=web.HTTPInternalServerError.status_code - ) + root_real = Path(config.download_path).resolve() + if not target_dir.exists() or not target_dir.is_dir(): + record( + path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir} + ) + continue - return web.Response(status=web.HTTPOk.status_code) + if not target_dir.is_relative_to(root_real): + record( + path, + ok=False, + error="Destination outside download root.", + action=action, + extra={"new_path": target_dir}, + ) + continue + + if path.parent == target_dir: + record(path, ok=False, error="Source and destination are the same.", action=action) + continue + + try: + dest = target_dir.joinpath(path.name) + path.rename(dest) + except OSError as e: + LOG.exception(e) + record(path, ok=False, error=str(e), action=action, extra={"item": params}) + continue + else: + record(path, ok=True, action=action, extra={"new_path": dest}) + + return web.json_response(data=operations_status, status=web.HTTPOk.status_code) @route("POST", "api/file/download", "browser.download.prepare") diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index e7ff9fed..b29bc385 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -343,7 +343,7 @@ const importItem = async (): Promise => { return } - if ((form.filter || form.cli) && !box.confirm('Overwrite the current form fields?', true)) { + if ((form.filter || form.cli) && !(await box.confirm('Overwrite the current form fields?', true))) { return } diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue index 2c4809ae..eada7cb3 100644 --- a/ui/app/components/Dialog.vue +++ b/ui/app/components/Dialog.vue @@ -56,6 +56,8 @@

+
@@ -71,7 +73,8 @@ @@ -201,12 +234,17 @@ const route = useRoute() const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() +const isMobile = useMediaQuery({ maxWidth: 1024 }) +const dialog = useDialog() const bg_enable = useStorage('random_bg', true) const bg_opacity = useStorage('random_bg_opacity', 0.95) const sort_by = useStorage('sort_by', 'name') const sort_order = useStorage('sort_order', 'asc') +const selectedElms = ref([]) +const masterSelectAll = ref(false) + const isLoading = ref(false) const initialLoad = ref(true) const items = ref([]) @@ -221,12 +259,31 @@ const table_container = ref(false) const search = ref('') const show_filter = ref(false) +const reset_dialog = () => ({ + visible: false, + title: 'Confirm Action', + confirm: (opts: any) => { }, + message: '', + html_message: '', + options: [], +}); + +const dialog_confirm = ref(reset_dialog()) + +watch(masterSelectAll, v => { + if (v) { + selectedElms.value = filteredItems.value.map(i => i.path) + } else { + selectedElms.value = [] + } +}) + watch(() => config.app.basic_mode, async v => { if (!config.isLoaded() || !v) { return } await navigateTo('/') -},{ immediate: true }) +}, { immediate: true }) const filteredItems = computed(() => { @@ -360,6 +417,10 @@ const reloadContent = async (dir: string = '/', fromMounted: boolean = false): P } items.value = [] + search.value = '' + selectedElms.value = [] + show_filter.value = false + masterSelectAll.value = false const data = await response.json() as FileBrowserResponse @@ -497,8 +558,15 @@ const createDirectory = async (dir: string): Promise => { return } - const newDir = prompt('Enter new directory name:', '') - if (!newDir) { + const { status, value: newDir } = await dialog.promptDialog({ + title: 'Create New Directory', + message: `Enter name for new directory in '${dir || '/'}':`, + initial: '', + confirmText: 'Create', + cancelText: 'Cancel', + }) + + if (true !== status || !newDir) { return } @@ -507,7 +575,7 @@ const createDirectory = async (dir: string): Promise => { return } - await actionRequest({ path: dir } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => { + await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => { reloadContent(path.value, true) toast.success(`Successfully created '${new_dir}'.`) }) @@ -519,8 +587,14 @@ const handleAction = async (action: string, item: FileItem): Promise => { } if ('rename' === action) { - const newName = prompt('Enter new name for the item:', item.name) - if (!newName) { + const { status, value: newName } = await dialog.promptDialog({ + title: 'Rename Item', + message: `Enter new name for '${item.name}':`, + initial: item.name, + confirmText: 'Rename', + cancelText: 'Cancel', + }) + if (true !== status) { return } @@ -529,7 +603,7 @@ const handleAction = async (action: string, item: FileItem): Promise => { return } - await actionRequest(item, 'rename', { new_name: new_name }, (item, action, data) => { + await actionRequest(item, 'rename', { new_name: new_name }, (item, _, data) => { item.name = data.new_name item.path = item.path.replace(/[^/]+$/, data.new_name) toast.success(`Renamed '${item.name}'.`) @@ -539,7 +613,15 @@ const handleAction = async (action: string, item: FileItem): Promise => { if ('delete' === action) { const msg = item.is_dir ? `Delete '${item.name}' and all its contents?` : `Delete file '${item.name}'?` - if (false === confirm(msg)) { + const { status } = await dialog.confirmDialog({ + title: 'Delete Confirmation', + message: msg, + confirmText: 'Delete', + cancelText: 'Cancel', + confirmColor: 'is-danger', + }) + + if (true !== status) { return } @@ -552,12 +634,19 @@ const handleAction = async (action: string, item: FileItem): Promise => { } if ('move' === action) { - const newPath = prompt('Enter new path:', item.path.replace(/[^/]+$/, '')) - if (!newPath) { + const { status, value: newPath } = await dialog.promptDialog({ + title: 'Move Item', + message: `Enter new path for '${item.name}':`, + initial: item.path.replace(/[^/]+$/, '') || '/', + confirmText: 'Move', + cancelText: 'Cancel', + }) + + if (true !== status) { return } - let new_path = sTrim(newPath, '/') + let new_path = sTrim(newPath, '/') || '/' if (!new_path || new_path === item.path) { return } @@ -586,13 +675,13 @@ const actionRequest = async ( } try { - const response = await request(`/api/file/action/${encodePath(item.path)}`, { + const response = await request(`/api/file/actions`, { method: 'POST', - body: JSON.stringify({ + body: JSON.stringify([{ path: item.path, action: action, ...data - }), + }]), }) if (!response.ok) { @@ -601,9 +690,21 @@ const actionRequest = async ( return } - if (cb && typeof cb === 'function') { - cb(item, action, data) - } + const json = await response.json() as Array<{ path: string, status: boolean, error?: string }> + json.forEach(i => { + if (i.path !== item.path) { + return + } + + if (true !== i.status) { + toast.error(`Failed to perform action: ${i.error || 'Unknown error'}`) + return + } + + if (cb && typeof cb === 'function') { + cb(item, action, data) + } + }); return response } catch (error: any) { @@ -611,4 +712,136 @@ const actionRequest = async ( toast.error(`Failed to perform action: ${error.message}`) } } + +const massAction = async (items: Array<{ path: string, action: string }>, cb: (item: any) => void): Promise => { + if (!config.app.browser_control_enabled) { + return + } + + if (!items || items.length < 1) { + return + } + + try { + const response = await request(`/api/file/actions`, { + method: 'POST', + body: JSON.stringify(items), + }) + + if (!response.ok) { + const error = await response.json() + toast.error(`Failed to perform action: ${error.error || 'Unknown error'}`) + return + } + + const json = await response.json() as Array<{ path: string, status: boolean, error?: string }> + json.forEach(i => { + if (true !== i.status) { + toast.error(`Failed to perform action on '${i.path}': ${i.error || 'Unknown error'}`) + return + } + + if (cb && typeof cb === 'function') { + cb(i) + } + }); + + return response + } catch (error: any) { + console.error(error) + toast.error(`Failed to perform action: ${error.message}`) + } +} + +const deleteSelected = async () => { + if (selectedElms.value.length < 1) { + toast.error('No items selected.') + return + } + + const rawHTML = `
    ` + selectedElms.value.map(p => { + const item = items.value.find(i => i.path === p) + if (!item) { + return '' + } + const color = 'dir' === item.type ? 'has-text-danger is-bold' : '' + + return `
  • ${item.name}
  • ` + }).join('') + `
` + + + const { status } = await dialog.confirmDialog({ + title: 'Delete Confirmation', + message: `Delete the following items?`, + rawHTML: rawHTML, + confirmText: 'Delete', + cancelText: 'Cancel', + confirmColor: 'is-danger', + }) + + if (true !== status) { + selectedElms.value = [] + return + } + + const actions = [] as Array<{ action: string, path: string }> + selectedElms.value.forEach(p => { + const item = items.value.find(i => i.path === p) + if (!item) { + return; + } + + actions.push({ path: item.path, action: 'delete' }) + }) + + await massAction(actions, i => { + const item = items.value.find(it => it.path === i.path) + if (!item) { + return + } + items.value = items.value.filter(it => it.path !== i.path) + toast.warning(`Deleted '${item.name}'.`) + }) +} + +const moveSelected = async () => { + if (selectedElms.value.length < 1) { + toast.error('No items selected.') + return + } + + const { status, value: newPath } = await dialog.promptDialog({ + title: 'Move Items', + message: `Enter new path for selected items:`, + initial: path.value || '/', + confirmText: 'Move', + confirmColor: 'is-danger', + cancelText: 'Cancel', + }) + + if (true !== status || !newPath || newPath === path.value) { + selectedElms.value = [] + return + } + + const actions = [] as Array<{ action: string, path: string, new_path: string }> + selectedElms.value.forEach(p => { + const item = items.value.find(i => i.path === p) + if (!item) { + return; + } + + actions.push({ + path: item.path, + action: 'move', + new_path: sTrim(newPath, '/') || '/', + }) + }) + + await massAction(actions, i => { + items.value = items.value.filter(it => it.path !== i.path) + toast.success(`Moved '${i.path}' to '${sTrim(newPath, '/')}'.`) + }) +} + diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index 64cf6964..9dca9c90 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -13,12 +13,14 @@

@@ -121,6 +123,7 @@ const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() const box = useConfirm() +const isMobile = useMediaQuery({ maxWidth: 1024 }) const items = ref([]) const item = ref>({}) @@ -219,7 +222,7 @@ const updateItems = async (newItems: ConditionItem[]): Promise => { } const deleteItem = async (cond: ConditionItem): Promise => { - if (!box.confirm(`Delete '${cond.name}'?`, true)) { + if (true !== (await box.confirm(`Delete '${cond.name}'?`, true))) { return } diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index 27c065ba..dea27146 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -46,9 +46,9 @@

@@ -123,8 +123,9 @@ const stateStore = useStateStore() const socket = useSocketStore() const bg_enable = useStorage('random_bg', true) const bg_opacity = useStorage('random_bg_opacity', 0.95) -const display_style = useStorage('display_style', 'cards') +const display_style = useStorage('display_style', 'grid') const show_thumbnail = useStorage('show_thumbnail', true) +const isMobile = useMediaQuery({ maxWidth: 1024 }) const info_view = ref({ url: '', @@ -143,7 +144,6 @@ const dialog_confirm = ref({ options: [], }) -const isMobile = useMediaQuery({ maxWidth: 1024 }) watch(toggleFilter, () => { if (!toggleFilter.value) { @@ -217,7 +217,7 @@ watch(() => info_view.value.url, v => { document.querySelector('body')?.setAttribute("style", `opacity: ${v ? 1 : bg_opacity.value}`) }) -const changeDisplay = () => display_style.value = display_style.value === 'cards' ? 'list' : 'cards' +const changeDisplay = () => display_style.value = display_style.value === 'grid' ? 'list' : 'grid' const toNewDownload = async (item: item_request | Partial) => { if (!item) { diff --git a/ui/app/pages/notifications.vue b/ui/app/pages/notifications.vue index a7325da6..8855f943 100644 --- a/ui/app/pages/notifications.vue +++ b/ui/app/pages/notifications.vue @@ -14,19 +14,25 @@

@@ -203,6 +209,7 @@ const config = useConfigStore() const socket = useSocketStore() const box = useConfirm() const display_style = useStorage("tasks_display_style", "cards") +const isMobile = useMediaQuery({ maxWidth: 1024 }) const allowedEvents = ref([]) const notifications = ref([]) @@ -305,7 +312,7 @@ const updateData = async (items: notification[]) => { } const deleteItem = async (item: notification) => { - if (true !== box.confirm(`Delete '${item.name}'?`)) { + if (true !== (await box.confirm(`Delete '${item.name}'?`))) { return } @@ -366,7 +373,7 @@ const join_events = (events: string[]) => !events || events.length < 1 ? 'ALL' : events.map(e => ucFirst(e)).join(', ') const sendTest = async () => { - if (true !== box.confirm('Send test notification?')) { + if (true !== (await box.confirm('Send test notification?'))) { return } diff --git a/ui/app/pages/presets.vue b/ui/app/pages/presets.vue index a66dec20..a915e975 100644 --- a/ui/app/pages/presets.vue +++ b/ui/app/pages/presets.vue @@ -14,20 +14,26 @@

@@ -214,6 +220,7 @@ const socket = useSocketStore() const box = useConfirm() const display_style = useStorage('preset_display_style', 'cards') +const isMobile = useMediaQuery({ maxWidth: 1024 }) const presets = ref([]) const preset = ref>({}) @@ -304,7 +311,7 @@ const updatePresets = async (items: Preset[]): Promise => { } const deleteItem = async (item: Preset) => { - if (true !== box.confirm(`Delete preset '${item.name}'?`, true)) { + if (true !== (await box.confirm(`Delete preset '${item.name}'?`, true))) { return } diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index 4277af39..bcc4bdf6 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -20,6 +20,7 @@

@@ -27,14 +28,19 @@

@@ -42,6 +48,7 @@

@@ -382,6 +389,7 @@ const config = useConfigStore() const socket = useSocketStore() const { confirmDialog: cDialog } = useDialog() const display_style = useStorage("tasks_display_style", "cards") +const isMobile = useMediaQuery({ maxWidth: 1024 }) const tasks = ref>([]) const task = ref({}) @@ -571,7 +579,7 @@ const deleteSelected = async () => { } const deleteItem = async (item: task_item) => { - if (true !== box.confirm(`Delete '${item.name}' task?`, true)) { + if (true !== (await box.confirm(`Delete '${item.name}' task?`, true))) { return } @@ -688,7 +696,7 @@ const runSelected = async () => { } const runNow = async (item: task_item, mass: boolean = false) => { - if (!mass && true !== box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`)) { + if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) { return }