2025-12-12 21:55:38 -08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
2025-12-20 23:57:44 -08:00
|
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
2025-12-12 21:55:38 -08:00
|
|
|
import sys
|
2025-12-21 05:10:09 -08:00
|
|
|
import re
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
from SYS.logger import log
|
|
|
|
|
|
2025-12-29 23:28:15 -08:00
|
|
|
from SYS import pipeline as ctx
|
2025-12-16 23:23:43 -08:00
|
|
|
from . import _shared as sh
|
|
|
|
|
|
|
|
|
|
Cmdlet = sh.Cmdlet
|
|
|
|
|
CmdletArg = sh.CmdletArg
|
2025-12-27 21:24:27 -08:00
|
|
|
QueryArg = sh.QueryArg
|
2025-12-16 23:23:43 -08:00
|
|
|
SharedArgs = sh.SharedArgs
|
|
|
|
|
normalize_hash = sh.normalize_hash
|
|
|
|
|
parse_cmdlet_args = sh.parse_cmdlet_args
|
|
|
|
|
normalize_result_input = sh.normalize_result_input
|
|
|
|
|
should_show_help = sh.should_show_help
|
2025-12-12 21:55:38 -08:00
|
|
|
from Store import Store
|
|
|
|
|
from SYS.utils import sha256_file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Add_Note(Cmdlet):
|
2026-01-02 02:28:59 -08:00
|
|
|
DEFAULT_QUERY_HINTS = (
|
|
|
|
|
"title:",
|
|
|
|
|
"text:",
|
|
|
|
|
"hash:",
|
|
|
|
|
"caption:",
|
|
|
|
|
"sub:",
|
|
|
|
|
"subtitle:",
|
|
|
|
|
)
|
2025-12-29 18:42:02 -08:00
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
def __init__(self) -> None:
|
|
|
|
|
super().__init__(
|
|
|
|
|
name="add-note",
|
2025-12-17 03:16:41 -08:00
|
|
|
summary="Add file store note",
|
2025-12-29 18:42:02 -08:00
|
|
|
usage=
|
|
|
|
|
'add-note (-query "title:<title>,text:<text>[,store:<store>][,hash:<sha256>]") [ -store <store> | <piped> ]',
|
2025-12-17 03:16:41 -08:00
|
|
|
alias=[""],
|
2025-12-12 21:55:38 -08:00
|
|
|
arg=[
|
|
|
|
|
SharedArgs.STORE,
|
2025-12-27 21:24:27 -08:00
|
|
|
QueryArg(
|
|
|
|
|
"hash",
|
|
|
|
|
key="hash",
|
|
|
|
|
aliases=["sha256"],
|
|
|
|
|
type="string",
|
|
|
|
|
required=False,
|
|
|
|
|
handler=normalize_hash,
|
2025-12-29 18:42:02 -08:00
|
|
|
description=
|
|
|
|
|
"(Optional) Specific file hash target, provided via -query as hash:<sha256>. When omitted, uses piped item hash.",
|
2025-12-27 21:24:27 -08:00
|
|
|
query_only=True,
|
|
|
|
|
),
|
2025-12-20 02:12:45 -08:00
|
|
|
SharedArgs.QUERY,
|
2025-12-12 21:55:38 -08:00
|
|
|
],
|
2025-12-29 18:42:02 -08:00
|
|
|
detail=["""
|
2025-12-17 03:16:41 -08:00
|
|
|
dde
|
2025-12-29 18:42:02 -08:00
|
|
|
"""],
|
2025-12-12 21:55:38 -08:00
|
|
|
exec=self.run,
|
|
|
|
|
)
|
|
|
|
|
# Populate dynamic store choices for autocomplete
|
|
|
|
|
try:
|
|
|
|
|
SharedArgs.STORE.choices = SharedArgs.get_store_choices(None)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self.register()
|
|
|
|
|
|
2025-12-21 05:10:09 -08:00
|
|
|
@staticmethod
|
|
|
|
|
def _commas_to_spaces_outside_quotes(text: str) -> str:
|
|
|
|
|
buf: List[str] = []
|
|
|
|
|
quote: Optional[str] = None
|
|
|
|
|
escaped = False
|
|
|
|
|
for ch in str(text or ""):
|
|
|
|
|
if escaped:
|
|
|
|
|
buf.append(ch)
|
|
|
|
|
escaped = False
|
|
|
|
|
continue
|
|
|
|
|
if ch == "\\" and quote is not None:
|
|
|
|
|
buf.append(ch)
|
|
|
|
|
escaped = True
|
|
|
|
|
continue
|
|
|
|
|
if ch in ('"', "'"):
|
|
|
|
|
if quote is None:
|
|
|
|
|
quote = ch
|
|
|
|
|
elif quote == ch:
|
|
|
|
|
quote = None
|
|
|
|
|
buf.append(ch)
|
|
|
|
|
continue
|
|
|
|
|
if ch == "," and quote is None:
|
|
|
|
|
buf.append(" ")
|
|
|
|
|
continue
|
|
|
|
|
buf.append(ch)
|
|
|
|
|
return "".join(buf)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _parse_note_query(query: str) -> Tuple[Optional[str], Optional[str]]:
|
|
|
|
|
"""Parse note payload from -query.
|
|
|
|
|
|
|
|
|
|
Expected:
|
|
|
|
|
title:<title>,text:<text>
|
|
|
|
|
Commas are treated as separators when not inside quotes.
|
|
|
|
|
"""
|
|
|
|
|
raw = str(query or "").strip()
|
|
|
|
|
if not raw:
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-29 18:42:02 -08:00
|
|
|
from SYS.cli_syntax import parse_query, get_field
|
2025-12-21 05:10:09 -08:00
|
|
|
except Exception:
|
|
|
|
|
parse_query = None # type: ignore
|
|
|
|
|
get_field = None # type: ignore
|
|
|
|
|
|
|
|
|
|
normalized = Add_Note._commas_to_spaces_outside_quotes(raw)
|
|
|
|
|
|
|
|
|
|
if callable(parse_query) and callable(get_field):
|
|
|
|
|
parsed = parse_query(normalized)
|
|
|
|
|
name = get_field(parsed, "title")
|
|
|
|
|
text = get_field(parsed, "text")
|
|
|
|
|
name_s = str(name or "").strip() if name is not None else ""
|
|
|
|
|
text_s = str(text or "").strip() if text is not None else ""
|
|
|
|
|
return (name_s or None, text_s or None)
|
|
|
|
|
|
|
|
|
|
# Fallback: best-effort regex.
|
2025-12-29 18:42:02 -08:00
|
|
|
name_match = re.search(
|
|
|
|
|
r"\btitle\s*:\s*([^,\s]+)",
|
|
|
|
|
normalized,
|
|
|
|
|
flags=re.IGNORECASE
|
|
|
|
|
)
|
2025-12-21 05:10:09 -08:00
|
|
|
text_match = re.search(r"\btext\s*:\s*(.+)$", normalized, flags=re.IGNORECASE)
|
2025-12-29 17:05:03 -08:00
|
|
|
note_name = name_match.group(1).strip() if name_match else ""
|
|
|
|
|
note_text = text_match.group(1).strip() if text_match else ""
|
2025-12-21 05:10:09 -08:00
|
|
|
return (note_name or None, note_text or None)
|
|
|
|
|
|
2026-01-02 02:28:59 -08:00
|
|
|
@classmethod
|
|
|
|
|
def _looks_like_note_query_token(cls, token: Any) -> bool:
|
|
|
|
|
text = str(token or "").strip().lower()
|
|
|
|
|
if not text:
|
|
|
|
|
return False
|
|
|
|
|
return any(hint in text for hint in cls.DEFAULT_QUERY_HINTS)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def _default_query_args(cls, args: Sequence[str]) -> List[str]:
|
|
|
|
|
tokens: List[str] = list(args or [])
|
|
|
|
|
lower_tokens = {str(tok).lower() for tok in tokens if tok is not None}
|
|
|
|
|
if "-query" in lower_tokens or "--query" in lower_tokens:
|
|
|
|
|
return tokens
|
|
|
|
|
|
|
|
|
|
for idx, tok in enumerate(tokens):
|
|
|
|
|
token_text = str(tok or "")
|
|
|
|
|
if not token_text or token_text.startswith("-"):
|
|
|
|
|
continue
|
|
|
|
|
if not cls._looks_like_note_query_token(token_text):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
combined_parts = [token_text]
|
|
|
|
|
end = idx + 1
|
|
|
|
|
while end < len(tokens):
|
|
|
|
|
next_text = str(tokens[end] or "")
|
|
|
|
|
if not next_text or next_text.startswith("-"):
|
|
|
|
|
break
|
|
|
|
|
if not cls._looks_like_note_query_token(next_text):
|
|
|
|
|
break
|
|
|
|
|
combined_parts.append(next_text)
|
|
|
|
|
end += 1
|
|
|
|
|
|
|
|
|
|
combined_query = " ".join(combined_parts)
|
|
|
|
|
tokens[idx:end] = [combined_query]
|
|
|
|
|
tokens.insert(idx, "-query")
|
|
|
|
|
return tokens
|
|
|
|
|
|
|
|
|
|
return tokens
|
|
|
|
|
|
2025-12-29 17:05:03 -08:00
|
|
|
def _resolve_hash(
|
2025-12-29 18:42:02 -08:00
|
|
|
self,
|
|
|
|
|
raw_hash: Optional[str],
|
|
|
|
|
raw_path: Optional[str],
|
|
|
|
|
override_hash: Optional[str]
|
2025-12-29 17:05:03 -08:00
|
|
|
) -> Optional[str]:
|
2025-12-29 18:42:02 -08:00
|
|
|
resolved = normalize_hash(override_hash
|
|
|
|
|
) if override_hash else normalize_hash(raw_hash)
|
2025-12-12 21:55:38 -08:00
|
|
|
if resolved:
|
|
|
|
|
return resolved
|
|
|
|
|
|
|
|
|
|
if raw_path:
|
|
|
|
|
try:
|
|
|
|
|
p = Path(str(raw_path))
|
|
|
|
|
stem = p.stem
|
2025-12-29 18:42:02 -08:00
|
|
|
if len(stem) == 64 and all(c in "0123456789abcdef"
|
|
|
|
|
for c in stem.lower()):
|
2025-12-12 21:55:38 -08:00
|
|
|
return stem.lower()
|
|
|
|
|
if p.exists() and p.is_file():
|
|
|
|
|
return sha256_file(p)
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|
|
|
|
if should_show_help(args):
|
|
|
|
|
log(f"Cmdlet: {self.name}\nSummary: {self.summary}\nUsage: {self.usage}")
|
|
|
|
|
return 0
|
|
|
|
|
|
2026-01-02 02:28:59 -08:00
|
|
|
parsed_args = self._default_query_args(args)
|
|
|
|
|
parsed = parse_cmdlet_args(parsed_args, self)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
store_override = parsed.get("store")
|
2025-12-21 05:10:09 -08:00
|
|
|
hash_override = normalize_hash(parsed.get("hash"))
|
|
|
|
|
note_name, note_text = self._parse_note_query(str(parsed.get("query") or ""))
|
2026-01-02 02:28:59 -08:00
|
|
|
note_name = str(note_name or "").strip()
|
|
|
|
|
note_text = str(note_text or "").strip()
|
2025-12-21 05:10:09 -08:00
|
|
|
if not note_name or not note_text:
|
2025-12-29 17:05:03 -08:00
|
|
|
log(
|
|
|
|
|
"[add_note] Error: -query must include title:<title> and text:<text>",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
2025-12-20 02:12:45 -08:00
|
|
|
return 1
|
2025-12-12 21:55:38 -08:00
|
|
|
|
2025-12-21 05:10:09 -08:00
|
|
|
if hash_override and not store_override:
|
2025-12-29 17:05:03 -08:00
|
|
|
log(
|
|
|
|
|
"[add_note] Error: hash:<sha256> requires store:<store> in -query or -store <store>",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
return 1
|
|
|
|
|
|
2025-12-21 05:10:09 -08:00
|
|
|
explicit_target = bool(hash_override and store_override)
|
2025-12-12 21:55:38 -08:00
|
|
|
results = normalize_result_input(result)
|
2025-12-21 05:10:09 -08:00
|
|
|
if results and explicit_target:
|
|
|
|
|
# Direct targeting mode: apply note once to the explicit target and
|
|
|
|
|
# pass through any piped items unchanged.
|
|
|
|
|
try:
|
|
|
|
|
store_registry = Store(config)
|
|
|
|
|
backend = store_registry[str(store_override)]
|
2025-12-29 18:42:02 -08:00
|
|
|
ok = bool(
|
|
|
|
|
backend.set_note(
|
|
|
|
|
str(hash_override),
|
|
|
|
|
note_name,
|
|
|
|
|
note_text,
|
|
|
|
|
config=config
|
|
|
|
|
)
|
|
|
|
|
)
|
2025-12-21 05:10:09 -08:00
|
|
|
if ok:
|
2025-12-29 17:05:03 -08:00
|
|
|
ctx.print_if_visible(
|
2025-12-29 18:42:02 -08:00
|
|
|
f"✓ add-note: 1 item in '{store_override}'",
|
|
|
|
|
file=sys.stderr
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
2026-01-02 02:28:59 -08:00
|
|
|
log(
|
|
|
|
|
"[add_note] Updated 1/1 item(s)",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
|
|
|
|
for res in results:
|
|
|
|
|
ctx.emit(res)
|
|
|
|
|
return 0
|
|
|
|
|
log(
|
|
|
|
|
"[add_note] Warning: Note write reported failure",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
|
|
|
|
return 1
|
2025-12-21 05:10:09 -08:00
|
|
|
except Exception as exc:
|
|
|
|
|
log(f"[add_note] Error: Failed to set note: {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
if not results:
|
2025-12-21 05:10:09 -08:00
|
|
|
if explicit_target:
|
|
|
|
|
# Allow standalone use (no piped input) and enable piping the target forward.
|
2025-12-29 18:42:02 -08:00
|
|
|
results = [{
|
|
|
|
|
"store": str(store_override),
|
|
|
|
|
"hash": hash_override
|
|
|
|
|
}]
|
2025-12-12 21:55:38 -08:00
|
|
|
else:
|
2025-12-29 17:05:03 -08:00
|
|
|
log(
|
|
|
|
|
'[add_note] Error: Requires piped item(s) from add-file, or explicit targeting via store/hash (e.g., -query "store:<store> hash:<sha256> ...")',
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
store_registry = Store(config)
|
2026-01-02 02:28:59 -08:00
|
|
|
planned_ops = 0
|
2025-12-12 21:55:38 -08:00
|
|
|
|
2025-12-20 23:57:44 -08:00
|
|
|
# Batch write plan: store -> [(hash, name, text), ...]
|
2025-12-29 18:42:02 -08:00
|
|
|
note_ops: Dict[str,
|
|
|
|
|
List[Tuple[str,
|
|
|
|
|
str,
|
|
|
|
|
str]]] = {}
|
2025-12-20 23:57:44 -08:00
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
for res in results:
|
|
|
|
|
if not isinstance(res, dict):
|
|
|
|
|
ctx.emit(res)
|
|
|
|
|
continue
|
|
|
|
|
|
2025-12-16 23:23:43 -08:00
|
|
|
item_note_text = note_text
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
store_name = str(store_override or res.get("store") or "").strip()
|
|
|
|
|
raw_hash = res.get("hash")
|
|
|
|
|
raw_path = res.get("path")
|
|
|
|
|
|
|
|
|
|
if not store_name:
|
2025-12-29 18:42:02 -08:00
|
|
|
log(
|
|
|
|
|
"[add_note] Error: Missing -store and item has no store field",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
resolved_hash = self._resolve_hash(
|
|
|
|
|
raw_hash=str(raw_hash) if raw_hash else None,
|
|
|
|
|
raw_path=str(raw_path) if raw_path else None,
|
2025-12-21 05:10:09 -08:00
|
|
|
override_hash=str(hash_override) if hash_override else None,
|
2025-12-12 21:55:38 -08:00
|
|
|
)
|
|
|
|
|
if not resolved_hash:
|
2025-12-29 18:42:02 -08:00
|
|
|
log(
|
|
|
|
|
"[add_note] Warning: Item missing usable hash; skipping",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
ctx.emit(res)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
backend = store_registry[store_name]
|
|
|
|
|
except Exception as exc:
|
2025-12-29 18:42:02 -08:00
|
|
|
log(
|
|
|
|
|
f"[add_note] Error: Unknown store '{store_name}': {exc}",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
return 1
|
|
|
|
|
|
2025-12-20 23:57:44 -08:00
|
|
|
# Queue for bulk write per store. We still emit items immediately;
|
|
|
|
|
# the pipeline only advances after this cmdlet returns.
|
2025-12-29 18:42:02 -08:00
|
|
|
note_ops.setdefault(store_name,
|
|
|
|
|
[]).append((resolved_hash,
|
|
|
|
|
note_name,
|
|
|
|
|
item_note_text))
|
2026-01-02 02:28:59 -08:00
|
|
|
planned_ops += 1
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
ctx.emit(res)
|
|
|
|
|
|
2025-12-20 23:57:44 -08:00
|
|
|
# Execute bulk writes per store.
|
2026-01-02 02:28:59 -08:00
|
|
|
successful_writes = 0
|
2025-12-20 23:57:44 -08:00
|
|
|
for store_name, ops in note_ops.items():
|
|
|
|
|
if not ops:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
backend = store_registry[store_name]
|
|
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
|
2026-01-02 02:28:59 -08:00
|
|
|
store_success = 0
|
2025-12-20 23:57:44 -08:00
|
|
|
bulk_fn = getattr(backend, "set_note_bulk", None)
|
|
|
|
|
if callable(bulk_fn):
|
|
|
|
|
try:
|
|
|
|
|
ok = bool(bulk_fn(list(ops), config=config))
|
2026-01-02 02:28:59 -08:00
|
|
|
if ok:
|
|
|
|
|
store_success += len(ops)
|
|
|
|
|
ctx.print_if_visible(
|
|
|
|
|
f"✓ add-note: {len(ops)} item(s) in '{store_name}'",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
|
|
|
|
successful_writes += store_success
|
|
|
|
|
continue
|
|
|
|
|
log(
|
|
|
|
|
f"[add_note] Warning: bulk set_note returned False for '{store_name}'",
|
|
|
|
|
file=sys.stderr,
|
2025-12-29 17:05:03 -08:00
|
|
|
)
|
2025-12-20 23:57:44 -08:00
|
|
|
except Exception as exc:
|
2025-12-29 17:05:03 -08:00
|
|
|
log(
|
|
|
|
|
f"[add_note] Warning: bulk set_note failed for '{store_name}': {exc}; falling back",
|
|
|
|
|
file=sys.stderr,
|
|
|
|
|
)
|
2025-12-20 23:57:44 -08:00
|
|
|
|
|
|
|
|
# Fallback: per-item writes
|
|
|
|
|
for file_hash, name, text in ops:
|
|
|
|
|
try:
|
|
|
|
|
ok = bool(backend.set_note(file_hash, name, text, config=config))
|
2026-01-02 02:28:59 -08:00
|
|
|
if ok:
|
|
|
|
|
store_success += 1
|
2025-12-20 23:57:44 -08:00
|
|
|
except Exception:
|
|
|
|
|
continue
|
|
|
|
|
|
2026-01-02 02:28:59 -08:00
|
|
|
if store_success:
|
|
|
|
|
successful_writes += store_success
|
|
|
|
|
ctx.print_if_visible(
|
|
|
|
|
f"✓ add-note: {store_success} item(s) in '{store_name}'",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
log(
|
|
|
|
|
f"[add_note] Updated {successful_writes}/{planned_ops} item(s)",
|
|
|
|
|
file=sys.stderr
|
|
|
|
|
)
|
|
|
|
|
return 0 if successful_writes > 0 else 1
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
CMDLET = Add_Note()
|