Pool admin: delete any word (tombstones + restore) + bulk import

Daily Word pool curation, full add/delete/import — no redeploys to fix tone:
- Remove ANY pool word, curated or admin-added, via a word_pool_removed
  tombstone table. Runtime pool = (static ∪ added) − removed, so even a
  baked-in word can be pulled on negative feedback. Reversible: a "Removed"
  list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove
  button when in-pool, Restore when removed.
- Import a vetted list (paste or .txt/.csv upload, read client-side): validates
  each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports
  rejects with reasons. Re-adding/importing a removed word lifts its tombstone.
- Word Search theme delete already existed (Edit/Remove per theme) — verified.

Pool stays the clean 251/224; today's noisy LLM enrichment is discarded.
Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest +
11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 17:17:16 -04:00
parent fb781f48b8
commit 2461584052
6 changed files with 352 additions and 19 deletions
+109 -2
View File
@@ -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 wont 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}
<span class="wp-tag bad">Must be 5 or 6 letters</span>
{:else if wpResult.in_pool}
<span class="wp-tag ok">Already in the pool</span>
<span class="wp-tag ok">In the pool</span>
<button class="act del" onclick={() => removeWord(wpResult.word)}>Remove</button>
{:else if wpResult.removed}
<span class="wp-tag bad">Removed</span>
<button class="wp-add" onclick={() => restoreWord(wpResult.word)}>Restore</button>
{:else if wpResult.in_dictionary}
<span class="wp-tag ok">Valid {wpResult.variant}-letter word</span>
<button class="wp-add" onclick={addWord}>Add to pool</button>
@@ -807,11 +855,52 @@
{/each}
</ul>
{:else}<p class="muted small">No words added yet — the curated pool is in use.</p>{/if}
{#if wpPool[v].removed && wpPool[v].removed.length}
<p class="muted small" style="margin-top:10px">Removed ({wpPool[v].removed.length}) — tap to restore:</p>
<ul class="wp-added removed">
{#each wpPool[v].removed as w (w)}
<li>{w}<button class="x" onclick={() => restoreWord(w)} aria-label={'Restore ' + w}></button></li>
{/each}
</ul>
{/if}
</div>
{/each}
</div>
{/if}
<h3 style="margin-top:24px">Import a vetted list</h3>
<p class="muted small">Paste words (any separators) or upload a .txt/.csv. Each is checked —
real word · 56 letters · in the guess dictionary — and duplicates are ignored.</p>
<div class="wp-import">
<textarea bind:value={wpImportText} rows="4"
placeholder="clear, bloom, kindle, ripple… (or upload a file below)"></textarea>
<div class="wp-import-row">
<label class="act file">Choose file…
<input type="file" accept=".txt,.csv,text/plain,text/csv" onchange={onImportFile} hidden />
</label>
<button class="wp-add" onclick={importWords} disabled={!wpImportText.trim() || wpImporting}>
{wpImporting ? 'Importing…' : 'Import'}
</button>
</div>
{#if wpImportResult}
{#if wpImportResult.error}
<p class="wp-msg">{wpImportResult.error}</p>
{:else}
<p class="wp-msg">Added {wpImportResult.counts.added} · ignored {wpImportResult.counts.duplicates} duplicate(s) · rejected {wpImportResult.counts.rejected}.</p>
{#if wpImportResult.rejected.length}
<details class="wp-rejects">
<summary>{wpImportResult.rejected.length} rejected — see why</summary>
<ul class="muted small">
{#each wpImportResult.rejected as r (r.word)}
<li>{r.word}{r.reason}</li>
{/each}
</ul>
</details>
{/if}
{/if}
{/if}
</div>
<h2 style="margin-top:38px">Word Search themes</h2>
<p class="muted">Add a theme and its words — the system splits them across the three sizes and places
them. You need {WS_NEEDED}+ valid words (48 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; }