455 lines
18 KiB
Python
455 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Sequence, Optional
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
from SYS.logger import log
|
|
|
|
import models
|
|
import pipeline as ctx
|
|
from ._shared import normalize_result_input, filter_results_by_temp
|
|
from ._shared import (
|
|
Cmdlet,
|
|
CmdletArg,
|
|
SharedArgs,
|
|
normalize_hash,
|
|
parse_tag_arguments,
|
|
expand_tag_groups,
|
|
parse_cmdlet_args,
|
|
collapse_namespace_tag,
|
|
should_show_help,
|
|
get_field,
|
|
)
|
|
from Store import Store
|
|
from SYS.utils import sha256_file
|
|
|
|
|
|
def _extract_title_tag(tags: List[str]) -> Optional[str]:
|
|
"""Return the value of the first title: tag if present."""
|
|
for t in tags:
|
|
if t.lower().startswith("title:"):
|
|
value = t.split(":", 1)[1].strip()
|
|
return value or None
|
|
return None
|
|
|
|
|
|
def _apply_title_to_result(res: Any, title_value: Optional[str]) -> None:
|
|
"""Update result object/dict title fields and columns in-place."""
|
|
if not title_value:
|
|
return
|
|
if isinstance(res, models.PipeObject):
|
|
res.title = title_value
|
|
# Update columns if present (Title column assumed index 0)
|
|
columns = getattr(res, "columns", None)
|
|
if isinstance(columns, list) and columns:
|
|
label, *_ = columns[0]
|
|
if str(label).lower() == "title":
|
|
columns[0] = (label, title_value)
|
|
elif isinstance(res, dict):
|
|
res["title"] = title_value
|
|
cols = res.get("columns")
|
|
if isinstance(cols, list):
|
|
updated = []
|
|
changed = False
|
|
for col in cols:
|
|
if isinstance(col, tuple) and len(col) == 2:
|
|
label, _val = col
|
|
if str(label).lower() == "title":
|
|
updated.append((label, title_value))
|
|
changed = True
|
|
else:
|
|
updated.append(col)
|
|
else:
|
|
updated.append(col)
|
|
if changed:
|
|
res["columns"] = updated
|
|
|
|
|
|
def _matches_target(item: Any, target_hash: Optional[str], target_path: Optional[str]) -> bool:
|
|
"""Determine whether a result item refers to the given hash/path target (canonical fields only)."""
|
|
|
|
def norm(val: Any) -> Optional[str]:
|
|
return str(val).lower() if val is not None else None
|
|
|
|
target_hash_l = target_hash.lower() if target_hash else None
|
|
target_path_l = target_path.lower() if target_path else None
|
|
|
|
if isinstance(item, dict):
|
|
hashes = [norm(item.get("hash"))]
|
|
paths = [norm(item.get("path"))]
|
|
else:
|
|
hashes = [norm(get_field(item, "hash"))]
|
|
paths = [norm(get_field(item, "path"))]
|
|
|
|
if target_hash_l and target_hash_l in hashes:
|
|
return True
|
|
if target_path_l and target_path_l in paths:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _update_item_title_fields(item: Any, new_title: str) -> None:
|
|
"""Mutate an item to reflect a new title in plain fields and columns."""
|
|
if isinstance(item, models.PipeObject):
|
|
item.title = new_title
|
|
columns = getattr(item, "columns", None)
|
|
if isinstance(columns, list) and columns:
|
|
label, *_ = columns[0]
|
|
if str(label).lower() == "title":
|
|
columns[0] = (label, new_title)
|
|
elif isinstance(item, dict):
|
|
item["title"] = new_title
|
|
cols = item.get("columns")
|
|
if isinstance(cols, list):
|
|
updated_cols = []
|
|
changed = False
|
|
for col in cols:
|
|
if isinstance(col, tuple) and len(col) == 2:
|
|
label, _val = col
|
|
if str(label).lower() == "title":
|
|
updated_cols.append((label, new_title))
|
|
changed = True
|
|
else:
|
|
updated_cols.append(col)
|
|
else:
|
|
updated_cols.append(col)
|
|
if changed:
|
|
item["columns"] = updated_cols
|
|
|
|
|
|
def _refresh_result_table_title(new_title: str, target_hash: Optional[str], target_path: Optional[str]) -> None:
|
|
"""Refresh the cached result table with an updated title and redisplay it."""
|
|
try:
|
|
last_table = ctx.get_last_result_table()
|
|
items = ctx.get_last_result_items()
|
|
if not last_table or not items:
|
|
return
|
|
|
|
updated_items = []
|
|
match_found = False
|
|
for item in items:
|
|
try:
|
|
if _matches_target(item, target_hash, target_path):
|
|
_update_item_title_fields(item, new_title)
|
|
match_found = True
|
|
except Exception:
|
|
pass
|
|
updated_items.append(item)
|
|
if not match_found:
|
|
return
|
|
|
|
new_table = last_table.copy_with_title(getattr(last_table, "title", ""))
|
|
|
|
for item in updated_items:
|
|
new_table.add_result(item)
|
|
|
|
# Keep the underlying history intact; update only the overlay so @.. can
|
|
# clear the overlay then continue back to prior tables (e.g., the search list).
|
|
ctx.set_last_result_table_overlay(new_table, updated_items)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _refresh_tag_view(res: Any, target_hash: Optional[str], store_name: Optional[str], target_path: Optional[str], config: Dict[str, Any]) -> None:
|
|
"""Refresh tag display via get-tag. Prefer current subject; fall back to direct hash refresh."""
|
|
try:
|
|
from cmdlets import get_tag as get_tag_cmd # type: ignore
|
|
except Exception:
|
|
return
|
|
|
|
if not target_hash or not store_name:
|
|
return
|
|
|
|
refresh_args: List[str] = ["-hash", target_hash, "-store", store_name]
|
|
|
|
try:
|
|
subject = ctx.get_last_result_subject()
|
|
if subject and _matches_target(subject, target_hash, target_path):
|
|
get_tag_cmd._run(subject, refresh_args, config)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
get_tag_cmd._run(res, refresh_args, config)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
|
|
class Add_Tag(Cmdlet):
|
|
"""Class-based add-tag cmdlet with Cmdlet metadata inheritance."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(
|
|
name="add-tag",
|
|
summary="Add tag to a file in a store.",
|
|
usage="add-tag -store <store> [-hash <sha256>] [-duplicate <format>] [-list <list>[,<list>...]] [--all] <tag>[,<tag>...]",
|
|
arg=[
|
|
SharedArgs.HASH,
|
|
SharedArgs.STORE,
|
|
CmdletArg("-duplicate", type="string", description="Copy existing tag values to new namespaces. Formats: title:album,artist (explicit) or title,album,artist (inferred)"),
|
|
CmdletArg("-list", type="string", description="Load predefined tag lists from adjective.json. Comma-separated list names (e.g., -list philosophy,occult)."),
|
|
CmdletArg("--all", type="flag", description="Include temporary files in tagging (by default, only tag non-temporary files)."),
|
|
CmdletArg("tag", type="string", required=False, description="One or more tag to add. Comma- or space-separated. Can also use {list_name} syntax. If omitted, uses tag from pipeline payload.", variadic=True),
|
|
],
|
|
detail=[
|
|
"- By default, only tag non-temporary files (from pipelines). Use --all to tag everything.",
|
|
"- Requires a store backend: use -store or pipe items that include store.",
|
|
"- If -hash 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\"",
|
|
"- 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).",
|
|
"- You can also pass the target hash as a tag token: hash:<sha256>. This overrides -hash and is removed from the tag list.",
|
|
],
|
|
exec=self.run,
|
|
)
|
|
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."""
|
|
if should_show_help(args):
|
|
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
|
return 0
|
|
|
|
# Parse arguments
|
|
parsed = parse_cmdlet_args(args, self)
|
|
|
|
# Check for --all flag
|
|
include_temp = parsed.get("all", False)
|
|
|
|
# Normalize input to list
|
|
results = normalize_result_input(result)
|
|
|
|
# Filter by temp status (unless --all is set)
|
|
if not include_temp:
|
|
results = filter_results_by_temp(results, include_temp=False)
|
|
|
|
if not results:
|
|
log("No valid files to tag (all results were temporary; use --all to include temporary files)", file=sys.stderr)
|
|
return 1
|
|
|
|
# Get tag from arguments (or fallback to pipeline payload)
|
|
raw_tag = parsed.get("tag", [])
|
|
if isinstance(raw_tag, str):
|
|
raw_tag = [raw_tag]
|
|
|
|
# Fallback: if no tag provided explicitly, try to pull from first result payload
|
|
if not raw_tag and results:
|
|
first = results[0]
|
|
payload_tag = None
|
|
|
|
# Try multiple tag lookup strategies in order
|
|
tag_lookups = [
|
|
lambda x: getattr(x, "tag", None),
|
|
lambda x: x.get("tag") if isinstance(x, dict) else None,
|
|
]
|
|
|
|
for lookup in tag_lookups:
|
|
try:
|
|
payload_tag = lookup(first)
|
|
if payload_tag:
|
|
break
|
|
except (AttributeError, TypeError, KeyError):
|
|
continue
|
|
|
|
if payload_tag:
|
|
if isinstance(payload_tag, str):
|
|
raw_tag = [payload_tag]
|
|
elif isinstance(payload_tag, list):
|
|
raw_tag = payload_tag
|
|
|
|
# Handle -list argument (convert to {list} syntax)
|
|
list_arg = parsed.get("list")
|
|
if list_arg:
|
|
for l in list_arg.split(','):
|
|
l = l.strip()
|
|
if l:
|
|
raw_tag.append(f"{{{l}}}")
|
|
|
|
# Parse and expand tag
|
|
tag_to_add = parse_tag_arguments(raw_tag)
|
|
tag_to_add = expand_tag_groups(tag_to_add)
|
|
|
|
# Allow hash override via namespaced token (e.g., "hash:abcdef...")
|
|
extracted_hash = None
|
|
filtered_tag: List[str] = []
|
|
for tag in tag_to_add:
|
|
if isinstance(tag, str) and tag.lower().startswith("hash:"):
|
|
_, _, hash_val = tag.partition(":")
|
|
if hash_val:
|
|
extracted_hash = normalize_hash(hash_val.strip())
|
|
continue
|
|
filtered_tag.append(tag)
|
|
tag_to_add = filtered_tag
|
|
|
|
if not tag_to_add:
|
|
log("No tag provided to add", file=sys.stderr)
|
|
return 1
|
|
|
|
# Get other flags (hash override can come from -hash or hash: token)
|
|
hash_override = normalize_hash(parsed.get("hash")) or extracted_hash
|
|
duplicate_arg = parsed.get("duplicate")
|
|
|
|
# tag ARE provided - apply them to each store-backed result
|
|
total_added = 0
|
|
total_modified = 0
|
|
|
|
store_override = parsed.get("store")
|
|
|
|
for res in results:
|
|
store_name: Optional[str]
|
|
raw_hash: Optional[str]
|
|
raw_path: Optional[str]
|
|
|
|
if isinstance(res, models.PipeObject):
|
|
store_name = store_override or res.store
|
|
raw_hash = res.hash
|
|
raw_path = res.path
|
|
elif isinstance(res, dict):
|
|
store_name = store_override or res.get("store")
|
|
raw_hash = res.get("hash")
|
|
raw_path = res.get("path")
|
|
else:
|
|
ctx.emit(res)
|
|
continue
|
|
|
|
if not store_name:
|
|
log("[add_tag] Error: Missing -store and item has no store field", file=sys.stderr)
|
|
return 1
|
|
|
|
resolved_hash = normalize_hash(hash_override) if hash_override else normalize_hash(raw_hash)
|
|
if not resolved_hash and raw_path:
|
|
try:
|
|
p = Path(str(raw_path))
|
|
stem = p.stem
|
|
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
|
|
resolved_hash = stem.lower()
|
|
elif p.exists() and p.is_file():
|
|
resolved_hash = sha256_file(p)
|
|
except Exception:
|
|
resolved_hash = None
|
|
|
|
if not resolved_hash:
|
|
log("[add_tag] Warning: Item missing usable hash (and could not derive from path); skipping", file=sys.stderr)
|
|
ctx.emit(res)
|
|
continue
|
|
|
|
try:
|
|
backend = Store(config)[str(store_name)]
|
|
except Exception as exc:
|
|
log(f"[add_tag] Error: Unknown store '{store_name}': {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
existing_tag, _src = backend.get_tag(resolved_hash, config=config)
|
|
except Exception:
|
|
existing_tag = []
|
|
|
|
existing_tag_list = [t for t in (existing_tag or []) if isinstance(t, str)]
|
|
existing_lower = {t.lower() for t in existing_tag_list}
|
|
original_title = _extract_title_tag(existing_tag_list)
|
|
|
|
# Per-item tag list (do not mutate shared list)
|
|
item_tag_to_add = list(tag_to_add)
|
|
item_tag_to_add = collapse_namespace_tag(item_tag_to_add, "title", prefer="last")
|
|
|
|
# Handle -duplicate logic (copy existing tag to new namespaces)
|
|
if duplicate_arg:
|
|
parts = str(duplicate_arg).split(':')
|
|
source_ns = ""
|
|
targets: list[str] = []
|
|
|
|
if len(parts) > 1:
|
|
source_ns = parts[0]
|
|
targets = [t.strip() for t in parts[1].split(',') if t.strip()]
|
|
else:
|
|
parts2 = str(duplicate_arg).split(',')
|
|
if len(parts2) > 1:
|
|
source_ns = parts2[0]
|
|
targets = [t.strip() for t in parts2[1:] if t.strip()]
|
|
|
|
if source_ns and targets:
|
|
source_prefix = source_ns.lower() + ":"
|
|
for t in existing_tag_list:
|
|
if not t.lower().startswith(source_prefix):
|
|
continue
|
|
value = t.split(":", 1)[1]
|
|
for target_ns in targets:
|
|
new_tag = f"{target_ns}:{value}"
|
|
if new_tag.lower() not in existing_lower:
|
|
item_tag_to_add.append(new_tag)
|
|
|
|
# Namespace replacement: delete old namespace:* when adding namespace:value
|
|
removed_namespace_tag: list[str] = []
|
|
for new_tag in item_tag_to_add:
|
|
if not isinstance(new_tag, str) or ":" not in new_tag:
|
|
continue
|
|
ns = new_tag.split(":", 1)[0].strip()
|
|
if not ns:
|
|
continue
|
|
ns_prefix = ns.lower() + ":"
|
|
for t in existing_tag_list:
|
|
if t.lower().startswith(ns_prefix) and t.lower() != new_tag.lower():
|
|
removed_namespace_tag.append(t)
|
|
|
|
removed_namespace_tag = sorted({t for t in removed_namespace_tag})
|
|
|
|
actual_tag_to_add = [t for t in item_tag_to_add if isinstance(t, str) and t.lower() not in existing_lower]
|
|
|
|
changed = False
|
|
if removed_namespace_tag:
|
|
try:
|
|
backend.delete_tag(resolved_hash, removed_namespace_tag, config=config)
|
|
changed = True
|
|
except Exception as exc:
|
|
log(f"[add_tag] Warning: Failed deleting namespace tag: {exc}", file=sys.stderr)
|
|
|
|
if actual_tag_to_add:
|
|
try:
|
|
backend.add_tag(resolved_hash, actual_tag_to_add, config=config)
|
|
changed = True
|
|
except Exception as exc:
|
|
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
|
|
|
|
if changed:
|
|
total_added += len(actual_tag_to_add)
|
|
total_modified += 1
|
|
|
|
try:
|
|
refreshed_tag, _src2 = backend.get_tag(resolved_hash, config=config)
|
|
refreshed_list = [t for t in (refreshed_tag or []) if isinstance(t, str)]
|
|
except Exception:
|
|
refreshed_list = existing_tag_list
|
|
|
|
# Update the result's tag using canonical field
|
|
if isinstance(res, models.PipeObject):
|
|
res.tag = refreshed_list
|
|
elif isinstance(res, dict):
|
|
res["tag"] = refreshed_list
|
|
|
|
final_title = _extract_title_tag(refreshed_list)
|
|
_apply_title_to_result(res, final_title)
|
|
|
|
if final_title and (not original_title or final_title.lower() != original_title.lower()):
|
|
_refresh_result_table_title(final_title, resolved_hash, raw_path)
|
|
|
|
if changed:
|
|
_refresh_tag_view(res, resolved_hash, str(store_name), raw_path, config)
|
|
|
|
ctx.emit(res)
|
|
|
|
log(
|
|
f"[add_tag] Added {total_added} new tag(s) across {len(results)} item(s); modified {total_modified} item(s)",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
CMDLET = Add_Tag() |