This commit is contained in:
2026-03-25 22:39:30 -07:00
parent c31402c8f1
commit 562acd809c
46 changed files with 2367 additions and 1868 deletions

View File

@@ -11,7 +11,7 @@ import sys
import tempfile
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from SYS.logger import log
import logging
logger = logging.getLogger(__name__)
@@ -29,6 +29,7 @@ _CONFIG_CACHE: Dict[str, Any] = {}
_LAST_SAVED_CONFIG: Dict[str, Any] = {}
_CONFIG_SAVE_MAX_RETRIES = 5
_CONFIG_SAVE_RETRY_DELAY = 0.15
_CONFIG_MISSING = object()
class ConfigSaveConflict(Exception):
@@ -61,6 +62,94 @@ def clear_config_cache() -> None:
_LAST_SAVED_CONFIG = {}
def get_nested_config_value(config: Dict[str, Any], *path: str) -> Any:
cur: Any = config
for key in path:
if not isinstance(cur, dict):
return None
cur = cur.get(key)
return cur
def coerce_config_value(
value: Any,
existing_value: Any = _CONFIG_MISSING,
*,
on_error: Optional[Callable[[str], None]] = None,
) -> Any:
if not isinstance(value, str):
return value
text = value.strip()
lowered = text.lower()
if existing_value is _CONFIG_MISSING:
if lowered in {"true", "false"}:
return lowered == "true"
if text.isdigit():
return int(text)
return value
if isinstance(existing_value, bool):
if lowered in {"true", "yes", "1", "on"}:
return True
if lowered in {"false", "no", "0", "off"}:
return False
if on_error is not None:
on_error(f"Warning: Could not convert '{value}' to boolean. Using string.")
return value
if isinstance(existing_value, int) and not isinstance(existing_value, bool):
try:
return int(text)
except ValueError:
if on_error is not None:
on_error(f"Warning: Could not convert '{value}' to int. Using string.")
return value
if isinstance(existing_value, float):
try:
return float(text)
except ValueError:
if on_error is not None:
on_error(f"Warning: Could not convert '{value}' to float. Using string.")
return value
return value
def set_nested_config_value(
config: Dict[str, Any],
key_path: str | Sequence[str],
value: Any,
*,
on_error: Optional[Callable[[str], None]] = None,
) -> bool:
if not isinstance(config, dict):
return False
if isinstance(key_path, str):
keys = [part for part in key_path.split(".") if part]
else:
keys = [str(part) for part in (key_path or []) if str(part)]
if not keys:
return False
current = config
for key in keys[:-1]:
next_value = current.get(key)
if not isinstance(next_value, dict):
next_value = {}
current[key] = next_value
current = next_value
last_key = keys[-1]
existing_value = current[last_key] if last_key in current else _CONFIG_MISSING
current[last_key] = coerce_config_value(value, existing_value, on_error=on_error)
return True
def get_hydrus_instance(
config: Dict[str, Any], instance_name: str = "home"
) -> Optional[Dict[str, Any]]:

104
SYS/detail_view_helpers.py Normal file
View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Any, Iterable, Optional, Sequence
def _labelize_key(key: str) -> str:
return str(key or "").replace("_", " ").title()
def _normalize_tags_value(tags: Any) -> Optional[str]:
if tags is None:
return None
if isinstance(tags, str):
text = tags.strip()
return text or None
if isinstance(tags, Sequence):
seen: list[str] = []
for tag in tags:
text = str(tag or "").strip()
if text and text not in seen:
seen.append(text)
return ", ".join(seen) if seen else None
text = str(tags).strip()
return text or None
def prepare_detail_metadata(
subject: Any,
*,
include_subject_fields: bool = False,
title: Optional[str] = None,
hash_value: Optional[str] = None,
store: Optional[str] = None,
path: Optional[str] = None,
tags: Any = None,
prefer_existing_tags: bool = True,
extra_fields: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
from SYS.result_table import extract_item_metadata
metadata = extract_item_metadata(subject) or {}
if include_subject_fields and isinstance(subject, dict):
for key, value in subject.items():
if str(key).startswith("_") or key in {"selection_action", "selection_args"}:
continue
label = _labelize_key(str(key))
if label not in metadata and value is not None:
metadata[label] = value
if title:
metadata["Title"] = title
if hash_value:
metadata["Hash"] = hash_value
if store:
metadata["Store"] = store
if path:
metadata["Path"] = path
tags_text = _normalize_tags_value(tags)
if tags_text and (not prefer_existing_tags or not metadata.get("Tags")):
metadata["Tags"] = tags_text
for key, value in (extra_fields or {}).items():
if value is not None:
metadata[str(key)] = value
return metadata
def create_detail_view(
title: str,
metadata: dict[str, Any],
*,
table_name: Optional[str] = None,
source_command: Optional[tuple[str, Sequence[str]]] = None,
init_command: Optional[tuple[str, Sequence[str]]] = None,
max_columns: Optional[int] = None,
exclude_tags: bool = False,
value_case: Optional[str] = "preserve",
perseverance: bool = True,
) -> Any:
from SYS.result_table import ItemDetailView
kwargs: dict[str, Any] = {"item_metadata": metadata}
if max_columns is not None:
kwargs["max_columns"] = max_columns
if exclude_tags:
kwargs["exclude_tags"] = True
table = ItemDetailView(title, **kwargs)
if table_name:
table = table.set_table(table_name)
if value_case:
table = table.set_value_case(value_case)
if perseverance:
table = table._perseverance(True)
if source_command:
name, args = source_command
table.set_source_command(name, list(args))
if init_command:
name, args = init_command
table = table.init_command(name, list(args))
return table

132
SYS/item_accessors.py Normal file
View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any:
if isinstance(obj, list):
if not obj:
return default
obj = obj[0]
if isinstance(obj, dict):
return obj.get(field, default)
value = getattr(obj, field, None)
if value is not None:
return value
extra_value = getattr(obj, "extra", None)
if isinstance(extra_value, dict):
return extra_value.get(field, default)
return default
def first_field(obj: Any, fields: Iterable[str], default: Optional[Any] = None) -> Any:
for field in fields:
value = get_field(obj, str(field), None)
if value is not None:
return value
return default
def get_text_field(obj: Any, *fields: str, default: str = "") -> str:
value = first_field(obj, fields, default=None)
if value is None:
return default
text = str(value).strip()
return text if text else default
def get_column_text(obj: Any, *labels: str) -> Optional[str]:
columns = get_field(obj, "columns")
if not isinstance(columns, list):
return None
wanted = {str(label or "").strip().lower() for label in labels if str(label or "").strip()}
if not wanted:
return None
for pair in columns:
try:
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
continue
key, value = pair
if str(key or "").strip().lower() not in wanted:
continue
text = str(value or "").strip()
if text:
return text
except Exception:
continue
return None
def get_int_field(obj: Any, *fields: str) -> Optional[int]:
value = first_field(obj, fields, default=None)
if value is None:
return None
if isinstance(value, (int, float)):
return int(value)
try:
return int(value)
except Exception:
return None
def get_extension_field(obj: Any, *fields: str) -> str:
text = get_text_field(obj, *(fields or ("ext", "extension")), default="")
return text.lstrip(".") if text else ""
def get_result_title(obj: Any, *fields: str) -> Optional[str]:
text = get_text_field(obj, *(fields or ("title", "name", "filename")), default="")
if text:
return text
return get_column_text(obj, "title", "name")
def extract_item_tags(obj: Any) -> list[str]:
return get_string_list(obj, "tag")
def get_string_list(obj: Any, field: str) -> list[str]:
value = get_field(obj, field)
if isinstance(value, list):
return [str(item).strip() for item in value if item is not None and str(item).strip()]
if isinstance(value, str):
text = value.strip()
return [text] if text else []
return []
def set_field(obj: Any, field: str, value: Any) -> bool:
if isinstance(obj, dict):
obj[field] = value
return True
try:
setattr(obj, field, value)
return True
except Exception:
return False
def get_sha256_hex(obj: Any, *fields: str) -> Optional[str]:
value = get_text_field(obj, *(fields or ("hash",)))
if value and _SHA256_RE.fullmatch(value):
return value.lower()
return None
def get_store_name(obj: Any, *fields: str) -> Optional[str]:
value = get_text_field(obj, *(fields or ("store",)))
return value or None
def get_http_url(obj: Any, *fields: str) -> Optional[str]:
value = get_text_field(obj, *(fields or ("url", "target")))
if value.lower().startswith(("http://", "https://")):
return value
return None

158
SYS/payload_builders.py Normal file
View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, Optional, Sequence
from urllib.parse import unquote, urlparse
def normalize_file_extension(ext_value: Any) -> str:
ext = str(ext_value or "").strip().lstrip(".")
for sep in (" ", "|", "(", "[", "{", ",", ";"):
if sep in ext:
ext = ext.split(sep, 1)[0]
break
if "." in ext:
ext = ext.split(".")[-1]
ext = "".join(ch for ch in ext if ch.isalnum())
return ext[:5]
def extract_title_tag_value(tags: Sequence[str]) -> Optional[str]:
for tag in tags:
text = str(tag or "").strip()
if text.lower().startswith("title:"):
value = text.split(":", 1)[1].strip()
if value:
return value
return None
def _derive_title(
title: Optional[str],
fallback_title: Optional[str],
path: Optional[str],
url: Any,
hash_value: Optional[str],
) -> str:
for candidate in (title, fallback_title):
text = str(candidate or "").strip()
if text:
return text
path_text = str(path or "").strip()
if path_text:
try:
return Path(path_text).stem or path_text
except Exception:
return path_text
if isinstance(url, str):
try:
parsed = urlparse(url)
name = Path(unquote(parsed.path)).stem
if name:
return name
except Exception:
pass
text = url.strip()
if text:
return text
if isinstance(url, list):
for candidate in url:
text = str(candidate or "").strip()
if text:
return text
return str(hash_value or "").strip()
def build_file_result_payload(
*,
title: Optional[str] = None,
fallback_title: Optional[str] = None,
path: Optional[str] = None,
url: Any = None,
hash_value: Optional[str] = None,
store: Optional[str] = None,
tag: Optional[Sequence[str]] = None,
ext: Any = None,
size_bytes: Optional[int] = None,
columns: Optional[Sequence[tuple[str, Any]]] = None,
source: Optional[str] = None,
table: Optional[str] = None,
detail: Optional[str] = None,
**extra: Any,
) -> Dict[str, Any]:
resolved_title = _derive_title(title, fallback_title, path, url, hash_value)
resolved_path = str(path).strip() if path is not None and str(path).strip() else None
resolved_store = str(store).strip() if store is not None and str(store).strip() else None
resolved_ext = normalize_file_extension(ext)
if not resolved_ext:
for candidate in (resolved_path, resolved_title):
text = str(candidate or "").strip()
if not text:
continue
try:
resolved_ext = normalize_file_extension(Path(text).suffix)
except Exception:
resolved_ext = ""
if resolved_ext:
break
payload: Dict[str, Any] = {"title": resolved_title}
if resolved_path is not None:
payload["path"] = resolved_path
if hash_value:
payload["hash"] = str(hash_value)
if url not in (None, "", []):
payload["url"] = url
if resolved_store is not None:
payload["store"] = resolved_store
if tag is not None:
payload["tag"] = list(tag)
if resolved_ext:
payload["ext"] = resolved_ext
if size_bytes is not None:
payload["size_bytes"] = size_bytes
if columns is not None:
payload["columns"] = list(columns)
if source:
payload["source"] = source
if table:
payload["table"] = table
if detail is not None:
payload["detail"] = str(detail)
payload.update(extra)
return payload
def build_table_result_payload(
*,
columns: Sequence[tuple[str, Any]],
title: Optional[str] = None,
table: Optional[str] = None,
detail: Optional[str] = None,
selection_args: Optional[Sequence[Any]] = None,
selection_action: Optional[Sequence[Any]] = None,
**extra: Any,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"columns": [(str(label), value) for label, value in columns],
}
if title is not None:
payload["title"] = str(title)
if table:
payload["table"] = table
if detail is not None:
payload["detail"] = str(detail)
if selection_args:
payload["_selection_args"] = [str(arg) for arg in selection_args if arg is not None]
if selection_action:
payload["_selection_action"] = [str(arg) for arg in selection_action if arg is not None]
payload.update(extra)
return payload

60
SYS/result_publication.py Normal file
View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any, Iterable, Optional
def resolve_publication_subject(
items: Iterable[Any] | None,
subject: Any = None,
) -> Any:
if subject is not None:
return subject
resolved_items = list(items or [])
if not resolved_items:
return None
if len(resolved_items) == 1:
return resolved_items[0]
return resolved_items
def publish_result_table(
pipeline_context: Any,
result_table: Any,
items: Iterable[Any] | None = None,
*,
subject: Any = None,
overlay: bool = False,
) -> None:
resolved_items = list(items or [])
resolved_subject = resolve_publication_subject(resolved_items, subject)
if overlay:
pipeline_context.set_last_result_table_overlay(
result_table,
resolved_items,
subject=resolved_subject,
)
return
pipeline_context.set_last_result_table(
result_table,
resolved_items,
subject=resolved_subject,
)
def overlay_existing_result_table(
pipeline_context: Any,
*,
subject: Any = None,
) -> bool:
table = pipeline_context.get_last_result_table()
items = list(pipeline_context.get_last_result_items() or [])
if table is None or not items:
return False
publish_result_table(
pipeline_context,
table,
items,
subject=subject,
overlay=True,
)
return True

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from typing import Any, Iterable
def add_row_columns(table: Any, columns: Iterable[tuple[str, Any]]) -> Any:
row = table.add_row()
for label, value in columns:
row.add_column(str(label), "" if value is None else str(value))
return row

157
SYS/selection_builder.py Normal file
View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import re
from typing import Any, Iterable, List, Optional, Sequence, Tuple
_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
def looks_like_url(value: Any, *, extra_prefixes: Iterable[str] = ()) -> bool:
try:
text = str(value or "").strip().lower()
except Exception:
return False
if not text:
return False
prefixes = ("http://", "https://", "magnet:", "torrent:") + tuple(
str(prefix).strip().lower() for prefix in extra_prefixes if str(prefix).strip()
)
return text.startswith(prefixes)
def normalize_selection_args(selection_args: Any) -> Optional[List[str]]:
if isinstance(selection_args, (list, tuple)):
return [str(arg) for arg in selection_args if arg is not None]
if selection_args is not None:
return [str(selection_args)]
return None
def normalize_hash_for_selection(value: Any) -> str:
text = str(value or "").strip()
if _SHA256_RE.fullmatch(text):
return text.lower()
return text
def build_hash_store_selection(
hash_value: Any,
store_value: Any,
*,
action_name: str = "get-metadata",
) -> Tuple[Optional[List[str]], Optional[List[str]]]:
hash_text = normalize_hash_for_selection(hash_value)
store_text = str(store_value or "").strip()
if not hash_text or not store_text:
return None, None
args = ["-query", f"hash:{hash_text}", "-store", store_text]
return args, [action_name] + list(args)
def build_default_selection(
*,
path_value: Any,
hash_value: Any = None,
store_value: Any = None,
) -> Tuple[Optional[List[str]], Optional[List[str]]]:
path_text = str(path_value or "").strip()
hash_args, hash_action = build_hash_store_selection(hash_value, store_value)
if path_text:
if looks_like_url(path_text):
if hash_args and "/view_file" in path_text:
return hash_args, hash_action
args = ["-url", path_text]
return args, ["download-file", "-url", path_text]
if hash_args:
return hash_args, hash_action
try:
from SYS.utils import expand_path
resolved_path = str(expand_path(path_text))
except Exception:
resolved_path = path_text
args = ["-path", resolved_path]
return args, ["get-file", "-path", resolved_path]
return hash_args, hash_action
def extract_selection_fields(
item: Any,
*,
extra_url_prefixes: Iterable[str] = (),
) -> Tuple[Optional[List[str]], Optional[List[str]], Optional[str]]:
selection_args: Any = None
selection_action: Any = None
item_url: Any = None
if isinstance(item, dict):
selection_args = item.get("_selection_args") or item.get("selection_args")
selection_action = item.get("_selection_action") or item.get("selection_action")
item_url = item.get("url") or item.get("path") or item.get("target")
nested_values = [item.get("metadata"), item.get("full_metadata"), item.get("extra")]
else:
item_url = getattr(item, "url", None) or getattr(item, "path", None) or getattr(item, "target", None)
nested_values = [
getattr(item, "metadata", None),
getattr(item, "full_metadata", None),
getattr(item, "extra", None),
]
for nested in nested_values:
if not isinstance(nested, dict):
continue
selection_args = selection_args or nested.get("_selection_args") or nested.get("selection_args")
selection_action = selection_action or nested.get("_selection_action") or nested.get("selection_action")
item_url = item_url or nested.get("url") or nested.get("source_url") or nested.get("target")
normalized_args = normalize_selection_args(selection_args)
normalized_action = normalize_selection_args(selection_action)
if item_url and not looks_like_url(item_url, extra_prefixes=extra_url_prefixes):
item_url = None
return normalized_args, normalized_action, str(item_url) if item_url else None
def selection_args_have_url(
args_list: Sequence[str],
*,
extra_url_prefixes: Iterable[str] = (),
) -> bool:
for idx, arg in enumerate(args_list):
low = str(arg or "").strip().lower()
if low in {"-url", "--url"} and idx + 1 < len(args_list):
return True
if looks_like_url(arg, extra_prefixes=extra_url_prefixes):
return True
return False
def extract_urls_from_selection_args(
args_list: Sequence[str],
*,
extra_url_prefixes: Iterable[str] = (),
) -> List[str]:
urls: List[str] = []
idx = 0
while idx < len(args_list):
token = str(args_list[idx] or "")
low = token.strip().lower()
if low in {"-url", "--url"} and idx + 1 < len(args_list):
candidate = str(args_list[idx + 1] or "").strip()
if looks_like_url(candidate, extra_prefixes=extra_url_prefixes) and candidate not in urls:
urls.append(candidate)
idx += 2
continue
if looks_like_url(token, extra_prefixes=extra_url_prefixes):
candidate = token.strip()
if candidate not in urls:
urls.append(candidate)
idx += 1
return urls