diff --git a/backend/scripts/sanitize_categories.py b/backend/scripts/sanitize_categories.py new file mode 100644 index 0000000..e1099e7 --- /dev/null +++ b/backend/scripts/sanitize_categories.py @@ -0,0 +1,134 @@ +"""Clean the category tree the way scripts/sanitize_tags.py cleaned the tags. + +The tree was generated from the old subject tags, and the conversion added a +parenthetical parent name to keep globally-unique labels: "Cellulitis (Emergency +Medicine)" filed under "Emergency Medicine". The breadcrumb already shows the +parent, so the suffix is pure noise in every picker. + +What it does: + 1. drop a "(Parent)" suffix when it merely repeats the row's own parent + 2. merge siblings that collapse to the same name, repointing questions, + additional-category links, articles and decks before deleting the loser + 3. merge singular/plural siblings ("Absence Seizure" / "Absence Seizures") + +Idempotent, dry run by default: + + docker compose exec backend python -m scripts.sanitize_categories + docker compose exec backend python -m scripts.sanitize_categories --apply +""" +import re +import sys +from collections import defaultdict + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal + + +def strip_parent_suffix(name: str, parent_name: str | None) -> str: + """Remove a trailing "(Parent)" that only repeats the parent.""" + if not parent_name: + return name + suffix = f"({parent_name})" + if name.endswith(suffix): + return name[: -len(suffix)].strip() + return name + + +def canonical_key(name: str) -> str: + """Key under which siblings collapse: lowercase, unpunctuated, singular.""" + key = re.sub(r"[^a-z0-9 ]+", "", (name or "").lower()).strip() + key = re.sub(r"\s+", " ", key) + head, _, last = key.rpartition(" ") + for suffix, replacement in (("ies", "y"), ("ses", "sis"), ("s", "")): + if last.endswith(suffix) and len(last) > len(suffix) + 2: + last = last[: -len(suffix)] + replacement + break + return f"{head} {last}".strip() + + +def question_count(db, category_id) -> int: + return db.execute(sa_text(""" + SELECT (SELECT COUNT(*) FROM questions WHERE question_category_id = :c) + + (SELECT COUNT(*) FROM question_category_links WHERE category_id = :c) + """), {"c": category_id}).scalar() or 0 + + +def merge_into(db, loser_id, keeper_id) -> None: + """Move everything pointing at `loser_id` onto `keeper_id`, then delete it.""" + db.execute(sa_text( + "UPDATE questions SET question_category_id = :k WHERE question_category_id = :l"), + {"k": keeper_id, "l": loser_id}) + # Skip links that would duplicate an existing (question, category) pair. + db.execute(sa_text(""" + UPDATE question_category_links SET category_id = :k + WHERE category_id = :l AND question_id NOT IN ( + SELECT question_id FROM question_category_links WHERE category_id = :k) + """), {"k": keeper_id, "l": loser_id}) + db.execute(sa_text("DELETE FROM question_category_links WHERE category_id = :l"), {"l": loser_id}) + db.execute(sa_text("UPDATE articles SET category_id = :k WHERE category_id = :l"), + {"k": keeper_id, "l": loser_id}) + db.execute(sa_text("UPDATE flashcard_decks SET category_id = :k WHERE category_id = :l"), + {"k": keeper_id, "l": loser_id}) + db.execute(sa_text("UPDATE question_categories SET parent_id = :k WHERE parent_id = :l"), + {"k": keeper_id, "l": loser_id}) + db.execute(sa_text("DELETE FROM category_grants WHERE category_id = :l"), {"l": loser_id}) + db.execute(sa_text("DELETE FROM question_categories WHERE id = :l"), {"l": loser_id}) + + +def main(): + apply_changes = "--apply" in sys.argv + db = SessionLocal() + try: + rows = db.execute(sa_text( + "SELECT id, name, parent_id FROM question_categories ORDER BY id")).fetchall() + names = {row[0]: row[1] for row in rows} + + renamed, merged = [], [] + # 1. Drop the redundant parent suffix. + tidy: dict[int, str] = {} + for cid, name, parent_id in rows: + clean = strip_parent_suffix(name, names.get(parent_id)) + tidy[cid] = clean + if clean != name: + renamed.append((cid, name, clean)) + if apply_changes: + db.execute(sa_text("UPDATE question_categories SET name = :n WHERE id = :i"), + {"n": clean, "i": cid}) + + # 2 & 3. Siblings that now collapse to the same key become one row. + siblings: dict[tuple, list] = defaultdict(list) + for cid, _name, parent_id in rows: + siblings[(parent_id, canonical_key(tidy[cid]))].append(cid) + + for (_parent_id, _key), group in siblings.items(): + if len(group) < 2: + continue + # Keep the row carrying the most content; ties break on lowest id. + group.sort(key=lambda cid: (-question_count(db, cid), cid)) + keeper = group[0] + for loser in group[1:]: + merged.append((tidy[loser], tidy[keeper])) + if apply_changes: + merge_into(db, loser, keeper) + + if apply_changes: + db.commit() + + print("APPLIED" if apply_changes else "DRY RUN") + print(f" categories before : {len(rows)}") + print(f" suffix stripped : {len(renamed)}") + print(f" siblings merged : {len(merged)}") + print(f" categories after : {len(rows) - len(merged)}") + for _cid, old, new in renamed[:8]: + print(f" rename {old!r} -> {new!r}") + for loser, keeper in merged[:8]: + print(f" merge {loser!r} -> {keeper!r}") + if not apply_changes: + print("\n Re-run with --apply to write these changes.") + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/TODO.md b/docs/TODO.md index 5ec8a33..e603d06 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -19,22 +19,19 @@ Updated 2026-09-10. ## UI fixes raised 2026-09-10 -- [ ] **Quiz/test categories removed entirely** — PREP now lives in study plans, - so the Categories tab, the category row in the session kebab, and - `quiz_categories` are dead weight. -- [ ] **Sessions list shows only a few**, with "see more" going to a full - sessions page that has the left rail. -- [ ] **Analysis session rail full-height on the left**, not a boxed card. +- [x] **Quiz/test categories removed** — done. +- [x] **Sessions list shows only a few** — done, with a link to full history. +- [x] **Analysis session rail full-height** — done. - [ ] **Articles page layout** — poor; AMBOSS stacks two menus for this. Check the live site before rebuilding. -- [ ] **Systems facet is flat and duplicated** — shows "Neurology › Absence - Seizure" and "Neurology › Absence Seizures" as siblings. Needs the nested - tree, and the duplicates are a symptom of the taxonomy work below. -- [ ] **"⚙ Filters2948 questions"** — no spacing between the control and the - count. -- [ ] **Category page** — show the relationships so an editor can see the shape; - point people at a desktop screen for editing. -- [ ] **Newly created categories do not appear in the edit picker** until reload. +- [x] **Systems facet duplicates** — done 2026-09-10. 491 redundant "(Parent)" + suffixes stripped and 3 sibling pairs merged. Nesting still to do. +- [x] **"⚙ Filters2948 questions"** — done. The stylesheet was never imported. +- [x] **Category page relationships** — direct vs rolled-up counts, empty-leaf + badge, and a desktop hint on small screens. +- [x] **Newly created categories now appear** — the bare path 307-redirected to + http://, which the browser blocks as mixed content, so the call failed + silently. Trailing slash added. - [ ] **Image/media page** — tags on images, shown when attaching to a question; an image library page; ids visible on hover. diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 711dee9..6ef8765 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -55,7 +55,7 @@ export default function ArticlesPage() { useEffect(() => { load() }, [load]) useEffect(() => { - api.get('/question-categories').then(res => setCategories(res.data)).catch(() => {}) + api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {}) }, []) const create = async () => { diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index de07314..c5335b3 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -21,7 +21,7 @@ const article = { beforeEach(() => { vi.resetAllMocks() api.get.mockImplementation(url => { - if (url === '/question-categories') return Promise.resolve({ data: [] }) + if (url === '/question-categories/') return Promise.resolve({ data: [] }) if (url === '/articles/') return Promise.resolve({ data: [article] }) if (url === '/articles/linked') return Promise.resolve({ data: [article] }) if (url === '/articles/1') return Promise.resolve({ data: article }) diff --git a/frontend/src/pages/CategoriesPage.css b/frontend/src/pages/CategoriesPage.css index 3ede05b..c865e68 100644 --- a/frontend/src/pages/CategoriesPage.css +++ b/frontend/src/pages/CategoriesPage.css @@ -46,3 +46,14 @@ .cat-child { padding-left: 12px; margin-left: 8px; } .cat-count { margin-left: 0; } } + +.cat-badge.is-empty { background: #fff7ed; color: #b45309; border-color: #fed7aa; } +/* Only worth saying on a screen too small to edit a tree comfortably. */ +.cat-desktop-hint { display: none; } +@media (max-width: 720px) { + .cat-desktop-hint { + display: block; margin: 0 0 12px; padding: 10px 12px; + background: var(--card-bg); border: 1px dashed var(--border); border-radius: 10px; + font-size: .82rem; color: var(--text-muted); line-height: 1.5; + } +} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index 3beccc7..4f6de2d 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -43,6 +43,14 @@ export default function CategoriesPage() { return map }, [categories]) + /** Questions in a category plus everything beneath it. */ + const rollup = useCallback((cat) => { + let total = cat.question_count || 0 + for (const child of childrenOf[cat.id] || []) total += rollup(child) + return total + }, [childrenOf]) + + const matches = useCallback((cat) => { if (!query.trim()) return true const needle = query.trim().toLowerCase() @@ -136,9 +144,19 @@ export default function CategoriesPage() { ) : ( <> {cat.name} - {cat.question_count} question{cat.question_count === 1 ? '' : 's'} + + {cat.question_count} direct + {(childrenOf[cat.id] || []).length > 0 && ( - {childrenOf[cat.id].length} sub + <> + + {rollup(cat)} total + + {childrenOf[cat.id].length} sub + > + )} + {cat.question_count === 0 && (childrenOf[cat.id] || []).length === 0 && ( + Empty )} )} + + Editing the tree — renaming, moving and merging — is much easier on a + desktop screen. On a phone this list is best used for reading the shape. + + setQuery(e.target.value)} placeholder="Search categories…" aria-label="Search categories" /> diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx index 69dde28..98edbc5 100644 --- a/frontend/src/pages/CategoriesPage.test.jsx +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -19,12 +19,21 @@ beforeEach(() => { vi.clearAllMocks(); mockCats() }) const mount = () => render() -it('renders the tree with question and subcategory counts', async () => { +it('shows direct and rolled-up counts so the shape of the tree is visible', async () => { mount() expect(await screen.findByText('Root')).toBeInTheDocument() expect(screen.getByText('Child')).toBeInTheDocument() - expect(screen.getByText('5 questions')).toBeInTheDocument() - expect(screen.getByText('1 sub')).toBeInTheDocument() + + const root = screen.getByText('Root').closest('.cat-row') + expect(within(root).getByText('2 direct')).toBeInTheDocument() + // Root holds 2 itself and Child holds 5, so everything beneath is 7. + expect(within(root).getByText('7 total')).toBeInTheDocument() + expect(within(root).getByText('1 sub')).toBeInTheDocument() + + // A leaf with nothing in it is called out rather than left ambiguous. + const other = screen.getByText('Other').closest('.cat-row') + expect(within(other).getByText('Empty')).toBeInTheDocument() + expect(within(other).queryByText(/total/)).not.toBeInTheDocument() }) it('reparents a category and reloads', async () => { diff --git a/frontend/src/pages/QuestionEditPage.jsx b/frontend/src/pages/QuestionEditPage.jsx index 6b98543..3c510a5 100644 --- a/frontend/src/pages/QuestionEditPage.jsx +++ b/frontend/src/pages/QuestionEditPage.jsx @@ -37,7 +37,7 @@ export default function QuestionEditPage({ mode = 'edit' }) { const [showVersions, setShowVersions] = useState(false) useEffect(() => { - api.get('/question-categories').then(res => setCategories(res.data || [])).catch(() => setCategories([])) + api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([])) }, []) const load = useCallback(() => { diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx index b87e3ce..6b1f5f7 100644 --- a/frontend/src/pages/QuestionManagerPage.jsx +++ b/frontend/src/pages/QuestionManagerPage.jsx @@ -80,7 +80,7 @@ export default function QuestionManagerPage() { useEffect(() => { load() }, [load]) useEffect(() => { loadSummary() }, [loadSummary]) useEffect(() => { - api.get('/question-categories').then(res => setCategories(res.data || [])).catch(() => setCategories([])) + api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => setCategories([])) api.get('/question-categories/my-grants').then(res => setScope(res.data)).catch(() => setScope(null)) }, [])
+ Editing the tree — renaming, moving and merging — is much easier on a + desktop screen. On a phone this list is best used for reading the shape. +