pdf-quiz-generator/backend/app/services/article_service.py
Daniel 14a75303a7 fix: every cross-reference in the corpus was a dead link
`[[403|urethritis]]` became `<ArticleLink slug="403">`, which built the
href `/articles/s/403` — the slug route — and asked the preview endpoint
to resolve "403" as a slug. Neither exists, so the hover card never
appeared and the link 404'd. Every one of the 2,150 links is written by
id, because an id survives a rename and a slug does not, so this was the
whole library and not one article.

resolve_slug now takes an id as well as a current or historical slug,
and the link addresses the article directly when it is written by id.

Also: a view of one section no longer prints a heading repeating the tab
above it. "Short" over a heading reading "In short" says the same word
twice, and hid the only content behind a chevron. No collapse control
over a single section, and no contents list of one entry.

And a horizontal-overflow guard that only half worked: `overflow-x:
hidden` was on body but not html, so the browser could still propagate
the overflow to the viewport and scroll the whole page sideways — which
is how the navbar came to be clipped mid-word. The exam name now
truncates with an ellipsis instead of clipping.

Backend 242/242, frontend 316/316.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 18:25:31 +02:00

173 lines
7 KiB
Python

"""Article editing rules: variants, revisions, slugs and cross-reference markers.
Three things live here because they are decisions rather than plumbing, and each
one has a failure mode worth naming.
**Variants.** One article is read three ways — short, long, and the clinical view
that carries management and doses. They are one article rather
than three because they describe one condition: split into three rows they drift
apart, and a question linked to "Bronchiolitis" would have to pick which of the
three it meant. Each section carries the variant it belongs to.
**Slugs.** A cross-reference written today has to survive a rename tomorrow, so
every slug an article has ever had is kept and old ones redirect. Without this a
rename silently breaks every article pointing at the old name, and the person who
renamed it never finds out.
**Markers.** `[[7|Febrile seizures]]` resolves by id and displays the text. The
id is what makes it rename-proof; the text is what makes the prose readable while
you are writing it.
"""
import logging
import re
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.article import Article, ArticleRevision, ArticleSlug
logger = logging.getLogger(__name__)
# Short first: it is the fastest way to tell whether this is the article you
# wanted. Long is one click away and is what an article written before variants
# existed becomes, since that is what it was.
VARIANTS = ("short", "long", "clinical")
DEFAULT_VARIANT = "long"
# [[7|Febrile seizures]] — id first, because the id is the part that must not
# change. [[febrile-seizures]] is the older slug form and still resolves.
MARKER_RE = re.compile(r"\[\[(?:(\d+)\|([^\]]+)|([a-z0-9][a-z0-9-]*))\]\]")
STATUSES = ("draft", "in_review", "published")
def normalise_sections(sections: list[dict]) -> list[dict]:
"""Stamp every section with a variant, defaulting to the full article.
Articles written before variants existed have no such field; treating them
as the long version is what keeps them rendering unchanged.
"""
out = []
for section in sections:
row = dict(section)
if row.get("variant") not in VARIANTS:
row["variant"] = DEFAULT_VARIANT
out.append(row)
return out
def sections_for(article: Article, variant: str) -> list[dict]:
"""The sections of one view, in order."""
variant = variant if variant in VARIANTS else DEFAULT_VARIANT
return [s for s in normalise_sections(article.sections or [])
if s.get("variant") == variant]
def available_variants(article: Article) -> list[str]:
"""Which views this article actually has, so the toggle offers no empty one."""
present = {s.get("variant") for s in normalise_sections(article.sections or [])}
return [v for v in VARIANTS if v in present]
def record_slug(db: Session, article: Article) -> None:
"""Remember the article's current slug, so it keeps resolving after a rename."""
existing = db.query(ArticleSlug).filter(ArticleSlug.slug == article.slug).first()
if existing is None:
db.add(ArticleSlug(slug=article.slug, article_id=article.id))
elif existing.article_id != article.id:
# Another article now owns this name. The newest claim wins, because the
# alternative is a redirect that sends readers to the wrong condition.
existing.article_id = article.id
def resolve_slug(db: Session, slug: str) -> Article | None:
"""Find an article by numeric id, by its current slug, or by an old one.
Cross-references in prose are written `[[403|urethritis]]` — by id, because
an id survives a rename and a slug does not. So whatever resolves a
reference has to accept both: looking only at slugs made every one of those
links dead, with no preview and a 404 behind it.
"""
reference = (slug or "").strip().lower()
if not reference:
return None
if reference.isdigit():
return db.get(Article, int(reference))
article = db.query(Article).filter(Article.slug == reference).first()
if article:
return article
historical = db.query(ArticleSlug).filter(ArticleSlug.slug == reference).first()
return db.get(Article, historical.article_id) if historical else None
def snapshot(db: Session, article: Article, user_id: int | None, note: str | None = None) -> None:
"""Keep what the article was, before it becomes something else."""
db.add(ArticleRevision(
article_id=article.id, title=article.title, summary=article.summary,
content=article.content, sections=article.sections or [],
references_json=article.references_json, status=article.status,
note=(note or "")[:200] or None, created_by=user_id,
))
def marker_targets(text: str) -> tuple[set[int], set[str]]:
"""The article ids and legacy slugs a piece of prose points at."""
ids, slugs = set(), set()
for match in MARKER_RE.finditer(text or ""):
if match.group(1):
ids.add(int(match.group(1)))
elif match.group(3):
slugs.add(match.group(3))
return ids, slugs
def broken_markers(db: Session, article: Article) -> list[str]:
"""Markers in this article that point at nothing.
Checked when an article is saved, because that is the last moment the person
who wrote the link is still looking at it. A dead cross-reference found a
week later belongs to nobody.
"""
body = " ".join(filter(None, [
article.content or "",
*[(s.get("content") or "") for s in (article.sections or [])],
]))
ids, slugs = marker_targets(body)
broken = []
if ids:
known = {row[0] for row in db.query(Article.id).filter(Article.id.in_(ids)).all()}
broken.extend(f"[[{missing}|…]]" for missing in sorted(ids - known))
for slug in sorted(slugs):
if resolve_slug(db, slug) is None:
broken.append(f"[[{slug}]]")
return broken
def upgrade_markers(db: Session, text: str) -> str:
"""Rewrite legacy slug markers to the id form, keeping the title as the label.
Done on save rather than in a migration: an article nobody has touched is not
broken, and rewriting prose that no one asked to change is how you lose an
author's trust in the editor.
"""
def replace(match: re.Match) -> str:
if match.group(1):
return match.group(0)
slug = match.group(3)
article = resolve_slug(db, slug)
return f"[[{article.id}|{article.title}]]" if article else match.group(0)
return MARKER_RE.sub(replace, text or "")
def set_status(db: Session, article: Article, status: str, user_id: int | None) -> None:
"""Move an article through draft → in_review → published."""
if status not in STATUSES:
raise ValueError(f"Unknown status: {status}")
now = datetime.utcnow()
if status == "in_review" and article.status != "in_review":
article.submitted_at = now
if status == "published" and article.status != "published":
article.reviewed_at = now
article.reviewed_by = user_id
article.status = status