Add personal significance notes and Esc-to-close cards.
Family can record why a name matters (grandfather, etc.), and Escape collapses an open name drawer.
This commit is contained in:
@@ -26,7 +26,7 @@ Visitors open the link and enter a display name.
|
||||
|
||||
- Renamable columns (First is fixed; `+` adds up to 4; ✕ removes extras)
|
||||
- Votes / ranking per board
|
||||
- Locale notes (en / ru / he) + voice recordings (re-record overwrites)
|
||||
- Locale notes (en / ru / he) + personal significance (why the name matters) + voice recordings (re-record overwrites)
|
||||
- Paper UI at `/`
|
||||
- Optional read-only boards via `STORK_READONLY_BOARD_IDS`
|
||||
|
||||
@@ -43,7 +43,7 @@ Visitors open the link and enter a display name.
|
||||
| GET | `/api/columns` | Columns for session board |
|
||||
| POST/PATCH/DELETE | `/api/columns…` | Add / rename / remove (not first; blocked if readonly) |
|
||||
| GET/POST | `/api/names` | List / add |
|
||||
| PATCH | `/api/names/{id}` | Locale notes |
|
||||
| PATCH | `/api/names/{id}` | Locale notes and/or personal significance |
|
||||
| POST | `/api/names/{id}/vote` | `{value: 1\|-1\|0}` |
|
||||
| POST/GET/DELETE | `/api/names/{id}/recording/{lang}` | Voice (magic sniff; max ~5 MiB) |
|
||||
| DELETE | `/api/names/{id}` | Admin header `X-Stork-Admin` |
|
||||
|
||||
+29
-4
@@ -449,6 +449,8 @@
|
||||
</div>
|
||||
<label for="spelling">Spelling</label>
|
||||
<input id="spelling" required aria-required="true" autocomplete="off" />
|
||||
<label for="significance" style="margin-top:0.55rem">Why this name? (optional)</label>
|
||||
<textarea id="significance" maxlength="500" placeholder="e.g. grandfather’s name, named after…"></textarea>
|
||||
<p class="hint">Written form for this column. Prefer a real recording over browser speech.</p>
|
||||
<details class="notes" open>
|
||||
<summary>Pronunciation / origin / meaning (optional)</summary>
|
||||
@@ -499,7 +501,7 @@
|
||||
</ul>
|
||||
<h3>Names & votes</h3>
|
||||
<ul>
|
||||
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear).</li>
|
||||
<li>Tap a name to expand. ▲ / ▼ rank it (tap again to clear). <kbd>Esc</kbd> closes an open name.</li>
|
||||
<li>Suggest names below; pick which column with the toggles.</li>
|
||||
</ul>
|
||||
<h3>Columns</h3>
|
||||
@@ -510,6 +512,7 @@
|
||||
</ul>
|
||||
<h3>Notes & voice</h3>
|
||||
<ul>
|
||||
<li><strong>Why this name?</strong> — personal note (grandfather, family story, etc.).</li>
|
||||
<li><strong>EN / RU / HE</strong> — spoken form, origin, meaning per language.</li>
|
||||
<li><strong>▶</strong> play (AI until you record) · <strong>●</strong> record · <strong>■</strong> stop</li>
|
||||
<li><strong>↺</strong> back to AI · <strong>✎</strong> edit notes · <strong>✓</strong> save</li>
|
||||
@@ -575,7 +578,17 @@
|
||||
if (isTypingTarget(e.target)) return;
|
||||
e.preventDefault();
|
||||
toggleHelp();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Escape") return;
|
||||
const help = document.getElementById("help-dialog");
|
||||
if (help && help.open) return; // <dialog> closes itself on Esc
|
||||
if (state.openId == null && state.editId == null) return;
|
||||
e.preventDefault();
|
||||
stopPlayback();
|
||||
state.openId = null;
|
||||
state.editId = null;
|
||||
refreshLists();
|
||||
});
|
||||
|
||||
function colLabel(col) {
|
||||
@@ -741,7 +754,8 @@
|
||||
const L = (n.locales && n.locales[lang]) || {};
|
||||
const form = formFor(n, lang);
|
||||
const hasRec = !!(n.recordings && n.recordings[lang]);
|
||||
const hasNotes = !!(L.origin || L.meaning || L.pronunciation);
|
||||
const sig = (n.significance || "").trim();
|
||||
const hasNotes = !!(L.origin || L.meaning || L.pronunciation || sig);
|
||||
const pills = ["en", "ru", "he"].map((l) =>
|
||||
`<button type="button" class="${lang === l ? "active" : ""}" data-lang="${l}">${l}</button>`
|
||||
).join("");
|
||||
@@ -749,6 +763,8 @@
|
||||
const spoken = L.pronunciation || form || "—";
|
||||
const notesView = `
|
||||
<div class="notes-view">
|
||||
<span class="k">Why this name?</span>
|
||||
<div dir="auto">${sig ? escapeHtml(sig) : `<span class="empty-hint">Not set</span>`}</div>
|
||||
<span class="k">Spoken (${lang})</span>
|
||||
<div dir="auto">${escapeHtml(spoken)}</div>
|
||||
<span class="k">Origin</span>
|
||||
@@ -758,6 +774,10 @@
|
||||
</div>`;
|
||||
const notesEdit = `
|
||||
<div class="edit-grid">
|
||||
<div>
|
||||
<label for="sig-${n.id}">Why this name?</label>
|
||||
<textarea id="sig-${n.id}" data-edit="significance" dir="auto" placeholder="e.g. grandfather’s name, named after…" maxlength="500">${escapeHtml(sig)}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="pron-${n.id}">Spoken form (${lang})</label>
|
||||
<input id="pron-${n.id}" data-edit="pronunciation" value="${escapeHtml(L.pronunciation || "")}" dir="auto" placeholder="${escapeHtml(form || n.spelling)}" />
|
||||
@@ -776,7 +796,7 @@
|
||||
<div class="score">${n.score}</div>
|
||||
<div>
|
||||
<button type="button" class="name-btn" data-toggle dir="auto">${escapeHtml(n.spelling)}</button>
|
||||
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}</p>
|
||||
<p class="meta">${escapeHtml(n.created_by || "someone")} · ${n.vote_count}${hasRec ? " · voice" : ""}${sig ? ` · ${escapeHtml(sig.length > 42 ? sig.slice(0, 40) + "…" : sig)}` : ""}</p>
|
||||
<div class="drawer ${open ? "" : "hidden"}">
|
||||
<div class="say-row">
|
||||
<div class="pills">${pills}</div>
|
||||
@@ -853,13 +873,16 @@
|
||||
const card = btn.closest(".card");
|
||||
const id = Number(card.dataset.id);
|
||||
const lang = state.langById[id] || "en";
|
||||
const sigEl = card.querySelector('[data-edit="significance"]');
|
||||
const locales = {};
|
||||
locales[lang] = {
|
||||
pronunciation: card.querySelector('[data-edit="pronunciation"]').value.trim(),
|
||||
origin: card.querySelector('[data-edit="origin"]').value.trim(),
|
||||
meaning: card.querySelector('[data-edit="meaning"]').value.trim(),
|
||||
};
|
||||
await api(`/api/names/${id}`, { method: "PATCH", body: JSON.stringify({ locales }) });
|
||||
const body = { locales };
|
||||
if (sigEl) body.significance = sigEl.value.trim();
|
||||
await api(`/api/names/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
state.openId = id;
|
||||
state.editId = null;
|
||||
await refresh();
|
||||
@@ -956,6 +979,7 @@
|
||||
|
||||
function clearForm() {
|
||||
document.getElementById("spelling").value = "";
|
||||
document.getElementById("significance").value = "";
|
||||
document.querySelectorAll("[data-loc][data-field]").forEach((el) => { el.value = ""; });
|
||||
}
|
||||
|
||||
@@ -1132,6 +1156,7 @@
|
||||
body: JSON.stringify({
|
||||
kind: state.kind,
|
||||
spelling,
|
||||
significance: document.getElementById("significance").value.trim(),
|
||||
locales: localePayload(),
|
||||
}),
|
||||
});
|
||||
|
||||
+17
-15
@@ -21,11 +21,7 @@ INVITE_TOKEN = os.environ.get("STORK_INVITE_TOKEN", "").strip()
|
||||
ADMIN_TOKEN = os.environ.get("STORK_ADMIN_TOKEN", "").strip()
|
||||
COOKIE_SECURE = os.environ.get("STORK_COOKIE_SECURE", "false").lower() in {"1", "true", "yes"}
|
||||
PUBLIC_BASE = os.environ.get("STORK_PUBLIC_URL", "https://stork.levkin.ca").rstrip("/")
|
||||
READONLY_BOARD_IDS = {
|
||||
x.strip()
|
||||
for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",")
|
||||
if x.strip()
|
||||
}
|
||||
READONLY_BOARD_IDS = {x.strip() for x in os.environ.get("STORK_READONLY_BOARD_IDS", "").split(",") if x.strip()}
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
@@ -75,6 +71,7 @@ class LocaleIn(BaseModel):
|
||||
class NameIn(BaseModel):
|
||||
kind: str = Field(pattern=r"^c[1-4]$")
|
||||
spelling: str = Field(min_length=1, max_length=80)
|
||||
significance: str = Field(default="", max_length=500)
|
||||
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -82,8 +79,9 @@ class ColumnPatch(BaseModel):
|
||||
label: str = Field(default="", max_length=40)
|
||||
|
||||
|
||||
class LocalesPatch(BaseModel):
|
||||
locales: dict[str, LocaleIn]
|
||||
class NamePatch(BaseModel):
|
||||
locales: dict[str, LocaleIn] = Field(default_factory=dict)
|
||||
significance: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class VoteIn(BaseModel):
|
||||
@@ -364,11 +362,7 @@ def delete_column(
|
||||
@app.get("/api/names")
|
||||
def list_names(kind: str = "c1", session: dict[str, str] = Depends(_session)) -> dict[str, Any]:
|
||||
try:
|
||||
return {
|
||||
"items": store.list_names(
|
||||
session["board_id"], kind, voter_key=session["voter_key"]
|
||||
)
|
||||
}
|
||||
return {"items": store.list_names(session["board_id"], kind, voter_key=session["voter_key"])}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
@@ -384,6 +378,7 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
|
||||
spelling=body.spelling,
|
||||
created_by=session["display_name"],
|
||||
locales=locales,
|
||||
significance=body.significance,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
@@ -392,12 +387,19 @@ def add_name(body: NameIn, session: dict[str, str] = Depends(_session)) -> dict[
|
||||
@app.patch("/api/names/{name_id}")
|
||||
def patch_name(
|
||||
name_id: int,
|
||||
body: LocalesPatch,
|
||||
body: NamePatch,
|
||||
session: dict[str, str] = Depends(_session),
|
||||
) -> dict[str, Any]:
|
||||
_assert_writable(session["board_id"])
|
||||
locales = {k: v.model_dump() for k, v in body.locales.items()}
|
||||
updated = store.update_locales(name_id, locales, board_id=session["board_id"])
|
||||
locales = {k: v.model_dump() for k, v in body.locales.items()} if body.locales else None
|
||||
if body.significance is None and not locales:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
updated = store.update_name(
|
||||
name_id,
|
||||
locales=locales,
|
||||
significance=body.significance,
|
||||
board_id=session["board_id"],
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(404, "Name not found")
|
||||
return updated
|
||||
|
||||
+59
-56
@@ -44,6 +44,7 @@ CREATE TABLE IF NOT EXISTS names (
|
||||
kind TEXT NOT NULL,
|
||||
spelling TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
significance TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
UNIQUE (board_id, kind, spelling COLLATE NOCASE)
|
||||
);
|
||||
@@ -106,6 +107,7 @@ class Store:
|
||||
self._migrate_legacy_pre_boards()
|
||||
self._migrate_attach_boards(bootstrap_invite.strip(), bootstrap_title.strip() or "Family")
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._migrate_significance_column()
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -163,10 +165,7 @@ class Store:
|
||||
)
|
||||
"""
|
||||
)
|
||||
used = [
|
||||
r["kind"]
|
||||
for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall()
|
||||
]
|
||||
used = [r["kind"] for r in self._conn.execute("SELECT DISTINCT kind FROM names").fetchall()]
|
||||
ordered: list[str] = []
|
||||
for legacy in LEGACY_KIND_ORDER:
|
||||
if legacy in used:
|
||||
@@ -224,9 +223,7 @@ class Store:
|
||||
).fetchone()
|
||||
if not found:
|
||||
# Prefer updating the oldest board's invite only if unique
|
||||
oldest = self._conn.execute(
|
||||
"SELECT id FROM boards ORDER BY created_at ASC LIMIT 1"
|
||||
).fetchone()
|
||||
oldest = self._conn.execute("SELECT id FROM boards ORDER BY created_at ASC LIMIT 1").fetchone()
|
||||
if oldest and self._conn.execute("SELECT COUNT(*) AS n FROM boards").fetchone()["n"] == 1:
|
||||
self._conn.execute(
|
||||
"UPDATE boards SET invite_token = ? WHERE id = ?",
|
||||
@@ -253,9 +250,7 @@ class Store:
|
||||
(board_id, bootstrap_title, invite, time.time()),
|
||||
)
|
||||
# If ignore hit (token clash), fetch existing
|
||||
row = self._conn.execute(
|
||||
"SELECT id FROM boards WHERE invite_token = ?", (invite,)
|
||||
).fetchone()
|
||||
row = self._conn.execute("SELECT id FROM boards WHERE invite_token = ?", (invite,)).fetchone()
|
||||
board_id = row["id"]
|
||||
|
||||
if needs_cols:
|
||||
@@ -295,14 +290,20 @@ class Store:
|
||||
CREATE INDEX IF NOT EXISTS idx_names_board_kind ON names(board_id, kind);
|
||||
"""
|
||||
)
|
||||
if not self._conn.execute(
|
||||
"SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)
|
||||
).fetchone():
|
||||
if not self._conn.execute("SELECT 1 FROM board_columns WHERE board_id = ?", (board_id,)).fetchone():
|
||||
self._conn.execute(
|
||||
"INSERT INTO board_columns (board_id, id, label, position, created_at) VALUES (?, 'c1', 'First', 0, ?)",
|
||||
(board_id, time.time()),
|
||||
)
|
||||
|
||||
def _migrate_significance_column(self) -> None:
|
||||
"""Add personal significance note on names (why this name matters to the family)."""
|
||||
if not self._table_exists("names"):
|
||||
return
|
||||
cols = {r[1] for r in self._conn.execute("PRAGMA table_info(names)").fetchall()}
|
||||
if "significance" not in cols:
|
||||
self._conn.execute("ALTER TABLE names ADD COLUMN significance TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
def _create_board_unlocked(self, title: str, invite_token: str) -> dict[str, Any]:
|
||||
board_id = "b_" + secrets.token_urlsafe(8)
|
||||
now = time.time()
|
||||
@@ -327,9 +328,7 @@ class Store:
|
||||
if not invite:
|
||||
return None
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM boards WHERE invite_token = ?", (invite,)
|
||||
).fetchone()
|
||||
row = self._conn.execute("SELECT * FROM boards WHERE invite_token = ?", (invite,)).fetchone()
|
||||
return self._board_row(row) if row else None
|
||||
|
||||
def find_board(self, board_id: str) -> dict[str, Any] | None:
|
||||
@@ -352,9 +351,7 @@ class Store:
|
||||
title = (title or "").strip()[:80] or "Family"
|
||||
token = (invite_token or "").strip() or secrets.token_urlsafe(18)
|
||||
with self._lock:
|
||||
if self._conn.execute(
|
||||
"SELECT 1 FROM boards WHERE invite_token = ?", (token,)
|
||||
).fetchone():
|
||||
if self._conn.execute("SELECT 1 FROM boards WHERE invite_token = ?", (token,)).fetchone():
|
||||
raise ValueError("invite token already in use")
|
||||
board = self._create_board_unlocked(title, token)
|
||||
self._conn.execute(
|
||||
@@ -369,9 +366,7 @@ class Store:
|
||||
if not title:
|
||||
raise ValueError("title required")
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"UPDATE boards SET title = ? WHERE id = ?", (title, board_id)
|
||||
)
|
||||
cur = self._conn.execute("UPDATE boards SET title = ? WHERE id = ?", (title, board_id))
|
||||
if cur.rowcount == 0:
|
||||
return None
|
||||
self._conn.commit()
|
||||
@@ -388,15 +383,10 @@ class Store:
|
||||
if count <= 1:
|
||||
raise ValueError("keep at least one board")
|
||||
name_ids = [
|
||||
r["id"]
|
||||
for r in self._conn.execute(
|
||||
"SELECT id FROM names WHERE board_id = ?", (board_id,)
|
||||
).fetchall()
|
||||
r["id"] for r in self._conn.execute("SELECT id FROM names WHERE board_id = ?", (board_id,)).fetchall()
|
||||
]
|
||||
for name_id in name_ids:
|
||||
recs = self._conn.execute(
|
||||
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||
).fetchall()
|
||||
recs = self._conn.execute("SELECT path FROM recordings WHERE name_id = ?", (name_id,)).fetchall()
|
||||
for rec in recs:
|
||||
path = self.audio_dir / rec["path"]
|
||||
if path.is_file():
|
||||
@@ -440,9 +430,7 @@ class Store:
|
||||
return deleted
|
||||
|
||||
def _column_ids(self, board_id: str) -> set[str]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT id FROM board_columns WHERE board_id = ?", (board_id,)
|
||||
).fetchall()
|
||||
rows = self._conn.execute("SELECT id FROM board_columns WHERE board_id = ?", (board_id,)).fetchall()
|
||||
return {r["id"] for r in rows}
|
||||
|
||||
def _column_row(self, board_id: str, row: sqlite3.Row) -> dict[str, Any]:
|
||||
@@ -537,9 +525,7 @@ class Store:
|
||||
).fetchall()
|
||||
]
|
||||
for name_id in name_ids:
|
||||
recs = self._conn.execute(
|
||||
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||
).fetchall()
|
||||
recs = self._conn.execute("SELECT path FROM recordings WHERE name_id = ?", (name_id,)).fetchall()
|
||||
for rec in recs:
|
||||
path = self.audio_dir / rec["path"]
|
||||
if path.is_file():
|
||||
@@ -587,9 +573,7 @@ class Store:
|
||||
|
||||
def _recordings_flags(self, name_id: int) -> dict[str, bool]:
|
||||
flags = self._empty_recordings()
|
||||
for row in self._conn.execute(
|
||||
"SELECT lang FROM recordings WHERE name_id = ?", (name_id,)
|
||||
).fetchall():
|
||||
for row in self._conn.execute("SELECT lang FROM recordings WHERE name_id = ?", (name_id,)).fetchall():
|
||||
flags[row["lang"]] = True
|
||||
return flags
|
||||
|
||||
@@ -610,6 +594,7 @@ class Store:
|
||||
"kind": row["kind"],
|
||||
"spelling": row["spelling"],
|
||||
"created_by": row["created_by"],
|
||||
"significance": (row["significance"] if "significance" in row.keys() else "") or "",
|
||||
"created_at": row["created_at"],
|
||||
"locales": locales,
|
||||
"recordings": self._recordings_flags(row["id"]),
|
||||
@@ -689,11 +674,13 @@ class Store:
|
||||
spelling: str,
|
||||
created_by: str,
|
||||
locales: dict[str, Any] | None = None,
|
||||
significance: str = "",
|
||||
) -> dict[str, Any]:
|
||||
spelling = spelling.strip()
|
||||
if not spelling or len(spelling) > 80:
|
||||
raise ValueError("spelling required (1–80 chars)")
|
||||
created_by = (created_by or "").strip()[:80]
|
||||
significance = (significance or "").strip()[:500]
|
||||
locs = self._normalize_locales(locales)
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
@@ -701,8 +688,11 @@ class Store:
|
||||
raise ValueError("unknown column")
|
||||
try:
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO names (board_id, kind, spelling, created_by, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(board_id, kind, spelling, created_by, now),
|
||||
"""
|
||||
INSERT INTO names (board_id, kind, spelling, created_by, significance, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(board_id, kind, spelling, created_by, significance, now),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise ValueError("name already exists for this column") from exc
|
||||
@@ -717,29 +707,36 @@ class Store:
|
||||
assert result is not None
|
||||
return result
|
||||
|
||||
def update_locales(
|
||||
def update_name(
|
||||
self,
|
||||
name_id: int,
|
||||
locales: dict[str, Any],
|
||||
*,
|
||||
locales: dict[str, Any] | None = None,
|
||||
significance: str | None = None,
|
||||
board_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not locales:
|
||||
"""Patch locales and/or personal significance for a name."""
|
||||
if significance is None and not locales:
|
||||
return self.get_name(name_id, board_id=board_id)
|
||||
patch: dict[str, dict[str, str]] = {}
|
||||
for lang, raw in locales.items():
|
||||
if lang not in LANGS or not isinstance(raw, dict):
|
||||
continue
|
||||
patch[lang] = {
|
||||
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
|
||||
"origin": str(raw.get("origin") or "").strip()[:200],
|
||||
"meaning": str(raw.get("meaning") or "").strip()[:500],
|
||||
}
|
||||
if not patch:
|
||||
return self.get_name(name_id, board_id=board_id)
|
||||
if locales:
|
||||
for lang, raw in locales.items():
|
||||
if lang not in LANGS or not isinstance(raw, dict):
|
||||
continue
|
||||
patch[lang] = {
|
||||
"pronunciation": str(raw.get("pronunciation") or "").strip()[:200],
|
||||
"origin": str(raw.get("origin") or "").strip()[:200],
|
||||
"meaning": str(raw.get("meaning") or "").strip()[:500],
|
||||
}
|
||||
with self._lock:
|
||||
row = self._conn.execute("SELECT id, board_id FROM names WHERE id = ?", (name_id,)).fetchone()
|
||||
if not row or (board_id is not None and row["board_id"] != board_id):
|
||||
return None
|
||||
if significance is not None:
|
||||
self._conn.execute(
|
||||
"UPDATE names SET significance = ? WHERE id = ?",
|
||||
((significance or "").strip()[:500], name_id),
|
||||
)
|
||||
for lang, payload in patch.items():
|
||||
self._conn.execute(
|
||||
"""
|
||||
@@ -755,6 +752,14 @@ class Store:
|
||||
self._conn.commit()
|
||||
return self.get_name(name_id, board_id=board_id)
|
||||
|
||||
def update_locales(
|
||||
self,
|
||||
name_id: int,
|
||||
locales: dict[str, Any],
|
||||
board_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
return self.update_name(name_id, locales=locales, board_id=board_id)
|
||||
|
||||
def vote(
|
||||
self,
|
||||
name_id: int,
|
||||
@@ -897,9 +902,7 @@ class Store:
|
||||
|
||||
def delete_name(self, name_id: int) -> bool:
|
||||
with self._lock:
|
||||
recs = self._conn.execute(
|
||||
"SELECT path FROM recordings WHERE name_id = ?", (name_id,)
|
||||
).fetchall()
|
||||
recs = self._conn.execute("SELECT path FROM recordings WHERE name_id = ?", (name_id,)).fetchall()
|
||||
for rec in recs:
|
||||
path = self.audio_dir / rec["path"]
|
||||
if path.is_file():
|
||||
|
||||
+23
-8
@@ -284,14 +284,32 @@ def test_patch_locales(authed: TestClient) -> None:
|
||||
assert patched.json()["locales"]["en"]["pronunciation"] == ""
|
||||
|
||||
|
||||
def test_significance_on_add_and_patch(authed: TestClient) -> None:
|
||||
created = authed.post(
|
||||
"/api/names",
|
||||
json={
|
||||
"kind": "c1",
|
||||
"spelling": "Levi",
|
||||
"significance": "Grandfather’s Hebrew name",
|
||||
},
|
||||
).json()
|
||||
assert created["significance"] == "Grandfather’s Hebrew name"
|
||||
patched = authed.patch(
|
||||
f"/api/names/{created['id']}",
|
||||
json={"significance": "Named after Ilia’s grandfather"},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["significance"] == "Named after Ilia’s grandfather"
|
||||
listed = authed.get("/api/names?kind=c1").json()["items"]
|
||||
levi = next(n for n in listed if n["spelling"] == "Levi")
|
||||
assert levi["significance"] == "Named after Ilia’s grandfather"
|
||||
|
||||
|
||||
def test_admin_delete_name(authed: TestClient) -> None:
|
||||
created = authed.post("/api/names", json={"kind": "c1", "spelling": "Temp"}).json()
|
||||
assert authed.delete(f"/api/names/{created['id']}").status_code == 403
|
||||
assert (
|
||||
authed.delete(
|
||||
f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}
|
||||
).status_code
|
||||
== 200
|
||||
authed.delete(f"/api/names/{created['id']}", headers={"X-Stork-Admin": "test-admin-token"}).status_code == 200
|
||||
)
|
||||
|
||||
|
||||
@@ -326,10 +344,7 @@ def test_readonly_board_blocks_mutations(tmp_path, monkeypatch) -> None:
|
||||
"/api/session",
|
||||
json={"board_id": board_id, "display_name": "Visitor"},
|
||||
)
|
||||
assert (
|
||||
client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code
|
||||
== 403
|
||||
)
|
||||
assert client2.post("/api/names", json={"kind": "c1", "spelling": "X"}).status_code == 403
|
||||
assert client2.get("/api/names?kind=c1").status_code == 200
|
||||
app_mod.store.close()
|
||||
# close first store if still open
|
||||
|
||||
@@ -19,3 +19,14 @@ def test_store_ranks_by_score_then_alpha(tmp_path: Path) -> None:
|
||||
ranked = store.list_names(board_id, "c1")
|
||||
assert [n["spelling"] for n in ranked] == ["Bo", "Zed", "Ann"]
|
||||
store.close()
|
||||
|
||||
|
||||
def test_store_significance(tmp_path: Path) -> None:
|
||||
store = Store(tmp_path / "sig.sqlite3", bootstrap_invite="sig-invite")
|
||||
board_id = store.list_boards()[0]["id"]
|
||||
n = store.add_name(board_id, "c1", "Noa", "x", significance="Aunt Mira’s middle name")
|
||||
assert n["significance"] == "Aunt Mira’s middle name"
|
||||
updated = store.update_name(n["id"], significance="Family favorite", board_id=board_id)
|
||||
assert updated is not None
|
||||
assert updated["significance"] == "Family favorite"
|
||||
store.close()
|
||||
|
||||
Reference in New Issue
Block a user