diff --git a/CLI.py b/CLI.py index e3f9090..c474111 100644 --- a/CLI.py +++ b/CLI.py @@ -725,8 +725,29 @@ class CmdletCompleter(Completer): return None @staticmethod - def _selected_plugin_name(cmd_name: str, stage_tokens: Sequence[str]) -> Optional[str]: + def _effective_cmd_name(cmd_name: str, stage_tokens: Sequence[str]) -> str: canonical_cmd = str(cmd_name or "").replace("_", "-").strip().lower() + if canonical_cmd != "file": + return canonical_cmd + + lowered = {str(tok or "").strip().lower() for tok in (stage_tokens or [])} + if "-search" in lowered or "--search" in lowered: + return "search-file" + if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered: + return "download-file" + if "-add" in lowered or "--add" in lowered: + return "add-file" + if "-get" in lowered or "--get" in lowered: + return "get-file" + if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered: + return "delete-file" + if "-merge" in lowered or "--merge" in lowered: + return "merge-file" + return canonical_cmd + + @staticmethod + def _selected_plugin_name(cmd_name: str, stage_tokens: Sequence[str]) -> Optional[str]: + canonical_cmd = CmdletCompleter._effective_cmd_name(cmd_name, stage_tokens) if canonical_cmd not in {"search-file", "add-file", "download-file"}: return None return CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin") @@ -778,7 +799,7 @@ class CmdletCompleter(Completer): if not arg_names: return [] - canonical_cmd = str(cmd_name or "").replace("_", "-").strip().lower() + canonical_cmd = self._effective_cmd_name(cmd_name, stage_tokens) plugin_name = self._selected_plugin_name(canonical_cmd, stage_tokens) instance_choices = self._plugin_instance_choices(plugin_name, config) has_named_instances = bool(instance_choices) @@ -862,6 +883,7 @@ class CmdletCompleter(Completer): return cmd_name = stage_tokens[0].replace("_", "-").lower() + effective_cmd = self._effective_cmd_name(cmd_name, stage_tokens) if ends_with_space: current_token = "" prev_token = stage_tokens[-1].lower() @@ -872,12 +894,12 @@ class CmdletCompleter(Completer): config = self._config_loader.load_shared() provider_name = None - if cmd_name == "search-file": + if effective_cmd == "search-file": provider_name = self._flag_value(stage_tokens, "-plugin", "--plugin") - selected_plugin = self._selected_plugin_name(cmd_name, stage_tokens) + selected_plugin = self._selected_plugin_name(effective_cmd, stage_tokens) - query_specs = self._query_args(cmd_name, config) + query_specs = self._query_args(effective_cmd, config) query_flag_index = -1 for idx, tok in enumerate(stage_tokens): if str(tok or "").strip().lower() in {"-query", "--query"}: @@ -923,7 +945,7 @@ class CmdletCompleter(Completer): partial_lower = partial.strip().lower() inline_choices = [] - if cmd_name == "search-file" and provider_name: + if effective_cmd == "search-file" and provider_name: inline_choices = self._inline_query_choices(provider_name, field, config) choice_pool = inline_choices or field_choices.get(field, []) @@ -948,7 +970,7 @@ class CmdletCompleter(Completer): return if ( - cmd_name == "search-file" + effective_cmd == "search-file" and provider_name and not ends_with_space and ":" in current_token @@ -990,7 +1012,7 @@ class CmdletCompleter(Completer): choices = self._plugin_instance_choices(selected_plugin, config) if not choices: choices = self._arg_choices( - cmd_name=cmd_name, + cmd_name=effective_cmd, arg_name=prev_token, config=config, force=False, @@ -1016,12 +1038,12 @@ class CmdletCompleter(Completer): return arg_names = self._filter_stage_arg_names( - cmd_name=cmd_name, + cmd_name=effective_cmd, stage_tokens=stage_tokens, config=config, - arg_names=self._cmdlet_args(cmd_name, config), + arg_names=self._cmdlet_args(effective_cmd, config), ) - used_logicals = self._used_arg_logicals(cmd_name, stage_tokens, config) + used_logicals = self._used_arg_logicals(effective_cmd, stage_tokens, config) logical_seen: Set[str] = set() for arg in arg_names: arg_low = arg.lower() @@ -1228,6 +1250,26 @@ class CmdletExecutor: emitted_items: Optional[List[Any]] = None, cmd_args: Optional[List[str]] = None, ) -> str: + def _file_action(args: Optional[List[str]]) -> str | None: + tokens = [str(t or "").strip().lower() for t in (args or [])] + token_set = set(tokens) + if "-search" in token_set or "--search" in token_set: + return "search-file" + if "-download" in token_set or "--download" in token_set or "-dl" in token_set or "--dl" in token_set: + return "download-file" + if "-get" in token_set or "--get" in token_set: + return "get-file" + if "-add" in token_set or "--add" in token_set: + return "add-file" + if "-delete" in token_set or "--delete" in token_set or "-del" in token_set or "--del" in token_set: + return "delete-file" + if "-merge" in token_set or "--merge" in token_set: + return "merge-file" + return None + + normalized_cmd = str(cmd_name or "").replace("_", "-").lower().strip() + mapped_cmd = _file_action(cmd_args) if normalized_cmd == "file" else normalized_cmd + title_map = { "search-file": "Results", "search_file": "Results", @@ -1235,14 +1277,9 @@ class CmdletExecutor: "download_data": "Downloads", "download-file": "Downloads", "download_file": "Downloads", - "get-tag": "Tags", - "get_tag": "Tags", + "metadata": "Tags", "get-file": "Results", "get_file": "Results", - "add-tags": "Results", - "add_tags": "Results", - "delete-tag": "Results", - "delete_tag": "Results", "add-url": "Results", "add_url": "Results", "get-url": "url", @@ -1266,7 +1303,7 @@ class CmdletExecutor: "get-metadata": None, "get_metadata": None, } - mapped = title_map.get(cmd_name, "Results") + mapped = title_map.get(mapped_cmd or normalized_cmd, "Results") if mapped is not None: return mapped @@ -1358,10 +1395,25 @@ class CmdletExecutor: ) -> None: nonlocal progress_ui, pipe_idx + def _effective_file_cmd(name: str, args: List[str]) -> str: + norm = str(name or "").replace("_", "-").strip().lower() + if norm != "file": + return norm + lowered = {str(a or "").strip().lower() for a in (args or [])} + if "-add" in lowered or "--add" in lowered: + return "add-file" + if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered: + return "delete-file" + if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered: + return "download-file" + return norm + + effective_cmd = _effective_file_cmd(cmd_name_norm, filtered_args) + # Keep behavior consistent with pipeline runner exclusions. # Some commands render their own Rich UI (tables/panels) and don't # play nicely with Live cursor control. - if cmd_name_norm in { + if effective_cmd in { "get-relationship", "get-rel", ".pipe", @@ -1375,8 +1427,7 @@ class CmdletExecutor: return # add-file directory selector mode: show only the selection table, no Live progress. - if cmd_name_norm in {"add-file", - "add_file"}: + if effective_cmd in {"add-file", "add_file"}: try: from pathlib import Path as _Path @@ -1457,8 +1508,7 @@ class CmdletExecutor: while i < len(toks): t = str(toks[i]) low = t.lower().strip() - if (cmd_name_norm in {"add-file", - "add_file"} and low in {"-path", + if (effective_cmd in {"add-file", "add_file"} and low in {"-path", "--path", "-p"} and i + 1 < len(toks)): @@ -1706,6 +1756,25 @@ class CmdletExecutor: filtered_args ) + def _effective_cmd_name(name: str, args: List[str]) -> str: + norm = str(name or "").replace("_", "-").strip().lower() + if norm != "file": + return norm + lowered = {str(a or "").strip().lower() for a in (args or [])} + if "-search" in lowered or "--search" in lowered: + return "search-file" + if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered: + return "download-file" + if "-get" in lowered or "--get" in lowered: + return "get-file" + if "-add" in lowered or "--add" in lowered: + return "add-file" + if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered: + return "delete-file" + return norm + + effective_cmd = _effective_cmd_name(cmd_name, filtered_args) + selectable_commands = { "search-file", "download-data", @@ -1729,8 +1798,7 @@ class CmdletExecutor: "get_metadata", } self_managing_commands = { - "get-tag", - "get_tag", + "tag", "tags", "get-metadata", "get_metadata", @@ -1740,9 +1808,10 @@ class CmdletExecutor: "search_file", "add-file", "add_file", + "file", } - if cmd_name in self_managing_commands: + if effective_cmd in self_managing_commands or cmd_name in self_managing_commands: table = ( ctx.get_display_table() if hasattr(ctx, "get_display_table") else None @@ -1758,11 +1827,11 @@ class CmdletExecutor: for emitted in emits: table.add_result(emitted) - if cmd_name in selectable_commands: - table.set_source_command(cmd_name, filtered_args) + if effective_cmd in selectable_commands: + table.set_source_command(effective_cmd, filtered_args) ctx.set_last_result_table(table, emits) ctx.set_current_stage_table(None) - elif cmd_name in display_only_commands: + elif effective_cmd in display_only_commands or cmd_name in display_only_commands: ctx.set_last_result_items_only(emits) else: ctx.set_last_result_items_only(emits) diff --git a/ProviderCore/base.py b/ProviderCore/base.py index f1d2012..72aacad 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -636,6 +636,21 @@ class Provider(ABC): """True if tag writes should be deferred until after file ingest.""" return False + @property + def supports_url_association(self) -> bool: + """True when this provider supports associating URLs to files.""" + return False + + @property + def supports_note_association(self) -> bool: + """True when this provider supports per-file named notes.""" + return False + + @property + def supports_relationship_association(self) -> bool: + """True when this provider supports file relationship links (king/alt/related).""" + return False + def add_file(self, file_path: Path, **kwargs: Any) -> str: """Ingest a file and return its canonical hash.""" raise NotImplementedError(f"Plugin '{self.name}' does not support add_file") diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index a51ee6b..bf9c259 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -471,7 +471,7 @@ class PluginRegistry: if not info.is_multi_instance: continue if not info.supported_cmdlets.intersection( - {"add-file", "get-file", "get-tag", "add-tag"} + {"add-file", "get-file", "tag"} ): continue try: @@ -544,6 +544,9 @@ def get_plugin_capabilities( "supports_upload": False, "supports_pipe_download": False, "supports_delete_file": False, + "supports_url_association": False, + "supports_note_association": False, + "supports_relationship_association": False, "is_multi_instance": False, "configured_instances": [], } @@ -559,11 +562,20 @@ def get_plugin_capabilities( supports_delete_file = callable(delete_method) and delete_method is not base_delete_method configured_instances: List[str] = [] + supports_url_association = False + supports_note_association = False + supports_relationship_association = False try: plugin_obj = info.plugin_class(config or {}) configured_instances = [str(v) for v in (plugin_obj.configured_instances() or []) if str(v).strip()] + supports_url_association = bool(getattr(plugin_obj, "supports_url_association", False)) + supports_note_association = bool(getattr(plugin_obj, "supports_note_association", False)) + supports_relationship_association = bool(getattr(plugin_obj, "supports_relationship_association", False)) except Exception: configured_instances = [] + supports_url_association = False + supports_note_association = False + supports_relationship_association = False return { "name": info.canonical_name, @@ -572,6 +584,9 @@ def get_plugin_capabilities( "supports_upload": bool(info.supports_upload), "supports_pipe_download": bool(supports_pipe_download), "supports_delete_file": bool(supports_delete_file), + "supports_url_association": bool(supports_url_association), + "supports_note_association": bool(supports_note_association), + "supports_relationship_association": bool(supports_relationship_association), "is_multi_instance": bool(info.is_multi_instance), "configured_instances": configured_instances, } diff --git a/SYS/pipeline.py b/SYS/pipeline.py index f42f967..922d26d 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -1282,15 +1282,30 @@ class PipelineExecutor: def _norm(name: str) -> str: return str(name or "").replace("_", "-").strip().lower() + def _file_action(stage_tokens: List[str]) -> str | None: + if not stage_tokens: + return None + head = _norm(stage_tokens[0]) + if head in {"download-file", "add-file"}: + return head + if head != "file": + return None + args = {_norm(t) for t in stage_tokens[1:]} + if "-download" in args or "--download" in args or "-dl" in args or "--dl" in args: + return "download-file" + if "-add" in args or "--add" in args: + return "add-file" + return None + names: List[str] = [] for stage in stages or []: if not stage: continue names.append(_norm(stage[0])) - dl_idxs = [i for i, n in enumerate(names) if n == "download-file"] + dl_idxs = [i for i, stage in enumerate(stages or []) if _file_action(stage or []) == "download-file"] rel_idxs = [i for i, n in enumerate(names) if n == "add-relationship"] - add_file_idxs = [i for i, n in enumerate(names) if n == "add-file"] + add_file_idxs = [i for i, stage in enumerate(stages or []) if _file_action(stage or []) == "add-file"] if not dl_idxs or not rel_idxs: return True @@ -1981,6 +1996,23 @@ class PipelineExecutor: def _norm_cmd_name(value: Any) -> str: return str(value or "").replace("_", "-").strip().lower() + def _stage_file_action(stage_tokens: Sequence[Any]) -> str | None: + if not stage_tokens: + return None + head = _norm_cmd_name(stage_tokens[0]) + if head in {"add-file", "download-file", "delete-file"}: + return head + if head != "file": + return None + args = {_norm_cmd_name(t) for t in stage_tokens[1:]} + if "-add" in args or "--add" in args: + return "add-file" + if "-download" in args or "--download" in args or "-dl" in args or "--dl" in args: + return "download-file" + if "-delete" in args or "--delete" in args or "-del" in args or "--del" in args: + return "delete-file" + return None + # ============================================================================ # PHASE 2: Parse source command and table metadata # ============================================================================ @@ -2333,8 +2365,8 @@ class PipelineExecutor: # ==================================================================== if not stages: if isinstance(table_type, str) and table_type.startswith("metadata."): - print("Auto-applying metadata selection via get-tag") - stages.append(["get-tag"]) + print("Auto-applying metadata selection via metadata -get") + stages.append(["metadata", "-get"]) elif auto_stage: try: print(f"Auto-running selection via {auto_stage[0]}") @@ -2383,12 +2415,12 @@ class PipelineExecutor: first_cmd_norm = _norm_cmd_name(first_cmd) inserted_provider_download = False - if first_cmd_norm == "add-file": + if _stage_file_action(stages[0]) == "add-file": # If selected rows advertise an explicit download-file action, # run download before add-file so add-file receives local files. if len(selection_indices) == 1: row_action = _get_row_action(selection_indices[0], items_list) - if row_action and _norm_cmd_name(row_action[0]) == "download-file": + if row_action and _stage_file_action(row_action) == "download-file": stages.insert(0, [str(x) for x in row_action if x is not None]) inserted_provider_download = True debug("Auto-inserting row download-file action before add-file") @@ -2401,24 +2433,24 @@ class PipelineExecutor: has_download_row_action = False for idx in selection_indices: row_action = _get_row_action(idx, items_list) - if row_action and _norm_cmd_name(row_action[0]) == "download-file": + if row_action and _stage_file_action(row_action) == "download-file": has_download_row_action = True break if has_download_row_action: - stages.insert(0, ["download-file"]) + stages.insert(0, ["file", "-download"]) inserted_provider_download = True debug("Auto-inserting download-file before add-file for provider selection") except Exception: pass if isinstance(table_type, str) and table_type.startswith("metadata.") and first_cmd not in ( - "get-tag", - "get_tag", + "metadata", + "tag", ".pipe", ".mpv", ): - print("Auto-inserting get-tag after metadata selection") - stages.insert(0, ["get-tag"]) + print("Auto-inserting metadata -get after metadata selection") + stages.insert(0, ["metadata", "-get"]) elif auto_stage: first_cmd_norm = _norm_cmd_name(stages[0][0] if stages and stages[0] else None) auto_cmd_norm = _norm_cmd_name(auto_stage[0]) @@ -2513,8 +2545,7 @@ class PipelineExecutor: # add-file directory selector stage: avoid Live progress so the # selection table renders cleanly. - if name in {"add-file", - "add_file"}: + if _stage_file_action(stage_tokens) == "add-file" or name in {"add_file"}: try: from pathlib import Path as _Path @@ -2559,8 +2590,7 @@ class PipelineExecutor: continue # `delete-file` prints a Rich table directly; Live progress interferes and # can truncate/overwrite the output. - if name in {"delete-file", - "del-file"}: + if _stage_file_action(stage_tokens) == "delete-file" or name in {"del-file"}: continue pipe_stage_indices.append(idx) pipe_labels.append(name) @@ -2707,7 +2737,7 @@ class PipelineExecutor: stage_args = stage_tokens[1:] if cmd_name == "@": - # Special-case get-tag tables: `@ | add-tag ...` should target the + # Special-case metadata -get tables: `@ | metadata -add ...` should target the # underlying file subject once, not each emitted TagItem row. try: next_cmd = None @@ -2721,28 +2751,36 @@ class PipelineExecutor: current_table = None source_cmd = str(getattr(current_table, "source_command", "") or "").replace("_", "-").strip().lower() - is_get_tag_table = source_cmd == "get-tag" + is_get_tag_table = source_cmd == "metadata" - if is_get_tag_table and next_cmd in {"add-tag"}: + if is_get_tag_table and next_cmd == "metadata": subject = ctx.get_last_result_subject() if subject is not None: - piped_result = subject - try: - subject_items = subject if isinstance(subject, list) else [subject] - ctx.set_last_items(subject_items) - except Exception: - logger.exception("Failed to set last_items from get-tag subject during @ handling") - if pipeline_session and worker_manager: + next_args = [ + str(x).replace("_", "-").strip().lower() + for x in (stages[stage_index + 1][1:] if stage_index + 1 < len(stages) else []) + ] + if "-add" not in next_args and "--add" not in next_args: + # Only apply this flattening for explicit tag-add pipelines. + pass + else: + piped_result = subject try: - worker_manager.log_step( - pipeline_session.worker_id, - "@ used get-tag table subject for add-tag" - ) + subject_items = subject if isinstance(subject, list) else [subject] + ctx.set_last_items(subject_items) except Exception: - logger.exception("Failed to record pipeline log step for '@ used get-tag table subject for add-tag' (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None)) - continue + logger.exception("Failed to set last_items from tag subject during @ handling") + if pipeline_session and worker_manager: + try: + worker_manager.log_step( + pipeline_session.worker_id, + "@ used metadata table subject for metadata -add" + ) + except Exception: + logger.exception("Failed to record pipeline log step for '@ used metadata table subject for metadata -add' (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None)) + continue except Exception: - logger.exception("Failed to evaluate get-tag @ subject special-case") + logger.exception("Failed to evaluate tag @ subject special-case") # Prefer piping the last emitted/visible items (e.g. add-file results) # over the result-table subject. The subject can refer to older context @@ -2962,17 +3000,23 @@ class PipelineExecutor: stage_is_last=(stage_index + 1 >= len(stages))): return - # Special case: selecting multiple tags from get-tag and piping into delete-tag + # Special case: selecting multiple metadata tag rows and piping into metadata -delete # should batch into a single operation (one backend call). next_cmd = None + next_args: List[str] = [] try: if stage_index + 1 < len(stages) and stages[stage_index + 1]: next_cmd = str(stages[stage_index + 1][0] ).replace("_", "-").lower() + next_args = [ + str(x).replace("_", "-").strip().lower() + for x in stages[stage_index + 1][1:] + ] except Exception: logger.exception("Failed to determine next_cmd during selection expansion for stage_index %s", stage_index) next_cmd = None + next_args = [] def _is_tag_row(obj: Any) -> bool: try: @@ -2991,8 +3035,7 @@ class PipelineExecutor: logger.exception("Failed to inspect dict tag_name while checking _is_tag_row") return False - if (next_cmd in {"delete-tag", - "delete_tag"} and len(filtered) > 1 + if (next_cmd == "tag" and ("-delete" in next_args or "--delete" in next_args) and len(filtered) > 1 and all(_is_tag_row(x) for x in filtered)): from SYS.field_access import get_field diff --git a/Store/_base.py b/Store/_base.py index fb7dd02..08f7647 100644 --- a/Store/_base.py +++ b/Store/_base.py @@ -40,6 +40,21 @@ class Store(ABC): """True if the store prefers tags to be applied after the file is added.""" return False + @property + def supports_url_association(self) -> bool: + """True when this store supports associating URLs to files.""" + return False + + @property + def supports_note_association(self) -> bool: + """True when this store supports per-file named notes.""" + return False + + @property + def supports_relationship_association(self) -> bool: + """True when this store supports file relationship links (king/alt/related).""" + return False + @abstractmethod def add_file(self, file_path: Path, **kwargs: Any) -> str: raise NotImplementedError diff --git a/TUI.py b/TUI.py index 5882030..61c7493 100644 --- a/TUI.py +++ b/TUI.py @@ -224,7 +224,7 @@ class TagEditorPopup(ModalScreen[None]): if not tags: try: runner: PipelineRunner = getattr(app, "executor") - cmd = "@1 | get-tag" + cmd = "@1 | metadata -get" res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True) tags = _extract_tag_names_from_table(getattr(res, "result_table", None)) if not tags: @@ -364,10 +364,10 @@ class TagEditorPopup(ModalScreen[None]): if to_del: del_args = " ".join(json.dumps(t) for t in to_del) - del_cmd = f"delete-tag -instance {store_tok}{query_chunk} {del_args}" - _log_pipeline_command("delete-tag", del_cmd) + del_cmd = f"metadata -delete -instance {store_tok}{query_chunk} {del_args}" + _log_pipeline_command("metadata-delete", del_cmd) del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True) - _log_pipeline_result("delete-tag", del_res) + _log_pipeline_result("metadata-delete", del_res) if not getattr(del_res, "success", False): failures.append( str( @@ -375,16 +375,16 @@ class TagEditorPopup(ModalScreen[None]): "error", "") or getattr(del_res, "stderr", - "") or "delete-tag failed" + "") or "metadata -delete failed" ).strip() ) if to_add: add_args = " ".join(json.dumps(t) for t in to_add) - add_cmd = f"add-tag -instance {store_tok}{query_chunk} {add_args}" - _log_pipeline_command("add-tag", add_cmd) + add_cmd = f"metadata -add -instance {store_tok}{query_chunk} {add_args}" + _log_pipeline_command("metadata-add", add_cmd) add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True) - _log_pipeline_result("add-tag", add_res) + _log_pipeline_result("metadata-add", add_res) if not getattr(add_res, "success", False): failures.append( str( @@ -392,7 +392,7 @@ class TagEditorPopup(ModalScreen[None]): "error", "") or getattr(add_res, "stderr", - "") or "add-tag failed" + "") or "metadata -add failed" ).strip() ) @@ -1027,8 +1027,8 @@ class PipelineHubApp(App): """Apply store/path/tags UI fields to the pipeline text. Rules (simple + non-destructive): - - If output path is set and the first stage is download-file and has no -path/--path, append -path. - - If an instance is selected and pipeline has no add-file stage, append add-file -instance . + - If output path is set and the first stage is file-download and has no -path/--path, append -path. + - If an instance is selected and pipeline has no file-add stage, append file -add -instance . """ base = str(pipeline_text or "").strip() if not base: @@ -1058,29 +1058,28 @@ class PipelineHubApp(App): except Exception: first_stage_cmd = "" - # Apply -path to download-file first stage (only if missing) + # Apply -path to file-download first stage (only if missing) if output_path: first = stages[0] low = first.lower() - if low.startswith("download-file" - ) and " -path" not in low and " --path" not in low: + if (low.startswith("download-file") or (low.startswith("file ") and " -download" in low)) and " -path" not in low and " --path" not in low: stages[0] = f"{first} -path {json.dumps(output_path)}" joined = " | ".join(stages) low_joined = joined.lower() - # Only auto-append add-file for download pipelines. + # Only auto-append file -add for download pipelines. should_auto_add_file = bool( - selected_store and ("add-file" not in low_joined) and ( - first_stage_cmd - in {"download-file"} + selected_store and ("add-file" not in low_joined and "file -add" not in low_joined) and ( + first_stage_cmd in {"download-file"} + or (first_stage_cmd == "file" and " -download" in stages[0].lower()) ) ) if should_auto_add_file: store_token = json.dumps(selected_store) - joined = f"{joined} | add-file -instance {store_token}" + joined = f"{joined} | file -add -instance {store_token}" return joined @@ -1092,13 +1091,13 @@ class PipelineHubApp(App): return command low = command.lower() - if "add-tag" in low: - # User already controls tag stage explicitly. + if "metadata -add" in low: + # User already controls metadata tag stage explicitly. self._pending_pipeline_tags_applied = False return command - # Apply draft tags when pipeline stores/emits files via add-file. - if "add-file" not in low: + # Apply draft tags when pipeline stores/emits files via file-add. + if "add-file" not in low and "file -add" not in low: self._pending_pipeline_tags_applied = False return command @@ -1109,7 +1108,7 @@ class PipelineHubApp(App): self._pending_pipeline_tags_applied = True self.notify(f"Applying {len(pending)} pending tag(s) after pipeline", timeout=3) - return f"{command} | add-tag {tag_args}" + return f"{command} | metadata -add {tag_args}" def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: if not self.results_table or event.control is not self.results_table: @@ -1656,27 +1655,27 @@ class PipelineHubApp(App): try: if to_del: del_args = " ".join(json.dumps(t) for t in to_del) - del_cmd = f"delete-tag -instance {store_tok}{query_chunk} {del_args}" + del_cmd = f"metadata -delete -instance {store_tok}{query_chunk} {del_args}" del_res = runner.run_pipeline(del_cmd, seeds=seeds, isolate=True) if not getattr(del_res, "success", False): failures.append( str( getattr(del_res, "error", "") or getattr(del_res, "stderr", "") - or "delete-tag failed" + or "metadata -delete failed" ).strip() ) if to_add: add_args = " ".join(json.dumps(t) for t in to_add) - add_cmd = f"add-tag -instance {store_tok}{query_chunk} {add_args}" + add_cmd = f"metadata -add -instance {store_tok}{query_chunk} {add_args}" add_res = runner.run_pipeline(add_cmd, seeds=seeds, isolate=True) if not getattr(add_res, "success", False): failures.append( str( getattr(add_res, "error", "") or getattr(add_res, "stderr", "") - or "add-tag failed" + or "metadata -add failed" ).strip() ) @@ -1835,7 +1834,7 @@ class PipelineHubApp(App): if not store_name or not file_hash: return try: - from cmdlet.get_tag import _emit_tags_as_table + from cmdlet.metadata.tag_get import _emit_tags_as_table except Exception: return @@ -2358,7 +2357,7 @@ class PipelineHubApp(App): self.notify("Delete action requires store + hash", severity="warning", timeout=3) return query = f"hash:{hash_value}" - cmd = f"delete-file -instance {json.dumps(store_name)} -query {json.dumps(query)}" + cmd = f"file -delete -instance {json.dumps(store_name)} -query {json.dumps(query)}" self._start_pipeline_execution(cmd) return @@ -2398,11 +2397,11 @@ class PipelineHubApp(App): query = f"hash:{hash_value}" base_copy = ( - f"search-file -instance {json.dumps(store_name)} {json.dumps(query)}" - f" | add-file -instance {json.dumps(selected_store)}" + f"file -search -instance {json.dumps(store_name)} {json.dumps(query)}" + f" | file -add -instance {json.dumps(selected_store)}" ) if action == "move_to_selected_store": - delete_cmd = f"delete-file -instance {json.dumps(store_name)} -query {json.dumps(query)}" + delete_cmd = f"file -delete -instance {json.dumps(store_name)} -query {json.dumps(query)}" cmd = f"{base_copy} | @ | {delete_cmd}" else: cmd = base_copy diff --git a/TUI/menu_actions.py b/TUI/menu_actions.py index 60d5615..855c879 100644 --- a/TUI/menu_actions.py +++ b/TUI/menu_actions.py @@ -30,21 +30,21 @@ PIPELINE_PRESETS: List[PipelinePreset] = [ PipelinePreset( label="Download → Merge → Local", description= - "Use download-file with playlist auto-selection, merge the pieces, tag, then import into local storage.", + "Use file -download with playlist auto-selection, merge the pieces, tag, then import into local storage.", pipeline= - 'download-file "" | merge-file | add-tags -instance local | add-file -storage local', + 'file -download "" | file -merge | metadata -add -instance local | file -add -storage local', ), PipelinePreset( label="Download → Hydrus", description="Fetch media, auto-tag, and push directly into Hydrus.", pipeline= - 'download-file "" | merge-file | add-tags -instance hydrus | add-file -storage hydrus', + 'file -download "" | file -merge | metadata -add -instance hydrus | file -add -storage hydrus', ), PipelinePreset( label="Search Local Library", description= - "Run search-file against the local library and emit a result table for further piping.", - pipeline='search-file -library local -query ""', + "Run file -search against the local library and emit a result table for further piping.", + pipeline='file -search -library local -query ""', ), ] diff --git a/TUI/modalscreen/download.py b/TUI/modalscreen/download.py index 6cd494e..040473e 100644 --- a/TUI/modalscreen/download.py +++ b/TUI/modalscreen/download.py @@ -404,7 +404,7 @@ class DownloadModal(ModalScreen): download_succeeded = False download_stderr_text = "" # Store for merge stage if download_enabled: - download_cmdlet_name = "download-file" + download_cmdlet_name = "file" download_cmdlet = get_cmdlet(download_cmdlet_name) if download_cmdlet: logger.info(f"📥 Executing {download_cmdlet_name} stage") @@ -416,7 +416,7 @@ class DownloadModal(ModalScreen): worker.log_step(f"Starting {download_cmdlet_name} stage...") # Build yt-dlp playlist arguments for download-file streaming (if applicable). - cmdlet_args = [] + cmdlet_args = ["-download"] if self.is_playlist: # Always use yt-dlp's native --playlist-items for playlists if playlist_selection: @@ -807,7 +807,7 @@ class DownloadModal(ModalScreen): # Stage 2: Merge files if enabled and this is a playlist (BEFORE tagging) merged_file_path = None if merge_enabled and download_succeeded and self.is_playlist: - merge_cmdlet = get_cmdlet("merge-file") + merge_cmdlet = get_cmdlet("file") if merge_cmdlet: from pathlib import Path @@ -818,6 +818,7 @@ class DownloadModal(ModalScreen): worker.log_step("Starting merge-file stage...") merge_args = [ + "-merge", "-delete", "-format", "mka", @@ -963,10 +964,10 @@ class DownloadModal(ModalScreen): else: logger.info("merge-file cmdlet not found") - # Stage 3: Add tags (now after merge, if merge happened) + # Stage 3: Add metadata tags (now after merge, if merge happened) # If merge succeeded, result_obj now points to merged file if tags and (download_succeeded or not download_enabled): - add_tags_cmdlet = get_cmdlet("add-tags") + add_tags_cmdlet = get_cmdlet("metadata") if add_tags_cmdlet: logger.info(f"Executing add-tags stage with {len(tags)} tags") logger.info(f" Tags: {tags}") @@ -980,9 +981,9 @@ class DownloadModal(ModalScreen): f"Starting add-tags stage with {len(tags)} tags..." ) - # Build add-tags arguments. add-tags requires a store; for downloads, default to local sidecar tagging. + # Build metadata-tag arguments. Default to local sidecar tagging for downloads. tag_args = ( - ["-instance", + ["-add", "-instance", "local"] + [str(t) for t in tags] + ["--source", str(source)] ) @@ -1051,7 +1052,7 @@ class DownloadModal(ModalScreen): self.app.call_from_thread(self._hide_progress) return else: - logger.error("add-tags cmdlet not found") + logger.error("metadata cmdlet not found for add stage") else: if tags and download_enabled and not download_succeeded: skip_msg = "⚠️ Skipping add-tags stage because download failed" @@ -1455,7 +1456,7 @@ class DownloadModal(ModalScreen): # Tag the file if tags provided if tags and get_cmdlet: - tag_cmdlet = get_cmdlet("add-tags") + tag_cmdlet = get_cmdlet("metadata") if tag_cmdlet: logger.info(f"Tagging merged PDF with {len(tags)} tags") @@ -1475,7 +1476,7 @@ class DownloadModal(ModalScreen): stdout_buf = io.StringIO() stderr_buf = io.StringIO() - tag_args = ["-instance", "local"] + [str(t) for t in tags] + tag_args = ["-add", "-instance", "local"] + [str(t) for t in tags] with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf): tag_returncode = tag_cmdlet( result_obj, @@ -1548,7 +1549,7 @@ class DownloadModal(ModalScreen): wipe_tags_and_source: bool = False, skip_tag_scraping: bool = False ) -> None: - """Background worker to scrape metadata using get-tag cmdlet. + """Background worker to scrape metadata using metadata -get. Args: url: URL to scrape metadata from @@ -1558,7 +1559,7 @@ class DownloadModal(ModalScreen): try: logger.info(f"Metadata worker started for: {url}") - # Call get-tag cmdlet to scrape URL + # Call metadata cmdlet to scrape URL if not get_cmdlet: logger.error("cmdlet module not available") self.app.call_from_thread( @@ -1569,13 +1570,13 @@ class DownloadModal(ModalScreen): ) return - # Get the get-tag cmdlet - get_tag_cmdlet = get_cmdlet("get-tag") + # Get the metadata cmdlet + get_tag_cmdlet = get_cmdlet("metadata") if not get_tag_cmdlet: - logger.error("get-tag cmdlet not found") + logger.error("metadata cmdlet not found") self.app.call_from_thread( self.app.notify, - "get-tag cmdlet not found", + "metadata cmdlet not found", title="Error", severity="error" ) @@ -1591,7 +1592,7 @@ class DownloadModal(ModalScreen): result_obj = URLResult(url) - # Call the cmdlet with -scrape flag (unless skipping tag scraping) + # Call the cmdlet with -get/-scrape flags (unless skipping tag scraping) import io from contextlib import redirect_stdout, redirect_stderr @@ -1599,7 +1600,7 @@ class DownloadModal(ModalScreen): error_buffer = io.StringIO() # Only scrape if not skipping tag scraping - args = [] if skip_tag_scraping else ["-scrape", url] + args = ["-get"] if skip_tag_scraping else ["-get", "-scrape", url] with redirect_stdout(output_buffer), redirect_stderr(error_buffer): returncode = get_tag_cmdlet(result_obj, @@ -1608,7 +1609,7 @@ class DownloadModal(ModalScreen): if returncode != 0: error_msg = error_buffer.getvalue() - logger.error(f"get-tag cmdlet failed: {error_msg}") + logger.error(f"metadata cmdlet failed: {error_msg}") try: self.app.call_from_thread( self.app.notify, @@ -1623,11 +1624,11 @@ class DownloadModal(ModalScreen): # Parse the JSON output output = output_buffer.getvalue().strip() if not output: - logger.warning("get-tag returned no output") + logger.warning("metadata -get returned no output") try: self.app.call_from_thread( self.app.notify, - "No metadata returned from get-tag", + "No metadata returned from metadata -get", title="Error", severity="error", ) @@ -1635,7 +1636,7 @@ class DownloadModal(ModalScreen): logger.debug(f"Could not notify user: {e}") return - # Extract the JSON line (skip debug messages that start with [get-tag]) + # Extract the JSON line (skip debug messages that start with [tag]) json_line = None for line in output.split("\n"): if line.strip().startswith("{"): @@ -1643,7 +1644,7 @@ class DownloadModal(ModalScreen): break if not json_line: - logger.error("No JSON found in get-tag output") + logger.error("No JSON found in metadata -get output") logger.debug(f"Raw output: {output}") try: self.app.call_from_thread( @@ -1832,7 +1833,7 @@ class DownloadModal(ModalScreen): # Stage 1: Download data if enabled if download_enabled: - download_cmdlet_name = "download-file" + download_cmdlet_name = "file" download_cmdlet = get_cmdlet(download_cmdlet_name) if download_cmdlet: stage_msg = f"📥 Executing {download_cmdlet_name} stage" @@ -1853,7 +1854,7 @@ class DownloadModal(ModalScreen): ) if isinstance(cmd_config, dict): cmd_config["_quiet_background_output"] = True - returncode = download_cmdlet(result_obj, [], cmd_config) + returncode = download_cmdlet(result_obj, ["-download"], cmd_config) stdout_text = stdout_buf.getvalue() stderr_text = stderr_buf.getvalue() @@ -1902,14 +1903,14 @@ class DownloadModal(ModalScreen): # Stage 2: Tag the file if tags provided if tags: - tag_cmdlet = get_cmdlet("add-tags") + tag_cmdlet = get_cmdlet("metadata") if tag_cmdlet and result_obj.get("path"): stage_msg = f"🏷️ Tagging with {len(tags)} tags" logger.info(stage_msg) if worker: worker.append_stdout(f"{stage_msg}\n") try: - tag_args = tags + tag_args = ["-add"] + [str(t) for t in tags] import io from contextlib import redirect_stdout, redirect_stderr diff --git a/cmdlet/__init__.py b/cmdlet/__init__.py index 64e00dc..5c896b2 100644 --- a/cmdlet/__init__.py +++ b/cmdlet/__init__.py @@ -89,7 +89,20 @@ def _load_root_modules() -> None: def _load_helper_modules() -> None: # Provider-specific module pre-loading removed; providers are loaded lazily # through ProviderCore.registry when first referenced. - pass + # + # Keep explicit imports for cmdlets that were moved under cmdlet/file so they + # remain registered under their legacy command names (add-note/add-url/add-relationship). + for mod in ( + ".file.add_note", + ".file.add_url", + ".file.add_relationship", + ".metadata.get_note", + ".metadata.get_relationship", + ): + try: + _import_module(mod, __name__) + except Exception as exc: + print(f"Error importing cmdlet helper '{mod}': {exc}", file=sys.stderr) def _register_native_commands() -> None: diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index 8ca61f8..58ce704 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -1235,6 +1235,13 @@ def run_store_note_batches( if on_store_error is not None and exc is not None: on_store_error(store_name, exc) continue + supports_note_capability = getattr(backend, "supports_note_association", None) + if supports_note_capability is None: + supports_note_capability = hasattr(backend, "set_note") + if not bool(supports_note_capability): + if on_unsupported_store is not None: + on_unsupported_store(store_name) + continue if not hasattr(backend, "set_note"): if on_unsupported_store is not None: on_unsupported_store(store_name) diff --git a/cmdlet/file/__init__.py b/cmdlet/file/__init__.py new file mode 100644 index 0000000..df790da --- /dev/null +++ b/cmdlet/file/__init__.py @@ -0,0 +1,3 @@ +"""File action cmdlets package.""" + +__all__ = [] diff --git a/cmdlet/add_file.py b/cmdlet/file/add.py similarity index 95% rename from cmdlet/add_file.py rename to cmdlet/file/add.py index 05a27e3..ba5c013 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/file/add.py @@ -19,7 +19,7 @@ from SYS.rich_display import show_available_plugins_panel, show_plugin_config_pa from SYS.utils_constant import ALL_SUPPORTED_EXTENSIONS from Store import Store from API.HTTP import _download_direct_file -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg @@ -862,7 +862,7 @@ class Add_File(Cmdlet): subject = collected_payloads[0] if len(collected_payloads) == 1 else collected_payloads # Use helper to display items and make them @-selectable - from ._shared import display_and_persist_items + from .._shared import display_and_persist_items display_and_persist_items(collected_payloads, title="Result", subject=subject) try: @@ -911,7 +911,7 @@ class Add_File(Cmdlet): return None try: - from cmdlet.search_file import CMDLET as search_file_cmdlet + from cmdlet.file.search import CMDLET as search_file_cmdlet query = "hash:" + ",".join(hashes) args = ["-instance", str(instance), "-internal-refresh", query] @@ -1135,6 +1135,9 @@ class Add_File(Cmdlet): except Exception: continue + if not bool(getattr(backend, "supports_relationship_association", False)): + continue + setter = getattr(backend, "set_relationship", None) if not callable(setter): continue @@ -1981,7 +1984,7 @@ class Add_File(Cmdlet): return try: - from ._shared import display_and_persist_items + from .._shared import display_and_persist_items display_and_persist_items([payload], title="Result", subject=payload) except Exception: @@ -2045,7 +2048,7 @@ class Add_File(Cmdlet): Returns the emitted search-file payload items on success, else None. """ try: - from cmdlet.search_file import CMDLET as search_file_cmdlet + from cmdlet.file.search import CMDLET as search_file_cmdlet args = ["-instance", str(instance), f"hash:{str(hash_value)}"] @@ -2233,6 +2236,63 @@ class Add_File(Cmdlet): pipe_obj.extra["url"] = merged_url return merged_tags, merged_url, preferred_title, file_hash + @staticmethod + def _normalize_hash_candidate(value: Any) -> str: + text = str(value or "").strip().lower() + if len(text) != 64: + return "" + if any(ch not in "0123456789abcdef" for ch in text): + return "" + return text + + @staticmethod + def _find_existing_hash_by_urls( + backend: Any, + urls: Sequence[str], + ) -> Optional[str]: + """Best-effort duplicate detection by URL before ingesting file bytes.""" + url_candidates: List[str] = [] + for raw in urls or []: + text = str(raw or "").strip() + if not text or not Add_File._is_probable_url(text): + continue + if text not in url_candidates: + url_candidates.append(text) + + if not url_candidates: + return None + + lookup_exact = getattr(backend, "find_hashes_by_url", None) + if callable(lookup_exact): + for candidate_url in url_candidates: + try: + hashes = lookup_exact(candidate_url) or [] + except Exception: + continue + if not isinstance(hashes, (list, tuple, set)): + continue + for item in hashes: + normalized = Add_File._normalize_hash_candidate(item) + if normalized: + return normalized + + searcher = getattr(backend, "search", None) + if callable(searcher): + for candidate_url in url_candidates: + try: + hits = searcher(f"url:{candidate_url}", limit=1, minimal=True) or [] + except Exception: + continue + if not isinstance(hits, list) or not hits: + continue + hit = hits[0] + for key in ("hash", "file_hash", "sha256"): + normalized = Add_File._normalize_hash_candidate(get_field(hit, key)) + if normalized: + return normalized + + return None + @staticmethod def _handle_local_export( media_path: Path, @@ -2383,8 +2443,6 @@ class Add_File(Cmdlet): list_plugins_with_capability, ) - log(f"Uploading via {plugin_name}: {media_path.name}", file=sys.stderr) - try: file_provider = get_plugin_with_capability(plugin_name, "upload", config) if not file_provider: @@ -2406,7 +2464,36 @@ class Add_File(Cmdlet): pipe_obj=pipe_obj, instance=instance_name, ) - log(f"File uploaded: {hoster_url}", file=sys.stderr) + + duplicate_upload = False + duplicate_rule = "" + duplicate_target = "" + try: + if isinstance(getattr(pipe_obj, "extra", None), dict): + duplicate_upload = bool(pipe_obj.extra.get("upload_duplicate")) + duplicate_rule = str(pipe_obj.extra.get("upload_duplicate_rule") or "").strip() + duplicate_target = str(pipe_obj.extra.get("upload_duplicate_target") or "").strip() + except Exception: + duplicate_upload = False + duplicate_rule = "" + duplicate_target = "" + + try: + debug_panel( + "add-file plugin upload", + [ + ("plugin", plugin_name), + ("instance", instance_name or ""), + ("source", media_path), + ("duplicate", duplicate_upload), + ("rule", duplicate_rule or "none"), + ("target", duplicate_target or ""), + ("url", hoster_url), + ], + border_style="yellow" if duplicate_upload else "green", + ) + except Exception: + pass f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None) @@ -2505,11 +2592,21 @@ class Add_File(Cmdlet): try: store = store_instance if store_instance is not None else Store(config) - backend = store[backend_name] + backend, store, backend_exc = sh.get_preferred_store_backend( + config, + backend_name, + store_registry=store, + suppress_debug=True, + ) + if backend is None: + raise backend_exc or KeyError(f"Unknown store backend: {backend_name}") # Use backend properties to drive metadata deferral behavior. is_remote_backend = getattr(backend, "is_remote", False) prefer_defer_tags = getattr(backend, "prefer_defer_tags", False) + supports_url_association = bool(getattr(backend, "supports_url_association", False)) + supports_note_association = bool(getattr(backend, "supports_note_association", False)) + supports_relationship_association = bool(getattr(backend, "supports_relationship_association", False)) # ... # Prepare metadata from pipe_obj and sidecars @@ -2573,7 +2670,7 @@ class Add_File(Cmdlet): pass # Collect relationship pairs for post-ingest DB/API persistence. - if collect_relationship_pairs is not None: + if collect_relationship_pairs is not None and supports_relationship_association: rels = Add_File._get_relationships(result, pipe_obj) if isinstance(rels, dict) and rels: king_hash, alt_hashes = Add_File._parse_relationships_king_alts(rels) @@ -2622,16 +2719,23 @@ class Add_File(Cmdlet): except Exception: pass - # Call backend's add_file with full metadata - # Backend returns hash as identifier. If we already know the hash from _resolve_source - # (which came from download-file emit), pass it to skip re-hashing the 4GB file. - file_identifier = backend.add_file( - media_path, - title=title, - tag=upload_tags, - url=[] if (defer_url_association and url) else url, - file_hash=f_hash, - ) + duplicate_hash = Add_File._find_existing_hash_by_urls(backend, url) + if duplicate_hash: + debug( + f"[add-file] URL duplicate detected in '{backend_name}', skipping upload and reusing hash {duplicate_hash[:12]}..." + ) + file_identifier = duplicate_hash + else: + # Call backend's add_file with full metadata. + # Backend returns hash as identifier. If we already know the hash from _resolve_source + # (which came from download-file emit), pass it to skip re-hashing large files. + file_identifier = backend.add_file( + media_path, + title=title, + tag=upload_tags, + url=[] if ((defer_url_association and url) or (not supports_url_association)) else url, + file_hash=f_hash, + ) ##log(f"✓ File added to '{backend_name}': {file_identifier}", file=sys.stderr) stored_path: Optional[str] = None @@ -2687,7 +2791,7 @@ class Add_File(Cmdlet): # If we have url(s), ensure they get associated with the destination file. # This mirrors `add-url` behavior but avoids emitting extra pipeline noise. - if url: + if url and supports_url_association: if defer_url_association and pending_url_associations is not None: try: pending_url_associations.setdefault( @@ -2708,7 +2812,7 @@ class Add_File(Cmdlet): # If a subtitle note was provided upstream (e.g., download-media writes notes.sub), # persist it automatically like add-note would. sub_note = Add_File._get_note_text(result, pipe_obj, "sub") - if sub_note: + if sub_note and supports_note_association: try: setter = getattr(backend, "set_note", None) if callable(setter): @@ -2726,7 +2830,7 @@ class Add_File(Cmdlet): ) lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric") - if lyric_note: + if lyric_note and supports_note_association: try: setter = getattr(backend, "set_note", None) if callable(setter): @@ -2744,7 +2848,7 @@ class Add_File(Cmdlet): ) chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters") - if chapters_note: + if chapters_note and supports_note_association: try: setter = getattr(backend, "set_note", None) if callable(setter): @@ -2762,7 +2866,7 @@ class Add_File(Cmdlet): ) caption_note = Add_File._get_note_text(result, pipe_obj, "caption") - if caption_note: + if caption_note and supports_note_association: try: setter = getattr(backend, "set_note", None) if callable(setter): @@ -2905,6 +3009,9 @@ class Add_File(Cmdlet): if backend is None: continue + if not bool(getattr(backend, "supports_url_association", False)): + continue + items = sh.coalesce_hash_value_pairs(pairs) if not items: continue diff --git a/cmdlet/add_note.py b/cmdlet/file/add_note.py similarity index 97% rename from cmdlet/add_note.py rename to cmdlet/file/add_note.py index facf991..54da53a 100644 --- a/cmdlet/add_note.py +++ b/cmdlet/file/add_note.py @@ -8,7 +8,7 @@ import re from SYS.logger import log from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg @@ -213,6 +213,12 @@ class Add_Note(Cmdlet): ) if backend is None: raise exc or KeyError(store_override) + if not bool(getattr(backend, "supports_note_association", False)): + log( + f"[add_note] Error: Store '{store_override}' does not support notes", + file=sys.stderr, + ) + return 1 ok = bool( backend.set_note( str(hash_override), diff --git a/cmdlet/add_relationship.py b/cmdlet/file/add_relationship.py similarity index 99% rename from cmdlet/add_relationship.py rename to cmdlet/file/add_relationship.py index b888676..93ac2db 100644 --- a/cmdlet/add_relationship.py +++ b/cmdlet/file/add_relationship.py @@ -12,7 +12,7 @@ from SYS.item_accessors import get_sha256_hex, get_store_name from ProviderCore.registry import get_plugin from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg @@ -603,6 +603,12 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int: if store_name: backend, _store_registry, _exc = sh.get_store_backend(config, str(store_name)) if backend is not None: + if not bool(getattr(backend, "supports_relationship_association", False)): + log( + f"Store '{store_name}' does not support relationships", + file=sys.stderr, + ) + return 1 loc = getattr(backend, "location", None) if callable(loc): is_folder_store = True diff --git a/cmdlet/add_url.py b/cmdlet/file/add_url.py similarity index 86% rename from cmdlet/add_url.py rename to cmdlet/file/add_url.py index d5ab731..5986766 100644 --- a/cmdlet/add_url.py +++ b/cmdlet/file/add_url.py @@ -4,7 +4,7 @@ from typing import Any, Dict, List, Sequence, Tuple import sys from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh from SYS.logger import log from Store import Store @@ -135,10 +135,25 @@ class Add_Url(sh.Cmdlet): on_warning=_warn, ) + supported_batch: Dict[str, List[Tuple[str, Sequence[str]]]] = {} + for store_text, store_pairs in batch.items(): + backend, storage, _exc = sh.get_store_backend( + config, + store_text, + store_registry=storage, + ) + if backend is None: + _warn(f"Store '{store_text}' not configured; skipping") + continue + if not bool(getattr(backend, "supports_url_association", False)): + _warn(f"Store '{store_text}' does not support URLs; skipping") + continue + supported_batch[store_text] = store_pairs + # Execute per-instance batches. storage, batch_stats = sh.run_store_hash_value_batches( config, - batch, + supported_batch, bulk_method_name="add_url_bulk", single_method_name="add_url", store_registry=storage, @@ -166,6 +181,9 @@ class Add_Url(sh.Cmdlet): if backend is None: log(f"Error: Storage backend '{store_name}' not configured") return 1 + if not bool(getattr(backend, "supports_url_association", False)): + log(f"Error: Store '{store_name}' does not support URL associations") + return 1 backend.add_url(str(file_hash), urls, config=config) ctx.print_if_visible( f"✓ add-url: {len(urls)} url(s) added", diff --git a/cmdlet/archive_file.py b/cmdlet/file/archive.py similarity index 99% rename from cmdlet/archive_file.py rename to cmdlet/file/archive.py index eb3aa20..a3a31ed 100644 --- a/cmdlet/archive_file.py +++ b/cmdlet/file/archive.py @@ -19,7 +19,7 @@ from SYS.utils import extract_hydrus_hash_from_url from SYS import pipeline as ctx from SYS.config import resolve_output_dir -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/convert_file.py b/cmdlet/file/convert.py similarity index 99% rename from cmdlet/convert_file.py rename to cmdlet/file/convert.py index 7882532..7500848 100644 --- a/cmdlet/convert_file.py +++ b/cmdlet/file/convert.py @@ -9,7 +9,7 @@ import subprocess from SYS.logger import log, debug from SYS.payload_builders import build_file_result_payload from SYS.utils import sha256_file -from . import _shared as sh +from .. import _shared as sh from SYS import pipeline as ctx Cmdlet = sh.Cmdlet diff --git a/cmdlet/delete_file.py b/cmdlet/file/delete.py similarity index 99% rename from cmdlet/delete_file.py rename to cmdlet/file/delete.py index fb11bbe..4a4387e 100644 --- a/cmdlet/delete_file.py +++ b/cmdlet/file/delete.py @@ -10,7 +10,7 @@ from pathlib import Path from SYS.logger import debug, log from ProviderCore.registry import get_plugin from Store import Store -from . import _shared as sh +from .. import _shared as sh from SYS import pipeline as ctx from SYS.result_table_helpers import add_row_columns from SYS.result_table import Table, _format_size diff --git a/cmdlet/download_file.py b/cmdlet/file/download.py similarity index 90% rename from cmdlet/download_file.py rename to cmdlet/file/download.py index fb8b028..635bff0 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/file/download.py @@ -24,6 +24,7 @@ from SYS.pipeline_progress import PipelineProgress from SYS.result_table import Table from SYS.rich_display import stderr_console as get_stderr_console from SYS import pipeline as pipeline_context +from rich.prompt import Prompt # SYS.metadata import deferred: normalize_urls loaded lazily at call site to avoid # pulling in Cryptodome (~900ms) at module import time. from SYS.selection_builder import ( @@ -38,7 +39,7 @@ try: except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers YtDlpTool = None # type: ignore -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg @@ -936,7 +937,7 @@ class Download_File(Cmdlet): try: subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items) # Use helper to display items and make them @-selectable - from ._shared import display_and_persist_items + from .._shared import display_and_persist_items display_and_persist_items(list(emitted_items), title="Result", subject=subject) except Exception: pass @@ -984,56 +985,13 @@ class Download_File(Cmdlet): def _find_existing_hash_for_url( cls, storage: Any, canonical_url: str, *, hydrus_available: bool ) -> Optional[str]: - if storage is None or not canonical_url: - return None - hydrus_provider = None - try: - registry_helpers = cls._load_provider_registry() - get_plugin = registry_helpers.get("get_plugin") - if callable(get_plugin): - hydrus_provider = get_plugin("hydrusnetwork", {}) - except Exception: - hydrus_provider = None - - try: - backend_names = list(storage.list_searchable_backends() or []) - except Exception: - backend_names = [] - - for backend_name in backend_names: - try: - backend = storage[backend_name] - except Exception: - continue - try: - if str(backend_name).strip().lower() == "temp": - continue - except Exception: - pass - try: - if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)) and not hydrus_available: - continue - except Exception: - pass - - try: - if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(backend_name)): - hashes = backend.find_hashes_by_url(canonical_url) or [] - for existing_hash in hashes: - normalized = sh.normalize_hash(existing_hash) - if normalized: - return normalized - continue - - hits = backend.search(f"url:{canonical_url}", limit=5) or [] - except Exception: - hits = [] - for hit in hits: - extracted = cls._extract_hash_from_search_hit(hit) - if extracted: - return extracted - - return None + hashes = cls._find_existing_hashes_for_url( + storage, + canonical_url, + hydrus_available=hydrus_available, + config={}, + ) + return hashes[0] if hashes else None @staticmethod def _init_storage(config: Dict[str, Any]) -> tuple[Any, bool]: @@ -1265,6 +1223,205 @@ class Download_File(Cmdlet): download_timeout_seconds=int(config.get("_pipeobject_timeout_seconds") or 300) if isinstance(config, dict) else 300, ) + @classmethod + def _find_existing_hashes_for_url( + cls, + storage: Any, + canonical_url: str, + *, + hydrus_available: bool, + config: Optional[Dict[str, Any]] = None, + ) -> List[str]: + if not canonical_url: + return [] + + config_dict = config if isinstance(config, dict) else {} + found_hashes: List[str] = [] + seen_hashes: set[str] = set() + seen_backends: set[str] = set() + + def _add_hash(value: Any) -> None: + normalized = sh.normalize_hash(str(value) if value is not None else None) + if not normalized or normalized in seen_hashes: + return + seen_hashes.add(normalized) + found_hashes.append(normalized) + + def _iter_backends() -> List[tuple[str, Any]]: + backends: List[tuple[str, Any]] = [] + if storage is not None: + try: + backend_names = list(storage.list_searchable_backends() or []) + except Exception: + backend_names = [] + + for backend_name in backend_names: + try: + backend = storage[backend_name] + except Exception: + continue + name_text = str(backend_name).strip() + if not name_text: + continue + if name_text.lower() == "temp": + continue + key = name_text.lower() + if key in seen_backends: + continue + seen_backends.add(key) + backends.append((name_text, backend)) + + # Hydrus can be plugin-configured without appearing in Store.list_searchable_backends(). + try: + registry_helpers = cls._load_provider_registry() + get_plugin = registry_helpers.get("get_plugin") + hydrus_provider = get_plugin("hydrusnetwork", config_dict) if callable(get_plugin) else None + if hydrus_provider is not None: + for backend_name, backend in hydrus_provider.iter_backends(): + name_text = str(backend_name or "").strip() + if not name_text: + continue + key = name_text.lower() + if key in seen_backends: + continue + seen_backends.add(key) + backends.append((name_text, backend)) + except Exception: + pass + + return backends + + for backend_name, backend in _iter_backends(): + try: + if not hydrus_available and str(getattr(backend, "STORE_TYPE", "")).strip().lower() == "hydrusnetwork": + continue + except Exception: + pass + + lookup_exact = getattr(backend, "find_hashes_by_url", None) + if callable(lookup_exact): + try: + hashes = lookup_exact(canonical_url) or [] + except Exception: + hashes = [] + if isinstance(hashes, (list, tuple, set)): + for existing_hash in hashes: + _add_hash(existing_hash) + if found_hashes: + continue + + searcher = getattr(backend, "search", None) + if callable(searcher): + try: + hits = searcher(f"url:{canonical_url}", limit=5, minimal=True) or [] + except Exception: + hits = [] + for hit in hits: + extracted = cls._extract_hash_from_search_hit(hit) + _add_hash(extracted) + + return found_hashes + + def _preflight_explicit_url_duplicates( + self, + *, + raw_urls: Sequence[str], + config: Dict[str, Any], + ) -> tuple[List[str], Optional[int], int]: + """Return (urls_to_process, early_exit, skipped_count).""" + urls = [str(u or "").strip() for u in (raw_urls or []) if str(u or "").strip()] + if not urls: + return [], None, 0 + + if bool(config.get("_skip_url_preflight")): + return urls, None, 0 + + storage, hydrus_available = self._init_storage(config) + duplicates: Dict[str, List[str]] = {} + for url in urls: + found = self._find_existing_hashes_for_url( + storage, + url, + hydrus_available=hydrus_available, + config=config, + ) + if found: + duplicates[url] = found + + if not duplicates: + return urls, None, 0 + + duplicate_count = len(duplicates) + total_count = len(urls) + try: + debug_panel( + "download-file duplicate preflight", + [ + ("total_urls", total_count), + ("duplicate_urls", duplicate_count), + ], + border_style="yellow", + ) + except Exception: + pass + + table = Table(f"Duplicate URLs detected ({duplicate_count}/{total_count})", max_columns=8) + table._interactive(False) + for url, hashes in duplicates.items(): + table.add_result( + build_table_result_payload( + title="(exists)", + columns=[ + ("URL", url), + ("Hash", str((hashes[0] if hashes else "") or "")), + ], + url=url, + hash=str((hashes[0] if hashes else "") or ""), + ) + ) + + try: + stdin_interactive = bool(sys.stdin and sys.stdin.isatty()) + except Exception: + stdin_interactive = False + + policy = "skip" + if stdin_interactive: + console = get_stderr_console() + console.print(table) + policy = Prompt.ask( + "Duplicate URLs found. Action?", + choices=["ignore", "skip", "cancel"], + default="skip", + console=console, + ) + else: + # Safe default in non-interactive runs: avoid redownloading known duplicates. + policy = "skip" + try: + get_stderr_console().print(table) + except Exception: + pass + + if policy == "cancel": + try: + pipeline_context.request_pipeline_stop(reason="duplicate-url cancelled", exit_code=0) + except Exception: + pass + return [], 0, 0 + + if policy == "ignore": + return urls, None, 0 + + filtered = [u for u in urls if u not in duplicates] + skipped = len(urls) - len(filtered) + if skipped: + try: + log(f"Skipped {skipped} duplicate URL(s); processing remaining {len(filtered)}.", file=sys.stderr) + except Exception: + pass + return filtered, None, skipped + @staticmethod def _format_timecode(seconds: int, *, force_hours: bool) -> str: total = max(0, int(seconds)) @@ -1739,8 +1896,18 @@ class Download_File(Cmdlet): items_preview=preview ) + raw_url, preflight_exit, skipped_dupe_count = self._preflight_explicit_url_duplicates( + raw_urls=raw_url, + config=config, + ) + if preflight_exit is not None: + return int(preflight_exit) + downloaded_count = 0 + if skipped_dupe_count and not raw_url and not piped_items: + return 0 + urls_downloaded, early_exit = self._process_explicit_urls( raw_urls=raw_url, final_output_dir=final_output_dir, diff --git a/cmdlet/get_file.py b/cmdlet/file/get.py similarity index 99% rename from cmdlet/get_file.py rename to cmdlet/file/get.py index c80fc92..37fe254 100644 --- a/cmdlet/get_file.py +++ b/cmdlet/file/get.py @@ -16,7 +16,7 @@ from urllib.parse import urljoin from urllib.request import pathname2url from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh from SYS.item_accessors import get_result_title from SYS.logger import log, debug, debug_panel from SYS.config import resolve_output_dir diff --git a/cmdlet/merge_file.py b/cmdlet/file/merge.py similarity index 99% rename from cmdlet/merge_file.py rename to cmdlet/file/merge.py index 56c8014..c2ac466 100644 --- a/cmdlet/merge_file.py +++ b/cmdlet/file/merge.py @@ -13,7 +13,7 @@ import re as _re from SYS.config import resolve_output_dir -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/screen_shot.py b/cmdlet/file/screenshot.py similarity index 99% rename from cmdlet/screen_shot.py rename to cmdlet/file/screenshot.py index 1430a1e..a5daca2 100644 --- a/cmdlet/screen_shot.py +++ b/cmdlet/file/screenshot.py @@ -23,7 +23,7 @@ from SYS.item_accessors import extract_item_tags, get_result_title from API.HTTP import HTTPClient from SYS.pipeline_progress import PipelineProgress from SYS.utils import ensure_directory, sha256_file, unique_path, unique_preserve_order -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/search_file.py b/cmdlet/file/search.py similarity index 99% rename from cmdlet/search_file.py rename to cmdlet/file/search.py index b60a352..fd125da 100644 --- a/cmdlet/search_file.py +++ b/cmdlet/file/search.py @@ -26,7 +26,7 @@ from SYS.item_accessors import get_extension_field, get_int_field, get_result_ti from SYS.selection_builder import build_default_selection from SYS.result_publication import publish_result_table -from ._shared import ( +from .._shared import ( Cmdlet, CmdletArg, SharedArgs, diff --git a/cmdlet/trim_file.py b/cmdlet/file/trim.py similarity index 99% rename from cmdlet/trim_file.py rename to cmdlet/file/trim.py index 8c9bfd5..97d8990 100644 --- a/cmdlet/trim_file.py +++ b/cmdlet/file/trim.py @@ -14,7 +14,7 @@ from urllib.parse import urlparse from SYS.logger import log, debug from SYS.item_accessors import get_store_name from SYS.utils import sha256_file -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/file_cmdlet.py b/cmdlet/file_cmdlet.py new file mode 100644 index 0000000..502cfed --- /dev/null +++ b/cmdlet/file_cmdlet.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from importlib import import_module +from typing import Any, Dict, List, Sequence +import sys + +from SYS.logger import log +from . import _shared as sh + +Cmdlet = sh.Cmdlet +CmdletArg = sh.CmdletArg +SharedArgs = sh.SharedArgs + + +class File(Cmdlet): + """Unified file command: file -add|-delete|-get|-merge|...""" + + _ACTION_FLAGS = { + "add": {"-add", "--add"}, + "delete": {"-delete", "--delete", "-del", "--del"}, + "get": {"-get", "--get"}, + "merge": {"-merge", "--merge"}, + "download": {"-download", "--download", "-dl", "--dl"}, + "search": {"-search", "--search"}, + "convert": {"-convert", "--convert"}, + "trim": {"-trim", "--trim"}, + "archive": {"-archive", "--archive"}, + "screenshot": {"-screenshot", "--screenshot", "-screen-shot", "--screen-shot", "-shot", "--shot"}, + } + + _ACTION_MODULE = { + "add": "cmdlet.file.add", + "delete": "cmdlet.file.delete", + "get": "cmdlet.file.get", + "merge": "cmdlet.file.merge", + "download": "cmdlet.file.download", + "search": "cmdlet.file.search", + "convert": "cmdlet.file.convert", + "trim": "cmdlet.file.trim", + "archive": "cmdlet.file.archive", + "screenshot": "cmdlet.file.screenshot", + } + + def __init__(self) -> None: + super().__init__( + name="file", + summary="Manage file operations with one command", + usage='file (-add|-delete|-get|-merge|-download|-search|-convert|-trim|-archive|-screenshot) [args]', + arg=[ + SharedArgs.QUERY, + SharedArgs.INSTANCE, + SharedArgs.PATH, + CmdletArg("-add", type="flag", required=False, description="Run add-file"), + CmdletArg("-delete", type="flag", required=False, description="Run delete-file", alias="del"), + CmdletArg("-get", type="flag", required=False, description="Run get-file"), + CmdletArg("-merge", type="flag", required=False, description="Run merge-file"), + CmdletArg("-download", type="flag", required=False, description="Run download-file", alias="dl"), + CmdletArg("-search", type="flag", required=False, description="Run search-file"), + CmdletArg("-convert", type="flag", required=False, description="Run convert-file"), + CmdletArg("-trim", type="flag", required=False, description="Run trim-file"), + CmdletArg("-archive", type="flag", required=False, description="Run archive-file"), + CmdletArg("-screenshot", type="flag", required=False, description="Run screen-shot", alias="shot"), + ], + detail=[ + "- Exactly one action flag is required.", + "- Remaining args are passed through to the selected file cmdlet.", + "- Examples: file -add ..., file -delete ..., file -merge ...", + ], + exec=self.run, + ) + self.register() + + @classmethod + def _extract_action(cls, args: Sequence[str]) -> tuple[str | None, List[str], List[str]]: + matched_actions: List[str] = [] + passthrough: List[str] = [] + + for token in args or []: + text = str(token or "") + lower = text.strip().lower() + matched = None + for action_name, variants in cls._ACTION_FLAGS.items(): + if lower in variants: + matched = action_name + break + if matched: + matched_actions.append(matched) + continue + passthrough.append(text) + + unique_actions: List[str] = [] + for action in matched_actions: + if action not in unique_actions: + unique_actions.append(action) + + if len(unique_actions) != 1: + return None, passthrough, unique_actions + return unique_actions[0], passthrough, unique_actions + + @classmethod + def _dispatch(cls, action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + module_name = cls._ACTION_MODULE.get(action) + if not module_name: + log(f"file: unsupported action '{action}'", file=sys.stderr) + return 1 + + module = import_module(module_name) + + cmdlet_obj = getattr(module, "CMDLET", None) + if cmdlet_obj is not None: + exec_fn = getattr(cmdlet_obj, "exec", None) + if callable(exec_fn): + return int(exec_fn(result, args, config)) + + fallback_run = getattr(module, "_run", None) + if callable(fallback_run): + return int(fallback_run(result, args, config)) + + log(f"file: cannot dispatch action '{action}' via module '{module_name}'", file=sys.stderr) + return 1 + + def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + action, passthrough_args, seen = self._extract_action(args) + + if action is None: + if not seen: + log( + "file: missing action flag; choose exactly one of -add, -delete, -get, -merge, -download, -search, -convert, -trim, -archive, -screenshot", + file=sys.stderr, + ) + else: + rendered = ", ".join(f"-{name}" for name in seen) + log(f"file: conflicting actions ({rendered}); choose exactly one", file=sys.stderr) + return 1 + + return self._dispatch(action, result, passthrough_args, config) + + +CMDLET = File() diff --git a/cmdlet/metadata/__init__.py b/cmdlet/metadata/__init__.py new file mode 100644 index 0000000..dff9a49 --- /dev/null +++ b/cmdlet/metadata/__init__.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +"""Metadata domain command handlers. + +This package centralizes routing for metadata sub-domains (tag, url, +relationship, note, inspect) behind the top-level `metadata` cmdlet. +""" + +from .tag import run_tag_action +from .url import run_url_action +from .relationship import run_relationship_action +from .note import run_note_action +from .inspect import run_inspect_action + +__all__ = [ + "run_tag_action", + "run_url_action", + "run_relationship_action", + "run_note_action", + "run_inspect_action", +] diff --git a/cmdlet/get_note.py b/cmdlet/metadata/get_note.py similarity index 99% rename from cmdlet/get_note.py rename to cmdlet/metadata/get_note.py index ed99e69..56a6c54 100644 --- a/cmdlet/get_note.py +++ b/cmdlet/metadata/get_note.py @@ -10,7 +10,7 @@ from SYS.result_publication import publish_result_table from SYS.result_table_helpers import add_row_columns from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/get_relationship.py b/cmdlet/metadata/get_relationship.py similarity index 99% rename from cmdlet/get_relationship.py rename to cmdlet/metadata/get_relationship.py index 43c8507..628c402 100644 --- a/cmdlet/get_relationship.py +++ b/cmdlet/metadata/get_relationship.py @@ -11,7 +11,7 @@ from SYS.selection_builder import build_hash_store_selection from SYS.result_publication import publish_result_table from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg diff --git a/cmdlet/metadata/inspect.py b/cmdlet/metadata/inspect.py new file mode 100644 index 0000000..32e090b --- /dev/null +++ b/cmdlet/metadata/inspect.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + + +def run_inspect_action(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Route metadata inspect to get-metadata implementation.""" + from cmdlet.get_metadata import CMDLET as GET_METADATA_CMDLET + + exec_fn = getattr(GET_METADATA_CMDLET, "exec", None) + if callable(exec_fn): + return int(exec_fn(result, args, config)) + return int(GET_METADATA_CMDLET.run(result, args, config)) diff --git a/cmdlet/metadata/note.py b/cmdlet/metadata/note.py new file mode 100644 index 0000000..6ae4db3 --- /dev/null +++ b/cmdlet/metadata/note.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + + +def run_note_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Route metadata note actions to note cmdlets.""" + act = str(action or "").strip().lower() + + if act == "add": + from cmdlet.file.add_note import CMDLET as ADD_NOTE_CMDLET + + return int(ADD_NOTE_CMDLET.run(result, args, config)) + + if act == "delete": + from cmdlet.delete_note import CMDLET as DELETE_NOTE_CMDLET + + return int(DELETE_NOTE_CMDLET.run(result, args, config)) + + if act == "get": + from cmdlet.metadata.get_note import CMDLET as GET_NOTE_CMDLET + + return int(GET_NOTE_CMDLET.run(result, args, config)) + + return 1 diff --git a/cmdlet/metadata/relationship.py b/cmdlet/metadata/relationship.py new file mode 100644 index 0000000..bfb6678 --- /dev/null +++ b/cmdlet/metadata/relationship.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + + +def run_relationship_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Route metadata relationship actions to relationship cmdlets.""" + act = str(action or "").strip().lower() + + if act == "add": + from cmdlet.file.add_relationship import _run as run_add_relationship + + return int(run_add_relationship(result, args, config)) + + if act == "delete": + from cmdlet.delete_relationship import _run as run_delete_relationship + + return int(run_delete_relationship(list(args), config)) + + if act == "get": + from cmdlet.metadata.get_relationship import _run as run_get_relationship + + return int(run_get_relationship(result, args, config)) + + return 1 diff --git a/cmdlet/metadata/tag.py b/cmdlet/metadata/tag.py new file mode 100644 index 0000000..c57e8a3 --- /dev/null +++ b/cmdlet/metadata/tag.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + + +def run_tag_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Route metadata tag actions to the existing tag implementations.""" + act = str(action or "").strip().lower() + + if act == "add": + from cmdlet.metadata.tag_add import Add_Tag + + return Add_Tag(register_cmdlet=False).run(result, args, config) + + if act == "delete": + from cmdlet.metadata.tag_delete import _run as run_delete + + return run_delete(result, args, config) + + if act == "get": + from cmdlet.metadata.tag_get import _run as run_get + + return run_get(result, args, config) + + return 1 diff --git a/cmdlet/add_tag.py b/cmdlet/metadata/tag_add.py similarity index 98% rename from cmdlet/add_tag.py rename to cmdlet/metadata/tag_add.py index 2f89cea..1af8b2e 100644 --- a/cmdlet/add_tag.py +++ b/cmdlet/metadata/tag_add.py @@ -12,7 +12,7 @@ from SYS.result_publication import publish_result_table from SYS import models from SYS import pipeline as ctx -from . import _shared as sh +from .. import _shared as sh from Store import Store # retained for test monkeypatch compatibility normalize_result_input = sh.normalize_result_input @@ -411,7 +411,7 @@ def _refresh_tag_view( get_tag = None try: - get_tag = get_cmdlet("get-tag") + get_tag = get_cmdlet("metadata") except Exception: get_tag = None if not callable(get_tag): @@ -422,7 +422,7 @@ def _refresh_tag_view( if not subject or not _matches_target(subject, target_hash, target_path, store_name): return - refresh_args: List[str] = ["-query", f"hash:{target_hash}"] + refresh_args: List[str] = ["-get", "-query", f"hash:{target_hash}"] # Build a lean subject so get-tag fetches fresh tags instead of reusing cached payloads. def _build_refresh_subject() -> Dict[str, Any]: @@ -463,14 +463,14 @@ def _refresh_tag_view( class Add_Tag(Cmdlet): - """Class-based add-tag cmdlet with Cmdlet metadata inheritance.""" + """Class-based metadata -add tag handler with Cmdlet metadata inheritance.""" - def __init__(self) -> None: + def __init__(self, *, register_cmdlet: bool = True) -> None: super().__init__( - name="add-tag", + name="tag", summary="Add tag to a file in a store.", usage= - 'add-tag -instance [-query "hash:"] [-duplicate ] [-list [,...]] [--all] [,...]', + 'metadata -add -instance [-query "hash:"] [-duplicate ] [-list [,...]] [--all] [,...]', arg=[ CmdletArg( "tag", @@ -519,22 +519,23 @@ class Add_Tag(Cmdlet): "- If -query is not provided, uses the piped item's hash (or derives from its path when possible).", "- Multiple tag can be comma-separated or space-separated.", "- Use -list to include predefined tag lists from adjective.json: -list philosophy,occult", - '- tag can also reference lists with curly braces: add-tag {philosophy} "other:tag"', + '- tag can also reference lists with curly braces: metadata -add {philosophy} "other:tag"', "- Use -duplicate to copy EXISTING tag values to new namespaces:", " Explicit format: -duplicate title:album,artist (copies title: to album: and artist:)", " Inferred format: -duplicate title,album,artist (first is source, rest are targets)", "- The source namespace must already exist in the file being tagged.", "- Target namespaces that already have a value are skipped (not overwritten).", "- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.", - "- Use #(namespace) inside a tag value to insert existing values, e.g. add-tag \"title:#(track) - #(series)\".", - "- Use angle-bracket transforms for advanced formatting, e.g. add-tag \"code:e\".", + "- Use #(namespace) inside a tag value to insert existing values, e.g. metadata -add \"title:#(track) - #(series)\".", + "- Use angle-bracket transforms for advanced formatting, e.g. metadata -add \"code:e\".", "- Current documented transforms include padding, default, replace, and increment.", "- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.", "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.", ], exec=self.run, ) - self.register() + if register_cmdlet: + self.register() def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Add tag to a file with smart filtering for pipeline results.""" @@ -1215,4 +1216,3 @@ class Add_Tag(Cmdlet): return 0 -CMDLET = Add_Tag() diff --git a/cmdlet/delete_tag.py b/cmdlet/metadata/tag_delete.py similarity index 97% rename from cmdlet/delete_tag.py rename to cmdlet/metadata/tag_delete.py index 3004eaf..a1f93c1 100644 --- a/cmdlet/delete_tag.py +++ b/cmdlet/metadata/tag_delete.py @@ -7,7 +7,7 @@ from SYS import pipeline as ctx from SYS.item_accessors import set_field from SYS.payload_builders import extract_title_tag_value from SYS.result_publication import publish_result_table -from . import _shared as sh +from .. import _shared as sh Cmdlet = sh.Cmdlet CmdletArg = sh.CmdletArg @@ -203,7 +203,7 @@ def _refresh_tag_view_if_current( get_tag = None try: - get_tag = get_cmdlet("get-tag") + get_tag = get_cmdlet("metadata") except Exception: get_tag = None if not callable(get_tag): @@ -245,7 +245,7 @@ def _refresh_tag_view_if_current( if not is_match: return - refresh_args: list[str] = [] + refresh_args: list[str] = ["-get"] if file_hash: refresh_args.extend(["-query", f"hash:{file_hash}"]) @@ -385,10 +385,10 @@ def _parse_delete_tag_arguments(arguments: Sequence[str]) -> list[str]: return tags -CMDLET = Cmdlet( - name="delete-tag", +_DELETE_TAG_CMDLET = Cmdlet( + name="tag", summary="Remove tags from a file in a store.", - usage='delete-tag -instance [-query "hash:"] [,...]', + usage='metadata -delete -instance [-query "hash:"] [,...]', arg=[ SharedArgs.QUERY, SharedArgs.INSTANCE, @@ -401,8 +401,8 @@ CMDLET = Cmdlet( detail=[ "- Requires a Hydrus file (hash present) or explicit -query override.", "- Multiple tags can be comma-separated or space-separated.", - "- Use #(namespace) inside a tag value to remove a derived tag, e.g. delete-tag \"title:#(track) - #(series)\".", - "- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e\".", + "- Use #(namespace) inside a tag value to remove a derived tag, e.g. metadata -delete \"title:#(track) - #(series)\".", + "- Angle-bracket transforms match metadata -add syntax, e.g. metadata -delete \"code:e\".", "- Current documented transforms include padding, default, replace, and increment.", "- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.", "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.", @@ -413,7 +413,7 @@ CMDLET = Cmdlet( def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # Help if should_show_help(args): - log(f"Cmdlet: {CMDLET.name}\nSummary: {CMDLET.summary}\nUsage: {CMDLET.usage}") + log(f"Cmdlet: {_DELETE_TAG_CMDLET.name}\nSummary: {_DELETE_TAG_CMDLET.summary}\nUsage: {_DELETE_TAG_CMDLET.usage}") return 0 def _looks_like_tag_row(obj: Any) -> bool: @@ -474,7 +474,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # If @ reaches here as a literal argument, it's almost certainly user error. if rest and str(rest[0] ).startswith("@") and not (has_piped_tag or has_piped_tag_list): - log("Selection syntax is only supported via piping. Use: @N | delete-tag") + log("Selection syntax is only supported via piping. Use: @N | metadata -delete") return 1 # Special case: grouped tag selection created by the pipeline runner. @@ -766,7 +766,7 @@ def _process_deletion( remaining_titles = [t for t in current_titles if t.lower() not in del_title_set] if current_titles and not remaining_titles: log( - 'Cannot delete the last title: tag. Add a replacement title first (add-tags "title:new title").', + 'Cannot delete the last title: tag. Add a replacement title first (metadata -add "title:new title").', file=sys.stderr, ) return False @@ -803,6 +803,3 @@ def _process_deletion( return False -# Register cmdlet (no legacy decorator) -CMDLET.exec = _run -CMDLET.register() diff --git a/cmdlet/get_tag.py b/cmdlet/metadata/tag_get.py similarity index 97% rename from cmdlet/get_tag.py rename to cmdlet/metadata/tag_get.py index 3b24743..7a0d9b1 100644 --- a/cmdlet/get_tag.py +++ b/cmdlet/metadata/tag_get.py @@ -37,7 +37,7 @@ from SYS.detail_view_helpers import create_detail_view, prepare_detail_metadata from SYS.payload_builders import extract_title_tag_value from SYS.result_publication import publish_result_table from SYS.result_table_helpers import add_row_columns -from . import _shared as sh +from .. import _shared as sh from SYS.field_access import get_field normalize_hash = sh.normalize_hash @@ -276,8 +276,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: """Get tags from Hydrus, local sidecar, or URL metadata. Usage: - get-tag [-query "hash:"] [--instance ] [--emit] - get-tag -scrape + metadata -get [-query "hash:"] [--instance ] [--emit] + metadata -get -scrape Options: -query "hash:": Override hash to use instead of result's hash @@ -292,7 +292,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - """Internal implementation details for get-tag.""" + """Internal implementation details for metadata -get.""" emit_mode = False is_store_backed = False args_list = [str(arg) for arg in (args or [])] @@ -329,7 +329,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: return getattr(obj, field, default) # Parse arguments using shared parser - parsed_args = parse_cmdlet_args(args_list, CMDLET) + parsed_args = parse_cmdlet_args(args_list, Get_Tag(register_cmdlet=False)) # Detect if -scrape flag was provided without a value (parse_cmdlet_args skips missing values) scrape_flag_present = any( @@ -660,7 +660,7 @@ def _run_impl(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: table = Table(f"Metadata: {provider.name}") table.set_table(f"metadata.{provider.name}") - table.set_source_command("get-tag", []) + table.set_source_command("metadata", ["-get"]) selection_payload = [] hash_for_payload = normalize_hash(hash_override) or normalize_hash( get_field(result, @@ -956,15 +956,15 @@ _SCRAPE_CHOICES = [ class Get_Tag(Cmdlet): - """Class-based get-tag cmdlet with self-registration.""" + """Class-based metadata -get tag handler with self-registration.""" - def __init__(self) -> None: - """Initialize get-tag cmdlet.""" + def __init__(self, *, register_cmdlet: bool = True) -> None: + """Initialize metadata -get handler.""" super().__init__( - name="get-tag", + name="tag", summary="Get tag values from Hydrus or local sidecar metadata", usage= - 'get-tag [-query "hash:"] [--instance ] [--emit] [-scrape ]', + 'metadata -get [-query "hash:"] [--instance ] [--emit] [-scrape ]', alias=[], arg=[ SharedArgs.QUERY, @@ -1001,12 +1001,11 @@ class Get_Tag(Cmdlet): ], exec=self.run, ) - self.register() + if register_cmdlet: + self.register() def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - """Execute get-tag cmdlet.""" + """Execute metadata -get.""" return _run(result, args, config) -# Create and register the cmdlet -CMDLET = Get_Tag() diff --git a/cmdlet/metadata/url.py b/cmdlet/metadata/url.py new file mode 100644 index 0000000..064313e --- /dev/null +++ b/cmdlet/metadata/url.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any, Dict, Sequence + + +def run_url_action(action: str, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + """Route metadata URL actions to URL cmdlets.""" + act = str(action or "").strip().lower() + + if act == "add": + from cmdlet.file.add_url import CMDLET as ADD_URL_CMDLET + + return int(ADD_URL_CMDLET.run(result, args, config)) + + if act == "delete": + from cmdlet.delete_url import CMDLET as DELETE_URL_CMDLET + + return int(DELETE_URL_CMDLET.run(result, args, config)) + + if act == "get": + from cmdlet.get_url import CMDLET as GET_URL_CMDLET + + return int(GET_URL_CMDLET.run(result, args, config)) + + return 1 diff --git a/cmdlet/metadata_cmdlet.py b/cmdlet/metadata_cmdlet.py new file mode 100644 index 0000000..5d62975 --- /dev/null +++ b/cmdlet/metadata_cmdlet.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Sequence +import sys + +from SYS.logger import log +from . import _shared as sh + +Cmdlet = sh.Cmdlet +CmdletArg = sh.CmdletArg +SharedArgs = sh.SharedArgs + + +class Metadata(Cmdlet): + """Unified metadata command with domain + action routing.""" + + _ACTION_FLAGS = { + "add": {"-add", "--add"}, + "delete": {"-delete", "--delete", "-del", "--del"}, + "get": {"-get", "--get"}, + "inspect": {"-inspect", "--inspect", "-info", "--info", "-file", "--file"}, + } + + _DOMAIN_FLAGS = { + "tag": {"-tag", "--tag", "-tags", "--tags"}, + "url": {"-url", "--url", "-urls", "--urls"}, + "relationship": { + "-relationship", + "--relationship", + "-relationships", + "--relationships", + "-rel", + "--rel", + }, + "note": {"-note", "--note", "-notes", "--notes"}, + "file": {"-file", "--file"}, + } + + def __init__(self) -> None: + super().__init__( + name="metadata", + summary="Manage metadata domains with one command", + usage='metadata [-tag|-url|-relationship|-note] (-add|-delete|-get) [args] OR metadata -inspect [args]', + alias=["meta"], + arg=[ + SharedArgs.QUERY, + SharedArgs.INSTANCE, + CmdletArg("-tag", type="flag", required=False, description="Metadata tag domain (default if omitted)"), + CmdletArg("-url", type="flag", required=False, description="URL metadata domain"), + CmdletArg("-relationship", type="flag", required=False, description="Relationship metadata domain", alias="rel"), + CmdletArg("-note", type="flag", required=False, description="Note metadata domain"), + CmdletArg("-add", type="flag", required=False, description="Add metadata tag value(s)"), + CmdletArg("-delete", type="flag", required=False, description="Delete metadata tag value(s)", alias="del"), + CmdletArg("-get", type="flag", required=False, description="Read metadata values for selected domain"), + CmdletArg("-inspect", type="flag", required=False, description="Inspect file metadata details", alias="info"), + CmdletArg( + "[,...]", + type="string", + required=False, + variadic=True, + description="Tag values for -add/-delete", + ), + ], + detail=[ + "- Use one action flag: -add, -delete, -get, or -inspect.", + "- Domain flags: -tag (default), -url, -relationship, -note.", + "- Examples:", + " metadata -url -add ", + " metadata -url -delete ", + " metadata -relationship -get", + " metadata -note -add -query \"title:lyric text:...\"", + "- -inspect maps to get-metadata and prints rich metadata details.", + ], + exec=self.run, + ) + self.register() + + @classmethod + def _extract_parts( + cls, + args: Sequence[str], + ) -> tuple[str | None, str, List[str], List[str], List[str]]: + matched_actions: List[str] = [] + matched_domains: List[str] = [] + passthrough: List[str] = [] + + for token in args or []: + text = str(token or "") + lower = text.strip().lower() + matched = None + for action_name, variants in cls._ACTION_FLAGS.items(): + if lower in variants: + matched = action_name + break + if matched: + matched_actions.append(matched) + continue + + matched_domain = None + for domain_name, variants in cls._DOMAIN_FLAGS.items(): + if lower in variants: + matched_domain = domain_name + break + if matched_domain: + matched_domains.append(matched_domain) + continue + + passthrough.append(text) + + unique_actions: List[str] = [] + for action in matched_actions: + if action not in unique_actions: + unique_actions.append(action) + + unique_domains: List[str] = [] + for domain in matched_domains: + if domain not in unique_domains: + unique_domains.append(domain) + + action = unique_actions[0] if len(unique_actions) == 1 else None + domain = unique_domains[0] if len(unique_domains) == 1 else "tag" + return action, domain, passthrough, unique_actions, unique_domains + + def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: + action, domain, passthrough_args, seen_actions, seen_domains = self._extract_parts(args) + + if action is None: + if not seen_actions: + log( + "metadata: missing action flag; choose exactly one of -add, -delete, -get, -inspect", + file=sys.stderr, + ) + else: + rendered = ", ".join(f"-{name}" for name in seen_actions) + log(f"metadata: conflicting actions ({rendered}); choose exactly one", file=sys.stderr) + return 1 + + if len(seen_domains) > 1: + rendered_domains = ", ".join(f"-{name}" for name in seen_domains) + log(f"metadata: conflicting domains ({rendered_domains}); choose one domain", file=sys.stderr) + return 1 + + if action == "inspect": + from cmdlet.metadata.inspect import run_inspect_action + + return run_inspect_action(result, passthrough_args, config) + + if domain == "file": + log("metadata: -file only supports -inspect; use metadata -inspect", file=sys.stderr) + return 1 + + if domain == "tag": + from cmdlet.metadata.tag import run_tag_action + + return run_tag_action(action, result, passthrough_args, config) + + if domain == "url": + from cmdlet.metadata.url import run_url_action + + return run_url_action(action, result, passthrough_args, config) + + if domain == "relationship": + from cmdlet.metadata.relationship import run_relationship_action + + return run_relationship_action(action, result, passthrough_args, config) + + if domain == "note": + from cmdlet.metadata.note import run_note_action + + return run_note_action(action, result, passthrough_args, config) + + log(f"metadata: unsupported domain '{domain}'", file=sys.stderr) + return 1 + + +CMDLET = Metadata() diff --git a/cmdnat/adjective.py b/cmdnat/adjective.py index 86550fd..cce84b5 100644 --- a/cmdnat/adjective.py +++ b/cmdnat/adjective.py @@ -103,8 +103,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # Check for -add flag if "-add" in remaining_args: - # .adjective category -add tag - # or .adjective category tag -add + # .adjective category -add + # or .adjective category -add add_idx = remaining_args.index("-add") # Tag could be before or after tag = None @@ -126,8 +126,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # Check for -delete flag elif "-delete" in remaining_args: - # .adjective category -delete tag - # or .adjective category tag -delete + # .adjective category -delete + # or .adjective category -delete del_idx = remaining_args.index("-delete") tag = None if del_idx + 1 < len(remaining_args): diff --git a/docs/tag_template_syntax.md b/docs/tag_template_syntax.md index 37f37eb..a8b5c0c 100644 --- a/docs/tag_template_syntax.md +++ b/docs/tag_template_syntax.md @@ -1,6 +1,6 @@ # Tag Template Syntax -This guide documents the reusable template syntax for tag mutation commands such as `add-tag` and `delete-tag`. +This guide documents the reusable template syntax for tag mutation commands such as `metadata -add` and `metadata -delete`. The current goal is lowercase-first tagging. Examples in this document use lowercase tag names and lowercase text values, and no case-conversion transforms are part of the documented syntax. @@ -8,8 +8,8 @@ The current goal is lowercase-first tagging. Examples in this document use lower The shared template resolver currently applies to: -- `add-tag` -- `delete-tag` +- `metadata -add` +- `metadata -delete` Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title. @@ -20,9 +20,9 @@ Use `#(namespace)` to insert the value from an existing namespaced tag. Examples: ```powershell -add-tag "title:#(track) - #(series)" -add-tag "album:#(series)" -delete-tag "title:#(track) - #(series)" +metadata -add "title:#(track) - #(series)" +metadata -add "album:#(series)" +metadata -delete "title:#(track) - #(series)" ``` If an item has: @@ -53,8 +53,8 @@ title:9 - ancient greek intensive course Examples: ```powershell -add-tag "title:#(track #) - #(series)" -add-tag "code:#(disc number)" +metadata -add "title:#(track #) - #(series)" +metadata -add "code:#(disc number)" ``` ## Transform Syntax @@ -74,9 +74,9 @@ Use `padding`, `pad`, or `zfill` to zero-pad a value. Examples: ```powershell -add-tag "code:e" -add-tag "code:e" -add-tag "code:e" +metadata -add "code:e" +metadata -add "code:e" +metadata -add "code:e" ``` If `episode:3` exists, each example resolves to: @@ -99,8 +99,8 @@ Use `default(value,fallback)` when a namespace may be missing. Examples: ```powershell -add-tag "season:" -add-tag "disc:" +metadata -add "season:" +metadata -add "disc:" ``` If `season:` is missing, the first example resolves to: @@ -116,8 +116,8 @@ Use `replace(value,old,new)` for simple substring replacement. Examples: ```powershell -add-tag "slug:" -add-tag "slug:" +metadata -add "slug:" +metadata -add "slug:" ``` If `title:ancient greek intensive course` exists, the first example resolves to: @@ -135,8 +135,8 @@ Use `increment(value,amount)` to do small integer adjustments. Examples: ```powershell -add-tag "episode_next:" -add-tag "disc_next:" +metadata -add "episode_next:" +metadata -add "disc_next:" ``` If `episode:3` exists, the first example resolves to: @@ -154,7 +154,7 @@ Tag arguments still support comma-separated tags, but commas inside transform ca This means the following stays as two tags, not three fragments: ```powershell -add-tag "code:e,title:#(series)" +metadata -add "code:e,title:#(series)" ``` ## Combining With `-extract` @@ -164,7 +164,7 @@ Templates are especially useful after deriving tags from a title. Example: ```powershell -add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" +metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)" ``` For a title like: @@ -197,25 +197,25 @@ The command logs a warning summary for skipped unresolved templates. Episode-style numbering: ```powershell -add-tag "code:e" +metadata -add "code:e" ``` Title synthesis from extracted tags: ```powershell -add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" +metadata -add -extract "(series) - part (track)" "title:#(track) - #(series)" ``` Delete a derived title tag: ```powershell -delete-tag "title:#(track) - #(series)" +metadata -delete "title:#(track) - #(series)" ``` Reuse an existing value under a new namespace: ```powershell -add-tag "album:#(series)" +metadata -add "album:#(series)" ``` ## Mass Tagging Recipes diff --git a/plugins/alldebrid/__init__.py b/plugins/alldebrid/__init__.py index 5d68563..6a46b7f 100644 --- a/plugins/alldebrid/__init__.py +++ b/plugins/alldebrid/__init__.py @@ -392,7 +392,7 @@ def _dispatch_alldebrid_magnet_search( config: Dict[str, Any], ) -> None: try: - from cmdlet.search_file import CMDLET as _SEARCH_FILE_CMDLET + from cmdlet.file.search import CMDLET as _SEARCH_FILE_CMDLET exec_fn = getattr(_SEARCH_FILE_CMDLET, "exec", None) if callable(exec_fn): diff --git a/plugins/ftp/__init__.py b/plugins/ftp/__init__.py index d491b03..6727028 100644 --- a/plugins/ftp/__init__.py +++ b/plugins/ftp/__init__.py @@ -549,6 +549,8 @@ class FTP(Provider): if not local_path.exists() or not local_path.is_file(): raise FileNotFoundError(f"File not found: {local_path}") + pipe_obj = kwargs.get("pipe_obj") + settings = self._resolve_settings( instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None, require_explicit=bool(kwargs.get("instance") or kwargs.get("store")), @@ -569,6 +571,19 @@ class FTP(Provider): ftp = self._connect(settings=settings) try: self._ensure_directory(ftp, remote_dir, base_path=str(settings.get("base_path") or "/")) + # FTP duplicate check is filename-based at the destination directory. + # If the exact filename already exists remotely, skip re-upload. + if self._remote_filename_exists(ftp, remote_dir, remote_name): + try: + if pipe_obj is not None: + if not isinstance(getattr(pipe_obj, "extra", None), dict): + pipe_obj.extra = {} + pipe_obj.extra["upload_duplicate"] = True + pipe_obj.extra["upload_duplicate_rule"] = "filename" + pipe_obj.extra["upload_duplicate_target"] = remote_path + except Exception: + pass + return self._build_url(remote_path, settings=settings) with local_path.open("rb") as handle: ftp.storbinary(f"STOR {remote_path}", handle) finally: @@ -930,6 +945,36 @@ class FTP(Provider): if not self._is_directory(ftp, partial): raise + def _remote_filename_exists(self, ftp: ftplib.FTP, remote_dir: str, filename: str) -> bool: + target_name = str(filename or "").strip() + if not target_name: + return False + + normalized_dir = self._normalize_remote_path(remote_dir, default=self._base_path) + + try: + for name, facts in ftp.mlsd(normalized_dir): + _ = facts + if str(name or "").strip() == target_name: + return True + except Exception: + pass + + try: + entries = ftp.nlst(normalized_dir) + except Exception: + entries = [] + + for entry in entries or []: + entry_text = str(entry or "").strip().rstrip("/") + if not entry_text: + continue + entry_name = posixpath.basename(entry_text) + if entry_name == target_name: + return True + + return False + def _item_metadata(self, item: Any, *, pipe_obj: Any = None) -> Dict[str, Any]: metadata: Dict[str, Any] = {} for source in (item, pipe_obj): diff --git a/plugins/mpv/LUA/main.lua b/plugins/mpv/LUA/main.lua index 37bc93b..bf99f47 100644 --- a/plugins/mpv/LUA/main.lua +++ b/plugins/mpv/LUA/main.lua @@ -2842,7 +2842,7 @@ local function _start_screenshot_store_save(store, out_path, tags) if screenshot_url == '' or not screenshot_url:match('^https?://') then screenshot_url = '' end - local cmd = 'add-file -store ' .. quote_pipeline_arg(store) + local cmd = 'file -add -store ' .. quote_pipeline_arg(store) .. ' -path ' .. quote_pipeline_arg(out_path) if screenshot_url ~= '' then cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url) @@ -2854,7 +2854,7 @@ local function _start_screenshot_store_save(store, out_path, tags) local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or '' if #tag_list > 0 then local tag_string = table.concat(tag_list, ',') - cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string) + cmd = cmd .. ' | tag -add ' .. quote_pipeline_arg(tag_string) end local queue_target = is_named_store and ('store ' .. store) or 'folder' @@ -5539,7 +5539,7 @@ local function _start_download_flow_for_current() end ensure_mpv_ipc_server() - local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) + local pipeline_cmd = 'file -get -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) _queue_pipeline_in_repl( pipeline_cmd, 'Queued in REPL: store copy', @@ -5835,9 +5835,9 @@ mp.register_script_message('medios-download-pick-store', function(json) end local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' - local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) + local pipeline_cmd = 'file -download -url ' .. quote_pipeline_arg(url) .. ' -query ' .. quote_pipeline_arg(query) - .. ' | add-file -store ' .. quote_pipeline_arg(store) + .. ' | file -add -store ' .. quote_pipeline_arg(store) _set_selected_store(store) _queue_pipeline_in_repl( @@ -5901,9 +5901,9 @@ mp.register_script_message('medios-download-pick-path', function() end local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' - local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) + local pipeline_cmd = 'file -download -url ' .. quote_pipeline_arg(url) .. ' -query ' .. quote_pipeline_arg(query) - .. ' | add-file -path ' .. quote_pipeline_arg(folder) + .. ' | file -add -path ' .. quote_pipeline_arg(folder) _queue_pipeline_in_repl( pipeline_cmd, @@ -6137,7 +6137,7 @@ function M.delete_current_file() local seed = {{path = path}} - M.run_pipeline('delete-file', seed, function(_, err) + M.run_pipeline('file -delete', seed, function(_, err) if err then mp.osd_message('Delete failed: ' .. tostring(err), 3) return @@ -6302,17 +6302,17 @@ local function _start_trim_with_range(range) _lua_log('trim: building store file pipeline (original from store)') if selected_store then pipeline_cmd = - 'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) .. + 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. - ' | add-file -path ' .. quote_pipeline_arg(output_path) .. + ' | file -add -path ' .. quote_pipeline_arg(output_path) .. ' -store "' .. selected_store .. '"' .. ' | add-relationship -store "' .. selected_store .. '"' .. ' -to-hash ' .. quote_pipeline_arg(store_hash.hash) else pipeline_cmd = - 'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) .. + 'tag -get -emit -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. - ' | add-file -path ' .. quote_pipeline_arg(output_path) .. + ' | file -add -path ' .. quote_pipeline_arg(output_path) .. ' -store "' .. store_hash.store .. '"' .. ' | add-relationship -store "' .. store_hash.store .. '"' .. ' -to-hash ' .. quote_pipeline_arg(store_hash.hash) @@ -6321,9 +6321,9 @@ local function _start_trim_with_range(range) -- Local file: save to selected store if available _lua_log('trim: local file pipeline (not from store)') if selected_store then - _lua_log('trim: building add-file command to selected_store=' .. selected_store) + _lua_log('trim: building file -add command to selected_store=' .. selected_store) -- Don't add title if empty - the file path will be used as title by default - pipeline_cmd = 'add-file -path ' .. quote_pipeline_arg(output_path) .. + pipeline_cmd = 'file -add -path ' .. quote_pipeline_arg(output_path) .. ' -store "' .. selected_store .. '"' _lua_log('trim: pipeline_cmd=' .. pipeline_cmd) else diff --git a/plugins/mpv/mpv_ipc.py b/plugins/mpv/mpv_ipc.py index 60995a0..d7548da 100644 --- a/plugins/mpv/mpv_ipc.py +++ b/plugins/mpv/mpv_ipc.py @@ -470,11 +470,11 @@ class MPV: def _q(s: str) -> str: return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' - pipeline = f"download-file -url {_q(url)} -query {_q(f'format:{fmt}')}" + pipeline = f"file -download -url {_q(url)} -query {_q(f'format:{fmt}')}" if store: - pipeline += f" | add-file -instance {_q(store)}" + pipeline += f" | file -add -instance {_q(store)}" else: - pipeline += f" | add-file -path {_q(path or '')}" + pipeline += f" | file -add -path {_q(path or '')}" try: from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 diff --git a/plugins/scp/__init__.py b/plugins/scp/__init__.py index c78b76a..9bd26bc 100644 --- a/plugins/scp/__init__.py +++ b/plugins/scp/__init__.py @@ -582,6 +582,8 @@ class SCP(Provider): if not local_path.exists() or not local_path.is_file(): raise FileNotFoundError(f"File not found: {local_path}") + pipe_obj = kwargs.get("pipe_obj") + settings = self._resolve_settings( instance_name=str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None, require_explicit=bool(kwargs.get("instance") or kwargs.get("store")), @@ -609,8 +611,30 @@ class SCP(Provider): if not self._is_sftp_negotiation_error(exc): raise self._ensure_directory_via_ssh(ssh, remote_dir) + if self._remote_filename_exists_via_ssh(ssh, remote_path): + try: + if pipe_obj is not None: + if not isinstance(getattr(pipe_obj, "extra", None), dict): + pipe_obj.extra = {} + pipe_obj.extra["upload_duplicate"] = True + pipe_obj.extra["upload_duplicate_rule"] = "filename" + pipe_obj.extra["upload_duplicate_target"] = remote_path + except Exception: + pass + return self._build_url(remote_path, settings=settings) else: self._ensure_directory(sftp, remote_dir, base_path=str(settings.get("base_path") or "/")) + if self._remote_filename_exists(sftp, remote_path): + try: + if pipe_obj is not None: + if not isinstance(getattr(pipe_obj, "extra", None), dict): + pipe_obj.extra = {} + pipe_obj.extra["upload_duplicate"] = True + pipe_obj.extra["upload_duplicate_rule"] = "filename" + pipe_obj.extra["upload_duplicate_target"] = remote_path + except Exception: + pass + return self._build_url(remote_path, settings=settings) scp_client = self._open_scp(ssh) scp_client.put(str(local_path), remote_path=remote_path) finally: @@ -620,6 +644,19 @@ class SCP(Provider): return self._build_url(remote_path, settings=settings) + def _remote_filename_exists(self, sftp: Any, remote_path: str) -> bool: + try: + sftp.stat(remote_path) + return True + except Exception: + return False + + def _remote_filename_exists_via_ssh(self, ssh: Any, remote_path: str) -> bool: + normalized = self._normalize_remote_path(remote_path, default=self._base_path) + quoted_path = shlex.quote(normalized) + status, _, _ = self._run_ssh_command(ssh, f"test -e {quoted_path}") + return status == 0 + def _run_test_connection(self) -> Dict[str, Any]: settings = self._resolve_settings() if not settings.get("host"):