diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index f095397..22e0289 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -66,8 +66,52 @@ wpPool = res.pool; wpWord = ''; wpResult = null; wpMsg = `Added “${res.word}” to the ${res.variant}-letter pool.`; } catch (e) { wpMsg = e.message || 'Could not add that word.'; } } + // Refresh the live lookup tag after a pool mutation, so the button flips Remove↔Restore↔Add. + async function refreshLookup() { + const w = wpWord.trim(); + if (!w) { wpResult = null; return; } + try { wpResult = await getJSON('/api/admin/word/lookup?word=' + encodeURIComponent(w)); } + catch { wpResult = null; } + } async function removeWord(w) { - try { wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); } catch { /* ignore */ } + try { + wpPool = await delJSON('/api/admin/word/pool/' + encodeURIComponent(w)); + wpMsg = `Removed “${w}”. It won’t appear as an answer — restore it any time below.`; + await refreshLookup(); + } catch { /* ignore */ } + } + async function restoreWord(w) { + try { + wpPool = await postJSON('/api/admin/word/pool/restore', { word: w }); + wpMsg = `Restored “${w}” to the pool.`; + await refreshLookup(); + } catch { /* ignore */ } + } + + // --- Games: bulk import (paste or .txt/.csv upload) --- + let wpImportText = $state(''); + let wpImportResult = $state(null); // { counts, added[], duplicates[], rejected[] } + let wpImporting = $state(false); + function onImportFile(e) { + const file = e.target.files && e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result || ''); + wpImportText = wpImportText.trim() ? wpImportText.trim() + '\n' + text : text; + }; + reader.readAsText(file); + e.target.value = ''; // allow re-picking the same file + } + async function importWords() { + if (!wpImportText.trim() || wpImporting) return; + wpImporting = true; wpImportResult = null; + try { + const res = await postJSON('/api/admin/word/pool/import', { text: wpImportText }); + wpPool = res.pool; wpImportResult = res; wpImportText = ''; + await refreshLookup(); + } catch (e) { wpImportResult = { error: e.message || 'Import failed.' }; } + finally { wpImporting = false; } } // --- Games: Word Search themes --- @@ -783,7 +827,11 @@ {#if !wpResult.variant} Must be 5 or 6 letters {:else if wpResult.in_pool} - Already in the pool + In the pool + + {:else if wpResult.removed} + Removed + {:else if wpResult.in_dictionary} Valid {wpResult.variant}-letter word @@ -807,11 +855,52 @@ {/each} {:else}
No words added yet — the curated pool is in use.
{/if} + {#if wpPool[v].removed && wpPool[v].removed.length} +Removed ({wpPool[v].removed.length}) — tap to restore:
+Paste words (any separators) or upload a .txt/.csv. Each is checked — + real word · 5–6 letters · in the guess dictionary — and duplicates are ignored.
+{wpImportResult.error}
+ {:else} +Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.
+ {#if wpImportResult.rejected.length} +Add a theme and its words — the system splits them across the three sizes and places them. You need {WS_NEEDED}+ valid words (4–8 letters) to fill all three puzzles. Stuck? Let the AI @@ -1139,6 +1228,24 @@ .wp-added .x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1.15rem; line-height: 1; padding: 0 5px; border-radius: 50%; } .wp-added .x:hover { color: #9a3b3b; } + .wp-added.removed li { opacity: 0.7; border-style: dashed; } + .wp-added.removed .x { font-size: 0.95rem; } + .wp-added.removed .x:hover { color: var(--accent-deep); } + .wp-lookup .act { font: inherit; font-size: 0.82rem; background: var(--surface); border: 1px solid var(--line); + border-radius: 999px; padding: 7px 16px; cursor: pointer; color: #9a3b3b; } + .wp-lookup .act:hover { border-color: #9a3b3b; } + /* Bulk import */ + .wp-import { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 10px 0 6px; } + .wp-import textarea { font: inherit; padding: 10px 14px; border: 1px solid var(--line); border-radius: 10px; + background: var(--surface); color: var(--ink); resize: vertical; line-height: 1.6; + text-transform: uppercase; letter-spacing: 0.03em; } + .wp-import-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + .act.file { font: inherit; font-size: 0.84rem; background: var(--accent-soft); color: var(--accent-deep); + border: none; border-radius: 999px; padding: 8px 16px; cursor: pointer; } + .act.file:hover { filter: brightness(0.97); } + .wp-rejects { margin: 4px 0 0; } + .wp-rejects summary { cursor: pointer; color: var(--accent-deep); font-size: 0.84rem; } + .wp-rejects ul { margin: 8px 0 0; padding-left: 18px; } /* Word Search theme authoring */ .ws-form { max-width: 560px; display: flex; flex-direction: column; gap: 10px; margin: 14px 0 6px; } diff --git a/goodnews/api.py b/goodnews/api.py index 8eb301a..62b6f92 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -344,6 +344,11 @@ class WordPoolBody(BaseModel): word: str +class WordPoolImportBody(BaseModel): + text: str = "" + words: list[str] = [] + + class ClientErrorBody(BaseModel): reason: str = "" path: str = "" @@ -1499,6 +1504,7 @@ def create_app() -> FastAPI: _require_admin(conn, request) res = games.lookup_word(word) res["in_pool"] = bool(res["variant"]) and res["word"] in games.answer_pool(conn, res["variant"]) + res["removed"] = bool(res["variant"]) and res["word"] in games._db_removed(conn, res["variant"]) return res @app.get("/api/admin/word/pool") @@ -1523,6 +1529,23 @@ def create_app() -> FastAPI: games.remove_pool_word(conn, word) return games.pool_summary(conn) + @app.post("/api/admin/word/pool/restore") + def admin_word_pool_restore(body: WordPoolBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + games.restore_pool_word(conn, body.word) + return games.pool_summary(conn) + + @app.post("/api/admin/word/pool/import") + def admin_word_pool_import(body: WordPoolImportBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + words = list(body.words) + if body.text: + words += re.findall(r"[A-Za-z]+", body.text) + res = games.import_pool_words(conn, words) + return {**res, "pool": games.pool_summary(conn)} + # --- Admin: Word Search theme authoring --- @app.get("/api/admin/wordsearch/themes") def admin_ws_themes(request: Request) -> list[dict]: diff --git a/goodnews/db.py b/goodnews/db.py index 03bc196..f1f3477 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -283,6 +283,13 @@ CREATE TABLE IF NOT EXISTS word_pool ( PRIMARY KEY (variant, word) ); +CREATE TABLE IF NOT EXISTS word_pool_removed ( + word TEXT NOT NULL, + variant TEXT NOT NULL, -- '5' | '6' + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (variant, word) +); + CREATE TABLE IF NOT EXISTS daily_puzzles ( id INTEGER PRIMARY KEY AUTOINCREMENT, puzzle_date TEXT NOT NULL, diff --git a/goodnews/games.py b/goodnews/games.py index adddf2e..b566f96 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -36,9 +36,21 @@ def _db_pool(conn: sqlite3.Connection, variant: str) -> list[str]: return [r["word"] for r in rows] +def _db_removed(conn: sqlite3.Connection, variant: str) -> list[str]: + rows = conn.execute( + "SELECT word FROM word_pool_removed WHERE variant=? ORDER BY word", (variant,) + ).fetchall() + return [r["word"] for r in rows] + + def answer_pool(conn: sqlite3.Connection, variant: str) -> list[str]: - """The day's answer pool = curated static list ∪ admin-added (DB) words.""" - return sorted(set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) + """The day's answer pool = (curated static ∪ admin-added) − admin-removed. + + Removals are tombstones, so even a baked-in curated word can be pulled + (e.g. on negative feedback) without editing the JSON or redeploying — and + a removal is reversible by lifting the tombstone (restore).""" + pool = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant)) + return sorted(pool) # --- Admin: Daily Word pool curation ------------------------------------------- @@ -56,37 +68,111 @@ def lookup_word(word: str) -> dict: } +def _validate_word(conn: sqlite3.Connection, w: str) -> tuple[str | None, str | None]: + """Return (variant, error). variant is set only when w is a valid candidate.""" + if not w.isalpha() or str(len(w)) not in WORD_VARIANTS: + return None, "not a 5- or 6-letter word" + variant = str(len(w)) + if w not in _DICT[variant]: + return None, "not in the dictionary — it wouldn't be guessable" + return variant, None + + +def _put_word(conn: sqlite3.Connection, w: str, variant: str) -> None: + """Make w a live answer: lift any tombstone, and record it as an addition + only if it isn't already part of the curated static list.""" + if w not in set(_POOL.get(variant, [])): + conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant)) + conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w)) + + def add_pool_word(conn: sqlite3.Connection, word: str) -> dict: """Add a word to the answer pool. Enforces the invariant (alpha, 5/6 letters, present in the guess dictionary) so the daily answer is always guessable.""" w = (word or "").strip().lower() - variant = str(len(w)) - if not w.isalpha() or variant not in WORD_VARIANTS: - return {"error": "must be a 5- or 6-letter word"} - if w not in _DICT[variant]: - return {"error": "not in the guess dictionary — it wouldn't be guessable"} - if w in set(_POOL.get(variant, [])) or w in set(_db_pool(conn, variant)): + variant, err = _validate_word(conn, w) + if err: + return {"error": err} + live = (set(_POOL.get(variant, [])) | set(_db_pool(conn, variant))) - set(_db_removed(conn, variant)) + if w in live: return {"error": "already in the pool"} - conn.execute("INSERT OR IGNORE INTO word_pool (word, variant) VALUES (?, ?)", (w, variant)) + _put_word(conn, w, variant) conn.commit() return {"word": w, "variant": variant, "added": True} def remove_pool_word(conn: sqlite3.Connection, word: str) -> dict: - """Remove an admin-added word. Curated static words can't be removed here.""" + """Remove ANY pool word — curated or admin-added — by laying down a tombstone. + Reversible via restore_pool_word. The daily picker subtracts tombstones.""" w = (word or "").strip().lower() - cur = conn.execute("DELETE FROM word_pool WHERE variant=? AND word=?", (str(len(w)), w)) + variant = str(len(w)) + if variant not in WORD_VARIANTS: + return {"word": w, "removed": False} + conn.execute("INSERT OR IGNORE INTO word_pool_removed (word, variant) VALUES (?, ?)", (w, variant)) conn.commit() - return {"word": w, "removed": cur.rowcount > 0} + return {"word": w, "variant": variant, "removed": True} + + +def restore_pool_word(conn: sqlite3.Connection, word: str) -> dict: + """Undo a removal: lift the tombstone so the word rejoins the pool.""" + w = (word or "").strip().lower() + variant = str(len(w)) + cur = conn.execute("DELETE FROM word_pool_removed WHERE variant=? AND word=?", (variant, w)) + conn.commit() + return {"word": w, "variant": variant, "restored": cur.rowcount > 0} + + +def import_pool_words(conn: sqlite3.Connection, words: list[str]) -> dict: + """Bulk-add a vetted list. Validates each word (alpha · 5–6 · in dictionary), + ignores duplicates (already-live words), and reports rejects with reasons. + A word currently tombstoned is treated as importable (re-adding lifts it).""" + static = {v: set(_POOL.get(v, [])) for v in WORD_VARIANTS} + added = {v: set(_db_pool(conn, v)) for v in WORD_VARIANTS} + removed = {v: set(_db_removed(conn, v)) for v in WORD_VARIANTS} + accepted: list[str] = [] + duplicates: list[str] = [] + rejected: list[dict] = [] + seen: set[str] = set() + for raw in words: + w = (raw or "").strip().lower() + if not w or w in seen: + continue + seen.add(w) + variant, err = _validate_word(conn, w) + if err: + rejected.append({"word": w, "reason": err}) + continue + live = (static[variant] | added[variant]) - removed[variant] + if w in live: + duplicates.append(w) + continue + _put_word(conn, w, variant) + added[variant].add(w) + removed[variant].discard(w) + accepted.append(w) + conn.commit() + return { + "added": accepted, + "duplicates": duplicates, + "rejected": rejected, + "counts": {"added": len(accepted), "duplicates": len(duplicates), "rejected": len(rejected)}, + } def pool_summary(conn: sqlite3.Connection) -> dict: - """Pool counts + the removable (admin-added) words, per variant.""" + """Pool counts + the admin-added words and the removed (tombstoned) words, per variant.""" out = {} for v in WORD_VARIANTS: - added = _db_pool(conn, v) - out[v] = {"curated": len(_POOL.get(v, [])), "added": added, - "total": len(set(_POOL.get(v, [])) | set(added))} + static = set(_POOL.get(v, [])) + removed = set(_db_removed(conn, v)) + added = [w for w in _db_pool(conn, v) if w not in removed] + total = len((static | set(added)) - removed) + out[v] = { + "curated": len(static), + "added": sorted(added), + "removed": sorted(removed), + "total": total, + } return out diff --git a/tests/test_admin.py b/tests/test_admin.py index c1b2d32..adc6268 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -371,12 +371,14 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch): def test_word_pool_admin(tmp_path, monkeypatch): + from goodnews import games app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") assert TestClient(app).get("/api/admin/word/pool").status_code == 401 # gated tc = _signin(app, api, "boss@x.com") # lookup: valid dict word vs non-word vs wrong length assert tc.get("/api/admin/word/lookup?word=thrive").json() == { - "word": "thrive", "length": 6, "alpha": True, "variant": "6", "in_dictionary": True, "in_pool": True} + "word": "thrive", "length": 6, "alpha": True, "variant": "6", + "in_dictionary": True, "in_pool": True, "removed": False} assert tc.get("/api/admin/word/lookup?word=zzzzz").json()["in_dictionary"] is False assert tc.get("/api/admin/word/lookup?word=cat").json()["variant"] is None # add a valid word, then it shows in the pool + lookup @@ -390,6 +392,21 @@ def test_word_pool_admin(tmp_path, monkeypatch): # remove the admin-added word tc.delete("/api/admin/word/pool/plumb") assert "plumb" not in tc.get("/api/admin/word/pool").json()["5"]["added"] + # remove a CURATED word (only a tombstone can pull it) → restore brings it back + tc.delete("/api/admin/word/pool/thrive") + look = tc.get("/api/admin/word/lookup?word=thrive").json() + assert look["in_pool"] is False and look["removed"] is True + assert "thrive" in tc.get("/api/admin/word/pool").json()["6"]["removed"] + tc.post("/api/admin/word/pool/restore", json={"word": "thrive"}) + assert tc.get("/api/admin/word/lookup?word=thrive").json()["in_pool"] is True + # bulk import: validates, dedupes, reports rejects + fresh5 = next(w for w in sorted(games._DICT["5"]) if w not in set(games._POOL["5"])) + imp = tc.post("/api/admin/word/pool/import", + json={"text": f"{fresh5.upper()}, {fresh5}, thrive, qwxzv, hi"}).json() + assert fresh5 in imp["added"] and imp["counts"]["added"] == 1 + assert imp["counts"]["duplicates"] >= 1 # thrive already present + assert {r["word"] for r in imp["rejected"]} == {"qwxzv", "hi"} + assert fresh5 in tc.get("/api/admin/word/pool").json()["5"]["added"] def test_client_error_telemetry(tmp_path, monkeypatch): diff --git a/tests/test_pool_admin.py b/tests/test_pool_admin.py new file mode 100644 index 0000000..80d4462 --- /dev/null +++ b/tests/test_pool_admin.py @@ -0,0 +1,93 @@ +"""Admin curation of the Daily Word answer pool: add, remove (tombstone any +word — curated or added), restore, and bulk import with dedupe + validation.""" +import pytest + +from goodnews import games +from goodnews.db import connect, init_db + + +@pytest.fixture +def conn(tmp_path): + c = connect(str(tmp_path / "t.db")) + init_db(c) + return c + + +def _a_curated(variant): + return games._POOL[variant][0] # a known baked-in word + + +def _fresh(variant, n=1): + """n dictionary words of the given length that are NOT already in the pool.""" + pool = set(games._POOL[variant]) + out = [w for w in sorted(games._DICT[variant]) if w not in pool and w.isalpha()] + return out[:n] + + +def test_add_and_remove_added_word(conn): + w = _fresh("5")[0] + res = games.add_pool_word(conn, w) + assert res.get("added") and res["variant"] == "5" + assert w in games.answer_pool(conn, "5") + # Removing an added word tombstones it → drops out of the live pool. + games.remove_pool_word(conn, w) + assert w not in games.answer_pool(conn, "5") + # ...and is reversible. + games.restore_pool_word(conn, w) + assert w in games.answer_pool(conn, "5") + + +def test_remove_curated_word_via_tombstone(conn): + w = _a_curated("5") + assert w in games.answer_pool(conn, "5") + games.remove_pool_word(conn, w) # curated word — only a tombstone can pull it + assert w not in games.answer_pool(conn, "5") + summary = games.pool_summary(conn) + assert w in summary["5"]["removed"] + games.restore_pool_word(conn, w) + assert w in games.answer_pool(conn, "5") + + +def test_add_rejects_non_dictionary_and_bad_length(conn): + assert "error" in games.add_pool_word(conn, "zzzzz") # not in dict + assert "error" in games.add_pool_word(conn, "hi") # wrong length + assert "error" in games.add_pool_word(conn, _a_curated("5")) # already in pool + + +def test_add_relifts_tombstone(conn): + w = _a_curated("6") + games.remove_pool_word(conn, w) + assert w not in games.answer_pool(conn, "6") + # Re-adding a removed word lifts the tombstone rather than erroring. + res = games.add_pool_word(conn, w) + assert res.get("added") + assert w in games.answer_pool(conn, "6") + + +def test_import_dedupes_and_validates(conn): + new5 = _fresh("5", 2) # two fresh 5-letter words + added_word = new5[0] + games.add_pool_word(conn, added_word) + existing_curated = _a_curated("5") + res = games.import_pool_words(conn, [ + new5[1], # valid new + new5[1].upper(), # same word, different case → counted once + added_word, # dup (already added) + existing_curated, # dup (curated) + "zzzzz", # rejected: not in dictionary + "hi", # rejected: wrong length + " ", # blank → ignored + ]) + assert set(res["added"]) == {new5[1]} + assert res["counts"]["added"] == 1 + assert res["counts"]["duplicates"] >= 2 + assert {r["word"] for r in res["rejected"]} == {"zzzzz", "hi"} + assert new5[1] in games.answer_pool(conn, "5") + + +def test_import_relifts_removed_word(conn): + w = _a_curated("5") + games.remove_pool_word(conn, w) + res = games.import_pool_words(conn, [w]) + assert w in res["added"] + assert w in games.answer_pool(conn, "5")