`[[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
173 lines
7 KiB
Python
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
|