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 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-02 12:10:54 +03:00
co-authored by Claude Sonnet 5.5
parent eed17fce63
commit d5d2989f29
9 changed files with 233 additions and 123 deletions
+1
View File
@@ -36,6 +36,7 @@ app/playwright-report/
/media/
/shortcuts.json
/markers.json
/shortcut_categories.json
# Taste keys: what your ratings taught, per rental
taste/
+3 -3
View File
@@ -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"),
+3 -101
View File
@@ -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);
+3 -3
View File
@@ -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 {
+3
View File
@@ -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.
+51 -14
View File
@@ -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
+107 -2
View File
@@ -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:
+15
View File
@@ -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
+47
View File
@@ -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"]