From d5d2989f291ecd41764ee529b76df7ef3825dd72 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:10:54 +0300 Subject: [PATCH] feat: shortcut categories, JSON export/import (reads v4 files), no inert refinement-key field, no dead Files tab; single-scene prompt no longer writes the anchor twice Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + app/legacy/api.js | 6 +- app/legacy/composer.js | 104 +------------------------- app/legacy/store.js | 6 +- core/config.py | 3 + core/routes.py | 65 +++++++++++++---- core/shortcuts.py | 109 +++++++++++++++++++++++++++- core/tests/test_routes_shortcuts.py | 15 ++++ core/tests/test_shortcuts.py | 47 ++++++++++++ 9 files changed, 233 insertions(+), 123 deletions(-) diff --git a/.gitignore b/.gitignore index 589218cc..befcdce0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ app/playwright-report/ /media/ /shortcuts.json /markers.json +/shortcut_categories.json # Taste keys: what your ratings taught, per rental taste/ diff --git a/app/legacy/api.js b/app/legacy/api.js index 3540c4b9..e91cda07 100644 --- a/app/legacy/api.js +++ b/app/legacy/api.js @@ -116,11 +116,11 @@ // --- prompt shortcuts (a/b) --------------------------------------------- shortcuts: () => j("GET", API("/api/shortcuts")), // {shortcuts:[...]} -- callers unwrap .shortcuts themselves suggestionStats: () => unsupported("no revolver in v5 yet"), - saveCategory: () => unsupported("shortcut categories are per-shortcut fields in v5, not a separate CRUD"), + saveCategory: (item) => j("POST", API("/api/shortcuts/category"), item), saveShortcut: (item) => j("POST", API("/api/shortcuts"), item), deleteShortcut: (name) => j("DELETE", API(`/api/shortcuts/${encodeURIComponent(name)}`)), - exportShortcutsUrl: unsupportedUrl, - importShortcuts: () => unsupported("no import route in v5 yet"), + exportShortcutsUrl: () => API("/api/shortcuts/export"), + importShortcuts: (data, mode) => j("POST", API("/api/shortcuts/import"), { data, mode }), clearShortcuts: () => j("POST", API("/api/shortcuts/clear"), {}), revolverSettings: () => unsupported("no revolver in v5 yet"), setRevolverSettings: () => unsupported("no revolver in v5 yet"), diff --git a/app/legacy/composer.js b/app/legacy/composer.js index 1b9494e1..39cd90ef 100644 --- a/app/legacy/composer.js +++ b/app/legacy/composer.js @@ -630,28 +630,14 @@ } paintGrouping(); - // Per-shortcut refinement key: firing this shortcut marks the named key as training. - const existingKey = String(item.refinement_key || "").trim(); - const useKey = checkRow("Use non-default refinement key", !!existingKey); - const keyField = labeled("Refinement key", (() => { - const i = el("input", "lib-in"); i.placeholder = "key name"; i.value = existingKey; return i; - })()); - const keyInput = keyField.querySelector("input"); - keyField.style.display = existingKey ? "" : "none"; - useKey._cb.onchange = () => { keyField.style.display = useKey._cb.checked ? "" : "none"; if (useKey._cb.checked) keyInput.focus(); }; - box.append(useKey, keyField); - const actions = el("div", "lib-form-actions"); const save = el("button", "btn primary tiny", "Save"); save.onclick = async () => { const triggers = splitTriggers(trig.value); if (!triggers.length) { alert("At least one trigger is required."); return; } - const refKey = useKey._cb.checked ? (keyInput.value || "").trim() : ""; - if (useKey._cb.checked && !refKey) { alert("Enter a refinement key name, or uncheck the box."); return; } await S.saveShortcut({ name: name.value.trim() || triggers[0], triggers, replacements: splitReplacements(reps.value), enabled: en._cb.checked, - refinement_key: refKey, category: grouping.category, sub_category: grouping.sub_category, original_name: item.name || undefined, }); @@ -768,90 +754,8 @@ return wrap; } - // ── files (FunPack on-disk file manager) ──────────────────────────────────────── - // A read/purge view over the files FunPack writes: the prompt library JSONs and the - // refinement-key store (keys + sidecars + value/latent tensors). Lets the user audit - // and delete them without leaving the editor. Fetched on demand, not held in the store. - let filesData = null; - let filesLoading = false; - - function fmtBytes(n) { - n = Number(n) || 0; - if (n < 1024) return n + " B"; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; - return (n / (1024 * 1024)).toFixed(1) + " MB"; - } - - async function loadFiles() { - filesLoading = true; - try { filesData = await API.listFiles(); } - catch (e) { filesData = { groups: [], error: e.message }; } - filesLoading = false; - if (tab === "Files") render(); - } - - // After a file mutation that could touch the prompt-library JSONs, re-pull the - // Shortcuts/Splits state so those tabs never show entries whose file is now gone. - function refreshLibraryAfterFileChange() { - if (S.loadShortcuts) S.loadShortcuts(); - if (S.loadTransitions) S.loadTransitions(); - } - - function filesTab() { - const wrap = el("div", "bin"); - const toolbar = el("div", "bin-toolbar"); - const refresh = el("button", "btn ghost tiny", "↻ Refresh"); refresh.onclick = () => loadFiles(); - toolbar.append(refresh); - wrap.append(toolbar); - - if (filesData == null) { if (!filesLoading) loadFiles(); wrap.append(el("div", "pj-meta", "Loading…")); return wrap; } - if (filesData.error) wrap.append(el("div", "pj-meta", "Error: " + filesData.error)); - - (filesData.groups || []).forEach((g) => { - const sec = el("div", "files-group"); - const head = el("div", "files-group-head"); - head.append(el("div", "files-group-title", g.label)); - if (g.files.length) { - const clr = el("button", "btn danger tiny", "✕ Delete all"); - clr.title = "Delete every file in this group"; - clr.onclick = async () => { - if (!confirm(`Delete ALL ${g.files.length} file(s) under "${g.label}"? This cannot be undone.`)) return; - try { filesData = await API.clearFiles(g.id); refreshLibraryAfterFileChange(); render(); } - catch (e) { alert("Delete-all failed: " + e.message); } - }; - head.append(clr); - } - sec.append(head); - sec.append(el("div", "files-dir", g.dir)); - - const list = el("div", "files-list"); - if (!g.files.length) { - list.append(el("div", "pj-meta", "No files.")); - } else { - g.files.forEach((f) => { - const row = el("div", "files-row"); - const main = el("div", "files-main"); - main.append(el("div", "files-name", f.name)); - const meta = [fmtBytes(f.size), f.kind || ""].filter(Boolean).join(" · "); - main.append(el("div", "files-meta", meta)); - row.append(main); - const del = el("button", "ic-btn danger", "✕"); del.title = "Delete file"; - del.onclick = async () => { - if (!confirm(`Delete "${f.name}"? This cannot be undone.`)) return; - try { filesData = await API.deleteFile(g.id, f.name); refreshLibraryAfterFileChange(); render(); } - catch (e) { alert("Delete failed: " + e.message); } - }; - row.append(del); list.append(row); - }); - } - sec.append(list); - wrap.append(sec); - }); - return wrap; - } - // ── window + tabs ──────────────────────────────────────────────────────────────── - const TABS = ["Story", "Shortcuts", "Cuts", "Files"]; + const TABS = ["Story", "Shortcuts", "Cuts"]; function render() { if (!win) return; const st = S.get(); @@ -863,11 +767,10 @@ const b = el("button", "bin-tab" + (tab === name ? " active" : ""), name); b.title = name === "Cuts" ? "The words that cut the story into scenes" : name === "Story" ? "Every scene as one text" - : name === "Files" ? "FunPack files on disk — audit & purge" : name; + : name; b.onclick = () => { if (tab === name) return; tab = name; closeModal(); - if (name === "Files") filesData = null; // re-fetch fresh on every open render(); }; tabs.append(b); @@ -877,8 +780,7 @@ scroll.append( tab === "Story" ? composeTab(st) : tab === "Shortcuts" ? shortcutsTab(st) - : tab === "Cuts" ? splitMarkersTab() - : filesTab(), + : splitMarkersTab(), ); shell.append(scroll); root.append(shell); diff --git a/app/legacy/store.js b/app/legacy/store.js index 3a70263e..1845b5a7 100644 --- a/app/legacy/store.js +++ b/app/legacy/store.js @@ -3680,10 +3680,10 @@ const text = targetSceneIds.length > 1 ? buildGlobalPromptFromTimeline(state.project) : (() => { + // The scene's own words only: /api/prompt/expand puts the anchor and postfix + // around them, and sending the anchor here as well wrote it into the prompt twice. const sc = scene(targetSceneIds[0]); - const anchor = (state.project.anchor || "").trim(); - const own = ((sc && sc.text) || "").trim(); - return [anchor, own].filter(Boolean).join(" "); + return ((sc && sc.text) || "").trim(); })(); if (text == null) return raw; try { diff --git a/core/config.py b/core/config.py index ca9433a4..da8f81ab 100644 --- a/core/config.py +++ b/core/config.py @@ -29,6 +29,9 @@ MEDIA_DIR = ROOT / "media" # a shortcut is reused across every project, not redefined in each one, so it # lives beside PROJECTS_DIR rather than inside any one project file. SHORTCUTS_FILE = ROOT / "shortcuts.json" +# Categories a person made before putting anything in them (the picker offers +# them); ones a shortcut already names need no entry here. +SHORTCUT_CATEGORIES_FILE = ROOT / "shortcut_categories.json" # The words that cut a story into scenes (core/story.py). Global for the same # reason the shortcut library is. diff --git a/core/routes.py b/core/routes.py index 74dbf49d..ef2d1fe4 100644 --- a/core/routes.py +++ b/core/routes.py @@ -800,35 +800,72 @@ def register(routes, prefix=None): # see core/shortcuts.py. CRUD here; the expansion itself is stateless # (below), not tied to saving or loading a shortcut. - @routes.get(P + "/api/shortcuts") - async def _shortcuts_list(_req): - return web.json_response({"shortcuts": [s.to_dict() for s in shortcuts_mod.listing()]}) + def _library(): + return web.json_response({"shortcuts": [s.to_dict() for s in shortcuts_mod.listing()], + "categories": shortcuts_mod.categories()}) - @routes.post(P + "/api/shortcuts") - async def _shortcuts_save(req): + async def _body(req): try: body = await req.json() except Exception: # noqa: BLE001 - return web.json_response({"problems": ["that is not JSON"]}, status=400) - if not isinstance(body, dict): - return web.json_response( - {"problems": [f"a shortcut is an object, not a {type(body).__name__}"]}, status=400) + return None + return body if isinstance(body, dict) else None + + @routes.get(P + "/api/shortcuts") + async def _shortcuts_list(_req): + return _library() + + @routes.post(P + "/api/shortcuts") + async def _shortcuts_save(req): + body = await _body(req) + if body is None: + return web.json_response({"problems": ["a shortcut is a JSON object"]}, status=400) original_name = body.get("original_name") try: - items = shortcuts_mod.save(body, original_name if isinstance(original_name, str) else None) + shortcuts_mod.save(body, original_name if isinstance(original_name, str) else None) except ValueError as exc: return web.json_response({"problems": [str(exc)]}, status=400) - return web.json_response({"shortcuts": [s.to_dict() for s in items]}) + return _library() @routes.delete(P + "/api/shortcuts/{name}") async def _shortcuts_delete(req): - items = shortcuts_mod.delete(req.match_info["name"]) - return web.json_response({"shortcuts": [s.to_dict() for s in items]}) + shortcuts_mod.delete(req.match_info["name"]) + return _library() @routes.post(P + "/api/shortcuts/clear") async def _shortcuts_clear(_req): shortcuts_mod.clear() - return web.json_response({"shortcuts": []}) + return _library() + + @routes.post(P + "/api/shortcuts/category") + async def _shortcuts_category(req): + body = await _body(req) + if body is None: + return web.json_response({"problems": ["send {category, sub_category}"]}, status=400) + try: + shortcuts_mod.add_category(body.get("category"), body.get("sub_category") or "") + except ValueError as exc: + return web.json_response({"problems": [str(exc)]}, status=400) + return _library() + + @routes.get(P + "/api/shortcuts/export") + async def _shortcuts_export(_req): + return web.Response( + text=json.dumps(shortcuts_mod.export_payload(), indent=2), content_type="application/json", + headers={"Content-Disposition": 'attachment; filename="funpack_shortcuts.json"'}) + + @routes.post(P + "/api/shortcuts/import") + async def _shortcuts_import(req): + body = await _body(req) + if body is None or "data" not in body: + return web.json_response({"problems": ["send {data, mode}"]}, status=400) + try: + n = shortcuts_mod.import_payload(body["data"], body.get("mode") or "merge") + except ValueError as exc: + return web.json_response({"problems": [str(exc)]}, status=400) + lib = json.loads(_library().text) + lib["imported"] = n + return web.json_response(lib) # ── story ───────────────────────────────────────────────────────────── # All scenes as one box, cut by marker words -- see core/story.py. Split diff --git a/core/shortcuts.py b/core/shortcuts.py index 881f9a53..b283c995 100644 --- a/core/shortcuts.py +++ b/core/shortcuts.py @@ -71,12 +71,13 @@ class Shortcut: @staticmethod def from_dict(d) -> "Shortcut": d = d if isinstance(d, dict) else {} - triggers = _clean_list(d.get("triggers")) + # v4 files spelled these differently; an import must read them. + triggers = _clean_list(d.get("triggers", d.get("activation_words", d.get("activation")))) name = str(d.get("name") or "").strip()[:MAX_NAME] or (triggers[0] if triggers else "") return Shortcut( name=name, triggers=triggers, - replacements=_clean_list(d.get("replacements"), keep_empty=True), + replacements=_clean_list(d.get("replacements", d.get("replacement")), keep_empty=True), enabled=bool(d.get("enabled", True)), category=str(d.get("category") or "").strip()[:MAX_NAME], sub_category=str(d.get("sub_category") or "").strip()[:MAX_NAME], @@ -142,9 +143,113 @@ def delete(name: str) -> list[Shortcut]: def clear() -> list[Shortcut]: with _LOCK: _save_all([]) + _save_categories([]) return [] +# --- categories -------------------------------------------------------------- + +def _label(v) -> str: + return re.sub(r"\s+", " ", str(v or "").strip())[:MAX_NAME] + + +def _union(cats: list[dict], name, sub="") -> None: + name, sub = _label(name), _label(sub) + if not name: + return + entry = next((c for c in cats if c["name"].lower() == name.lower()), None) + if entry is None: + entry = {"name": name, "sub_categories": []} + cats.append(entry) + if sub and sub.lower() not in (s.lower() for s in entry["sub_categories"]): + entry["sub_categories"].append(sub) + + +def _saved_categories() -> list[dict]: + try: + data = json.loads(config.SHORTCUT_CATEGORIES_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return [] + out: list[dict] = [] + for e in data if isinstance(data, list) else []: + if isinstance(e, str): + e = {"name": e} + if isinstance(e, dict): + _union(out, e.get("name")) + for s in e.get("sub_categories") or []: + _union(out, e.get("name"), s) + return out + + +def _save_categories(cats: list[dict]) -> None: + config.ROOT.mkdir(parents=True, exist_ok=True) + tmp = config.SHORTCUT_CATEGORIES_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(cats, indent=2), encoding="utf-8") + tmp.replace(config.SHORTCUT_CATEGORIES_FILE) + + +def categories() -> list[dict]: + """[{name, sub_categories}] -- the ones a person made plus every one a + shortcut names, so a grouping in use can never be missing from the picker.""" + cats = _saved_categories() + for it in listing(): + _union(cats, it.category, it.sub_category) + return cats + + +def add_category(name, sub_category="") -> list[dict]: + if not _label(name): + raise ValueError("a category needs a name") + with _LOCK: + cats = _saved_categories() + _union(cats, name, sub_category) + _save_categories(cats) + return categories() + + +# --- export / import --------------------------------------------------------- + +def export_payload() -> dict: + return {"version": 2, "shortcuts": [s.to_dict() for s in listing()], "categories": categories()} + + +def import_payload(data, mode: str = "merge") -> int: + """Read a file written by export_payload() or by v4 (shortcuts as a list or a + name-keyed object, plus categories). `merge` keeps what is here and + replaces same-named entries; `replace` starts from the file alone. + Entries without a trigger are skipped, not fatal -- one bad row in a + long library is not a reason to refuse the rest. Returns how many were read.""" + if mode not in ("merge", "replace"): + raise ValueError("mode is merge or replace") + raw = data.get("shortcuts") if isinstance(data, dict) else data + if isinstance(raw, dict): + raw = [dict({"name": k}, **v) if isinstance(v, dict) else v for k, v in raw.items()] + if not isinstance(raw, list): + raise ValueError("that file holds no shortcuts") + read = [s for s in (Shortcut.from_dict(r) for r in raw) if s.triggers] + if not read: + raise ValueError("that file holds no shortcut with a trigger") + with _LOCK: + items = [] if mode == "replace" else listing() + for s in read: + i = next((i for i, it in enumerate(items) if it.name.lower() == s.name.lower()), None) + if i is None: + items.append(s) + else: + items[i] = s + _save_all(items) + cats = [] if mode == "replace" else _saved_categories() + for c in (data.get("categories") if isinstance(data, dict) else None) or []: + if isinstance(c, str): + c = {"name": c} + if isinstance(c, dict): + _union(cats, c.get("name")) + for sub in c.get("sub_categories") or []: + _union(cats, c.get("name"), sub) + _save_categories(cats) + return len(read) + + # --- shortcut expansion ------------------------------------------------------ def _trigger_pattern(trigger: str) -> str: diff --git a/core/tests/test_routes_shortcuts.py b/core/tests/test_routes_shortcuts.py index d1effe51..b0152a7c 100644 --- a/core/tests/test_routes_shortcuts.py +++ b/core/tests/test_routes_shortcuts.py @@ -18,6 +18,7 @@ from core import config, routes # noqa: E402 def server(tmp_path, monkeypatch): monkeypatch.setattr(config, "SHORTCUTS_FILE", tmp_path / "shortcuts.json") monkeypatch.setattr(config, "MARKERS_FILE", tmp_path / "markers.json") + monkeypatch.setattr(config, "SHORTCUT_CATEGORIES_FILE", tmp_path / "cats.json") from aiohttp import web as aioweb app = aioweb.Application() @@ -134,3 +135,17 @@ def test_story_rejects_bad_bodies_and_an_empty_marker_list(server): assert _request(server, "POST", "/funpack/api/story/markers", {"markers": []})[0] == 400 assert _request(server, "POST", "/funpack/api/story/split", {"text": 5})[0] == 400 assert _request(server, "POST", "/funpack/api/story/join", {"scenes": [1]})[0] == 400 + + +def test_categories_export_import_over_http(server): + _, r = _request(server, "POST", "/funpack/api/shortcuts/category", {"category": "Camera", "sub_category": "Moves"}) + assert r["categories"] == [{"name": "Camera", "sub_categories": ["Moves"]}] + assert _request(server, "POST", "/funpack/api/shortcuts/category", {"category": ""})[0] == 400 + _request(server, "POST", "/funpack/api/shortcuts", {"name": "Fox", "triggers": ["fox"], "replacements": ["red fox"]}) + status, exported = _request(server, "GET", "/funpack/api/shortcuts/export") + assert status == 200 and [s["name"] for s in exported["shortcuts"]] == ["Fox"] + _request(server, "POST", "/funpack/api/shortcuts/clear") + status, back = _request(server, "POST", "/funpack/api/shortcuts/import", {"data": exported, "mode": "replace"}) + assert status == 200 and back["imported"] == 1 and back["categories"] == exported["categories"] + assert _request(server, "POST", "/funpack/api/shortcuts/import", {"data": {"shortcuts": 5}})[0] == 400 + assert _request(server, "POST", "/funpack/api/shortcuts/import", {"mode": "merge"})[0] == 400 diff --git a/core/tests/test_shortcuts.py b/core/tests/test_shortcuts.py index 2db29ef3..01a0b799 100644 --- a/core/tests/test_shortcuts.py +++ b/core/tests/test_shortcuts.py @@ -12,6 +12,7 @@ from core import config, shortcuts @pytest.fixture def store(tmp_path, monkeypatch): monkeypatch.setattr(config, "SHORTCUTS_FILE", tmp_path / "shortcuts.json") + monkeypatch.setattr(config, "SHORTCUT_CATEGORIES_FILE", tmp_path / "cats.json") return tmp_path @@ -195,3 +196,49 @@ def test_a_leading_dollar_in_the_declared_name_is_ignored(): def test_no_variables_declared_returns_text_unchanged(): assert shortcuts.resolve_variables("plain $x text", []) == "plain $x text" + + +# ── categories, export, import ─────────────────────────────────────────── + +def test_a_made_category_survives_empty_and_a_used_one_needs_no_entry(store): + shortcuts.add_category("Camera", "Moves") + shortcuts.save({"name": "x", "triggers": ["x"], "replacements": ["y"], "category": "Light"}) + assert shortcuts.categories() == [ + {"name": "Camera", "sub_categories": ["Moves"]}, {"name": "Light", "sub_categories": []}] + with pytest.raises(ValueError): + shortcuts.add_category(" ") + shortcuts.clear() + assert shortcuts.categories() == [] + + +def test_export_then_replace_import_is_the_same_library(store): + shortcuts.save({"name": "Fox", "triggers": ["fox", "vixen"], "replacements": ["red fox", ""], + "category": "Animals", "sub_category": "Wild"}) + shortcuts.add_category("Empty") + payload = shortcuts.export_payload() + shortcuts.clear() + assert shortcuts.import_payload(payload, "replace") == 1 + assert [s.to_dict() for s in shortcuts.listing()] == payload["shortcuts"] + assert shortcuts.categories() == payload["categories"] + + +def test_import_reads_a_v4_file_and_merge_keeps_what_is_here(store): + shortcuts.save({"name": "Keep", "triggers": ["keep"], "replacements": ["k"]}) + v4 = {"version": 1, "shortcuts": {"fox": {"name": "Fox", "activation_words": ["fox"], + "replacement": ["red fox"], "refinement_key": "k1", "category": "Animals"}, + "bad": {"name": "NoTrigger"}}, "categories": [{"name": "Animals", "sub_categories": ["Wild"]}]} + assert shortcuts.import_payload(v4, "merge") == 1 + assert sorted(s.name for s in shortcuts.listing()) == ["Fox", "Keep"] + assert shortcuts.categories() == [{"name": "Animals", "sub_categories": ["Wild"]}] + shortcuts.import_payload(v4, "merge") + assert len(shortcuts.listing()) == 2 # same name replaces, never duplicates + + +def test_import_refuses_what_holds_nothing_and_changes_nothing(store): + shortcuts.save({"name": "Keep", "triggers": ["keep"], "replacements": ["k"]}) + for bad in ({"shortcuts": {"a": {"name": "no trigger"}}}, "nope", {"shortcuts": 5}): + with pytest.raises(ValueError): + shortcuts.import_payload(bad, "replace") + with pytest.raises(ValueError): + shortcuts.import_payload([{"triggers": ["a"]}], "bogus") + assert [s.name for s in shortcuts.listing()] == ["Keep"]