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:
co-authored by
Claude Sonnet 5.5
parent
eed17fce63
commit
d5d2989f29
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user