From eeadeb4a94403afaf01dec5405c67b3d491b9023 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 04:09:18 +0200 Subject: [PATCH] fix: category tree cleanup, mixed-content redirect, and category page clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Category duplicates The tag→category conversion appended a parent name to keep labels globally unique, so the tree was full of rows like "Cellulitis (Emergency Medicine)" filed under Emergency Medicine. The breadcrumb already shows the parent, so the suffix was noise in every picker. scripts/sanitize_categories.py strips it, then merges siblings that collapse to the same name — including the "Absence Seizure" / "Absence Seizures" pair. Applied to production after a table backup: 491 renamed, 3 merged, 1078 → 1075, and a second run is a no-op. Merging repoints questions, additional-category links, articles, decks, child categories and grants before deleting the losing row. Newly created categories not appearing `/api/question-categories` (no trailing slash) 307-redirects to **http://**, which the browser blocks as mixed content on an https page. Three callers used the bare path, so the request failed silently into a catch and the list stayed stale. Trailing slash added. Category page Rows now show questions filed directly here, the roll-up including everything beneath, subcategory count, and an Empty badge for a leaf holding nothing — so the shape of the tree is visible rather than inferred. On small screens it says plainly that editing is easier on a desktop. Tests: 136 frontend green, build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01365DYKu14YtsBKv2ycW6eG --- backend/scripts/sanitize_categories.py | 134 +++++++++++++++++++++ docs/TODO.md | 25 ++-- frontend/src/pages/ArticlesPage.jsx | 2 +- frontend/src/pages/ArticlesPage.test.jsx | 2 +- frontend/src/pages/CategoriesPage.css | 11 ++ frontend/src/pages/CategoriesPage.jsx | 27 ++++- frontend/src/pages/CategoriesPage.test.jsx | 15 ++- frontend/src/pages/QuestionEditPage.jsx | 2 +- frontend/src/pages/QuestionManagerPage.jsx | 2 +- 9 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 backend/scripts/sanitize_categories.py 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 )}