feat: cross-link the articles, and strip what the analysis page replaced

The linking was the gap
The marker system was built weeks ago — resolves by id, survives a rename, shows
a preview on hover — and not one of 333 articles used it. Every article was
written in isolation, so a piece on croup named stridor and epiglottitis and
offered no way to reach either. `scripts/link_articles.py` reads what is written
and links it: 3,718 cross-references across 307 articles, by id, so a later
rename cannot break them.

Conservative on purpose, because a wrong link is worse than a missing one: only
the first mention in a section, whole words, longest title first so "Otitis media
with effusion" beats "Otitis media", never inside an existing link, marker,
heading, code span or table, and never an article to itself.

That exposed a second thing: the reading view had its own Markdown pipeline with
its own cross-reference regex, and it only understood the old slug form. It would
have printed every one of those 3,718 links as literal brackets. Article prose
now goes through the same renderer as the rest of the site.

Short and Clinical looked empty
Both are usually a single section, and everything starts collapsed, so the tab
showed one heading over blank space. A view of one section is not a contents
page; it opens.

Removed
Quiz reminders — emailed nudges to retake anything under 75%, with a scheduler
that existed solely to send them: the model, the service, the scheduler, the
email, the table. Article comments. The dashboard's in-progress list and its
stat cards, both of which the analysis page now answers better.

One mistake worth recording: the first pass at removing the reminder cleanup used
a regex that took 109 lines with it, including an unrelated endpoint. The test
suite caught it (`/attempts/quiz/{id}/in-progress` returning 404 instead of 403),
and the file was restored and edited by exact match instead.

208 backend, 249 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 03:44:44 +02:00
parent 6abe46d1ea
commit 1f77d421c7
27 changed files with 364 additions and 371 deletions

View file

@ -21,7 +21,7 @@ if _db_url:
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule, UserNote # noqa from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, UserNote # noqa
from app.database import Base from app.database import Base
target_metadata = Base.metadata target_metadata = Base.metadata

View file

@ -0,0 +1,35 @@
"""Remove quiz reminders.
Revision ID: a1b2c3d4e5f6
Revises: f0a1b2c3d4e5
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "a1b2c3d4e5f6"
down_revision = "f0a1b2c3d4e5"
branch_labels = None
depends_on = None
def upgrade():
# Emailed nudges to retake a quiz below 75%. The platform is about sessions
# and topics now, and a scheduler existed solely to send these.
if "reminder_schedules" in inspect(op.get_bind()).get_table_names():
op.drop_table("reminder_schedules")
def downgrade():
op.create_table(
"reminder_schedules",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("quiz_id", sa.Integer, sa.ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False),
sa.Column("next_reminder_at", sa.DateTime, nullable=False),
sa.Column("interval_days", sa.Integer, server_default="1"),
sa.Column("performance_score", sa.Float, server_default="0"),
sa.Column("is_active", sa.Boolean, server_default=sa.true()),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()),
)

View file

@ -13,7 +13,6 @@ from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin(): def seed_admin():
@ -574,15 +573,10 @@ async def lifespan(app: FastAPI):
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True) os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
seed_admin() seed_admin()
seed_default_models() seed_default_models()
# Scheduler and backfill must only run in one worker to avoid duplicate jobs / race conditions # The backfill must run in one worker only, or several race each other.
is_primary = _acquire_singleton_lock() if _acquire_singleton_lock():
if is_primary:
backfill_embeddings() backfill_embeddings()
start_scheduler()
yield yield
# Shutdown
if is_primary:
stop_scheduler()
app = FastAPI( app = FastAPI(

View file

@ -4,7 +4,6 @@ from app.models.section import Section
from app.models.quiz import Quiz from app.models.quiz import Quiz
from app.models.question import Question from app.models.question import Question
from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite from app.models.favorite import Favorite
from app.models.user_note import UserNote from app.models.user_note import UserNote
@ -23,7 +22,6 @@ __all__ = [
"Question", "Question",
"QuizAttempt", "QuizAttempt",
"AttemptAnswer", "AttemptAnswer",
"ReminderSchedule",
"AIModelConfig", "AIModelConfig",
"Favorite", "Favorite",
"UserNote", "UserNote",

View file

@ -43,4 +43,3 @@ class Quiz(Base):
viewonly=True, # mutations handled explicitly via QuizQuestionLink viewonly=True, # mutations handled explicitly via QuizQuestionLink
) )
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan") attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")

View file

@ -1,23 +0,0 @@
from datetime import datetime
from sqlalchemy import Column, Integer, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class ReminderSchedule(Base):
__tablename__ = "reminder_schedules"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False)
next_reminder_at = Column(DateTime, nullable=False)
interval_days = Column(Integer, default=1)
performance_score = Column(Float, default=0.0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reminders")
quiz = relationship("Quiz", back_populates="reminders")

View file

@ -25,7 +25,6 @@ class User(Base):
documents = relationship("PDFDocument", back_populates="user") documents = relationship("PDFDocument", back_populates="user")
quizzes = relationship("Quiz", back_populates="user") quizzes = relationship("Quiz", back_populates="user")
attempts = relationship("QuizAttempt", back_populates="user") attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False) note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)

View file

@ -176,13 +176,6 @@ def submit_attempt(
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
# Update reminder schedule (skip course quizzes)
if not is_course_quiz:
try:
from app.services.reminder_service import update_reminder_schedule
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
except Exception:
logger.warning("Failed to update reminder schedule for quiz %d", attempt.quiz_id, exc_info=True)
return AttemptDetail( return AttemptDetail(
id=attempt.id, id=attempt.id,
@ -401,7 +394,7 @@ def delete_attempt(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Delete own attempt + answers + reminders for that quiz. Cannot delete course quiz attempts.""" """Delete own attempt and its answers. Cannot delete course quiz attempts."""
attempt = db.query(QuizAttempt).filter( attempt = db.query(QuizAttempt).filter(
QuizAttempt.id == attempt_id, QuizAttempt.id == attempt_id,
QuizAttempt.user_id == current_user.id, QuizAttempt.user_id == current_user.id,
@ -416,11 +409,6 @@ def delete_attempt(
quiz_id = attempt.quiz_id quiz_id = attempt.quiz_id
# Delete all attempts for this quiz by this user (wipe history) # Delete all attempts for this quiz by this user (wipe history)
from app.models.reminder import ReminderSchedule
db.query(ReminderSchedule).filter(
ReminderSchedule.user_id == current_user.id,
ReminderSchedule.quiz_id == quiz_id,
).delete()
db.delete(attempt) db.delete(attempt)
db.commit() db.commit()

View file

@ -16,6 +16,7 @@ from app.database import get_db
from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant, MediaTagLink from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant, MediaTagLink
from app.models.user import User from app.models.user import User
from app.services import embedding_service, storage_service from app.services import embedding_service, storage_service
from app.models.question_media import QuestionMedia
from app.services.search_service import hybrid_ids from app.services.search_service import hybrid_ids
from app.utils.auth import get_current_user, require_moderator from app.utils.auth import get_current_user, require_moderator
@ -23,7 +24,28 @@ router = APIRouter()
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_IMAGE_BYTES = 12 * 1024 * 1024 MAX_IMAGE_BYTES = 12 * 1024 * 1024
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"} # A murmur is thirty seconds of audio and a bedside clip is a few megabytes, so
# they get their own ceiling rather than being squeezed under the image one.
MAX_MEDIA_BYTES = 60 * 1024 * 1024
IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"}
# Heart sounds are the reason this exists: a murmur cannot be shown as a picture.
AUDIO_TYPES = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
"audio/ogg", "audio/webm", "audio/mp4", "audio/aac"}
VIDEO_TYPES = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"}
ALLOWED_TYPES = IMAGE_TYPES | AUDIO_TYPES | VIDEO_TYPES
def kind_for(content_type: str) -> str:
"""Which of the three a file is. The player differs for each."""
if content_type in AUDIO_TYPES:
return "audio"
if content_type in VIDEO_TYPES:
return "video"
return "image"
def limit_for(kind: str) -> int:
return MAX_IMAGE_BYTES if kind == "image" else MAX_MEDIA_BYTES
def readable_libraries(db: Session, user: User) -> set[int] | None: def readable_libraries(db: Session, user: User) -> set[int] | None:
@ -159,7 +181,14 @@ def list_media(
total = query.count() total = query.count()
assets = query.order_by(MediaAsset.id.desc()).offset(offset).limit(limit).all() assets = query.order_by(MediaAsset.id.desc()).offset(offset).limit(limit).all()
tags = _tags_for(db, [a.id for a in assets]) tags = _tags_for(db, [a.id for a in assets])
return {"total": total, "images": [_asset_json(a, tags.get(a.id, [])) for a in assets]} # How many questions point at each one. Renaming and moving are safe because
# the link is the id; deleting is the one act that cannot be undone, so the
# count travels with the row and is shown before anyone presses delete.
used = dict(db.query(QuestionMedia.media_id, func.count(QuestionMedia.id)).filter(
QuestionMedia.media_id.in_([a.id for a in assets] or [0])
).group_by(QuestionMedia.media_id).all()) if assets else {}
return {"total": total, "images": [
{**_asset_json(a, tags.get(a.id, [])), "used_by": used.get(a.id, 0)} for a in assets]}
@router.post("/upload", status_code=201) @router.post("/upload", status_code=201)
@ -180,10 +209,13 @@ def upload_media(
raise HTTPException(400, "Choose one of your libraries for this image") raise HTTPException(400, "Choose one of your libraries for this image")
if file.content_type not in ALLOWED_TYPES: if file.content_type not in ALLOWED_TYPES:
raise HTTPException(400, "Upload a PNG, JPEG, GIF, WebP or SVG image") raise HTTPException(400, "Upload an image (PNG, JPEG, GIF, WebP, SVG), "
data = file.file.read(MAX_IMAGE_BYTES + 1) "audio (MP3, WAV, OGG, M4A) or video (MP4, WebM, MOV)")
if len(data) > MAX_IMAGE_BYTES: kind = kind_for(file.content_type)
raise HTTPException(413, f"Keep images under {MAX_IMAGE_BYTES // (1024 * 1024)} MB") ceiling = limit_for(kind)
data = file.file.read(ceiling + 1)
if len(data) > ceiling:
raise HTTPException(413, f"Keep {kind} under {ceiling // (1024 * 1024)} MB")
if not data: if not data:
raise HTTPException(400, "That file is empty") raise HTTPException(400, "That file is empty")
@ -193,7 +225,7 @@ def upload_media(
asset = MediaAsset( asset = MediaAsset(
path=key, title=title or file.filename, caption=caption, alt_text=alt_text, path=key, title=title or file.filename, caption=caption, alt_text=alt_text,
kind="image", library_id=library_id, user_id=current_user.id, kind=kind, library_id=library_id, user_id=current_user.id,
storage="s3" if storage_service.using_s3() else "local", byte_size=len(data), storage="s3" if storage_service.using_s3() else "local", byte_size=len(data),
) )
db.add(asset) db.add(asset)
@ -259,8 +291,19 @@ def update_media(media_id: int, data: MediaUpdate, db: Session = Depends(get_db)
@router.delete("/{media_id}", status_code=204) @router.delete("/{media_id}", status_code=204)
def delete_media(media_id: int, db: Session = Depends(get_db), def delete_media(media_id: int, force: bool = False, db: Session = Depends(get_db),
current_user: User = Depends(require_moderator)): current_user: User = Depends(require_moderator)):
"""Delete an image. Refused while a question still uses it, unless forced.
A question refers to a figure by id, so renaming it or moving it between
libraries never breaks anything. Deleting does, and silently the cascade
would take the link with it and the question would simply stop having a
picture. So the count has to be faced first.
"""
used = db.query(QuestionMedia).filter(QuestionMedia.media_id == media_id).count()
if used and not force:
raise HTTPException(409, f"{used} question{'s' if used > 1 else ''} still use this. "
"Detach it there first, or delete it anyway.")
asset = db.get(MediaAsset, media_id) asset = db.get(MediaAsset, media_id)
if not asset: if not asset:
raise HTTPException(404, "Image not found") raise HTTPException(404, "Image not found")

View file

@ -150,25 +150,3 @@ We received a request to reset your password. Click below to choose a new one.
await _send(to_email, subject, _wrap(subject, md)) await _send(to_email, subject, _wrap(subject, md))
async def send_reminder_email(email: str, user_name: str, quiz_title: str, score: float, next_date: str):
subject = f"Time to review: {quiz_title}"
note = "Great work — keep the streak going! 🌟" if score >= 75 else "A bit more practice will get you there. 📚"
md = f"""# Time to review
Hi **{user_name}**,
Your spaced repetition schedule says it's time to revisit **{quiz_title}**.
## Last score
**{score:.0f}%** {note}
Reviewing at spaced intervals is one of the most effective ways to retain medical knowledge long-term. Each session strengthens the memory trace.
[button:Take Quiz Now]({settings.APP_URL}/quizzes)
---
You're receiving this because you have active quiz reminders. To stop, disable reminders in your account settings.
"""
await _send(email, subject, _wrap(subject, md))

View file

@ -1,61 +0,0 @@
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.reminder import ReminderSchedule
# SM-2 simplified intervals in days
INTERVALS = [1, 3, 7, 14, 30]
def update_reminder_schedule(
db: Session,
user_id: int,
quiz_id: int,
score_percentage: float,
):
"""Update or create a reminder schedule based on quiz performance."""
reminder = db.query(ReminderSchedule).filter(
ReminderSchedule.user_id == user_id,
ReminderSchedule.quiz_id == quiz_id,
).first()
if not reminder:
reminder = ReminderSchedule(
user_id=user_id,
quiz_id=quiz_id,
performance_score=score_percentage,
interval_days=INTERVALS[0],
next_reminder_at=datetime.utcnow() + timedelta(days=INTERVALS[0]),
is_active=True,
)
db.add(reminder)
else:
reminder.performance_score = score_percentage
reminder.is_active = True
# Find current interval index
current_idx = 0
for i, interval in enumerate(INTERVALS):
if reminder.interval_days <= interval:
current_idx = i
break
if score_percentage < 75:
# Below threshold: reset to shortest interval
new_idx = 0
elif score_percentage < 90:
# Decent performance: advance one step
new_idx = min(current_idx + 1, len(INTERVALS) - 1)
else:
# Excellent performance: advance or deactivate
if current_idx >= len(INTERVALS) - 1:
reminder.is_active = False
db.commit()
return
new_idx = min(current_idx + 2, len(INTERVALS) - 1)
reminder.interval_days = INTERVALS[new_idx]
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=INTERVALS[new_idx])
db.commit()

View file

@ -1,98 +0,0 @@
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from app.database import SessionLocal
from app.models.reminder import ReminderSchedule
from app.models.user import User
from app.models.quiz import Quiz
from app.models.attempt import QuizAttempt
logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler()
def check_and_send_reminders():
"""Check for due reminders and send emails."""
db = SessionLocal()
try:
due_reminders = db.query(ReminderSchedule).filter(
ReminderSchedule.is_active == True,
ReminderSchedule.next_reminder_at <= datetime.utcnow(),
).all()
if not due_reminders:
return
logger.info(f"Found {len(due_reminders)} due reminders")
for reminder in due_reminders:
user = db.query(User).filter(User.id == reminder.user_id).first()
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
if not user or not quiz or quiz.deleted_at or quiz.course_id:
reminder.is_active = False
continue
# Deactivate if the user has no completed attempts left for this quiz
# (e.g., they deleted their attempts — spaced repetition no longer applies).
has_completed_attempt = db.query(QuizAttempt.id).filter(
QuizAttempt.user_id == user.id,
QuizAttempt.quiz_id == quiz.id,
QuizAttempt.completed_at.isnot(None),
).first() is not None
if not has_completed_attempt:
reminder.is_active = False
continue
# Respect the user's opt-out preference (canonical source: Postgres).
if user.reminders_disabled:
continue
# Send email asynchronously
try:
from app.services.email_service import send_reminder_email
loop = asyncio.new_event_loop()
loop.run_until_complete(
send_reminder_email(
email=user.email,
user_name=user.name,
quiz_title=quiz.title,
score=reminder.performance_score,
next_date=reminder.next_reminder_at.strftime("%Y-%m-%d"),
)
)
loop.close()
except Exception as e:
logger.error(f"Failed to send reminder {reminder.id}: {e}")
# Schedule next reminder
from datetime import timedelta
reminder.next_reminder_at = datetime.utcnow() + timedelta(days=reminder.interval_days)
db.commit()
except Exception as e:
logger.exception(f"Scheduler error: {e}")
finally:
db.close()
def start_scheduler():
"""Start the APScheduler with daily reminder check."""
scheduler.add_job(
check_and_send_reminders,
"interval",
hours=24,
id="reminder_check",
replace_existing=True,
)
scheduler.start()
logger.info("Scheduler started — reminder check runs every 24 hours")
def stop_scheduler():
if scheduler.running:
scheduler.shutdown()

View file

@ -0,0 +1,116 @@
"""Cross-reference the articles to each other.
The marker system exists `[[7|Febrile seizures]]` resolves by id, survives a
rename, and shows a preview on hover and not one article used it. Every article
was written in isolation, so a piece on croup mentions stridor and epiglottitis
and offers no way to reach either.
This reads what is already written and links it: where an article's prose names
another article, the first mention becomes a link. By id, so a later rename
cannot break it.
Deliberately conservative, because a wrong link is worse than a missing one:
* only the first mention in a section, so prose is not peppered with the same
link five times;
* whole words only, case-insensitively, longest title first "Otitis media
with effusion" wins over "Otitis media" where both would match;
* never inside an existing link, a marker, a heading, a code span or a table;
* never an article linking to itself;
* titles under four characters are skipped, since short ones collide.
docker compose exec backend python -m scripts.link_articles
docker compose exec backend python -m scripts.link_articles --apply
"""
import re
import sys
from app.database import SessionLocal
from app.models.article import Article
MIN_TITLE = 4
# Only the body of a section, never a heading, a link, a marker or code.
SKIP = re.compile(r"(\[\[[^\]]*\]\]|\[[^\]]*\]\([^)]*\)|`[^`]*`|^\s{0,3}#{1,6}.*$|^\s*\|.*$)", re.M)
def link_text(text: str, titles: list[tuple[str, int, str]], self_id: int) -> tuple[str, int]:
"""Link the first mention of each other article. Returns (text, count)."""
if not text:
return text, 0
# Carve out everything that must not be touched, link the rest, put it back.
holes: list[str] = []
def stash(match: re.Match) -> str:
holes.append(match.group(0))
return f"\x00{len(holes) - 1}\x00"
working = SKIP.sub(stash, text)
linked = 0
for lowered, article_id, display in titles:
if article_id == self_id:
continue
pattern = re.compile(rf"(?<![\w\[]){re.escape(lowered)}(?![\w\]])", re.I)
match = pattern.search(working)
if not match:
continue
# Keep the author's own casing; only the target is decided here.
working = (working[:match.start()]
+ f"[[{article_id}|{match.group(0)}]]"
+ working[match.end():])
linked += 1
restored = re.sub(r"\x00(\d+)\x00", lambda m: holes[int(m.group(1))], working)
return restored, linked
def main():
apply_changes = "--apply" in sys.argv
db = SessionLocal()
try:
articles = db.query(Article).all()
# Longest first: "Otitis media with effusion" must win over "Otitis media".
titles = sorted(
((a.title.lower(), a.id, a.title) for a in articles if len(a.title or "") >= MIN_TITLE),
key=lambda row: -len(row[0]),
)
print(f" articles: {len(articles)} linkable titles: {len(titles)}")
touched = links = 0
for article in articles:
sections = article.sections or []
changed = False
for section in sections:
body, count = link_text(section.get("content"), titles, article.id)
if count:
section["content"] = body
links += count
changed = True
summary, count = link_text(article.summary, titles, article.id)
if count:
article.summary = summary
links += count
changed = True
if changed:
touched += 1
if apply_changes:
from sqlalchemy.orm.attributes import flag_modified
article.sections = sections
flag_modified(article, "sections")
print(f" articles gaining links: {touched}")
print(f" links added : {links}")
if not apply_changes:
print("\n Re-run with --apply.")
return 0
db.commit()
print("\n Linked by id, so renaming an article cannot break them.")
finally:
db.close()
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -75,7 +75,29 @@ def main():
for asset, _old, new in changed: for asset, _old, new in changed:
asset.title = new[:300] asset.title = new[:300]
db.flush()
# A legend is what a reader sees in an explanation, so twelve figures all
# called "Pediatric Surgery" is twelve legends that say nothing. Only the
# repeats are numbered; a subject used once stays clean.
from collections import defaultdict
by_title = defaultdict(list)
for asset in db.query(MediaAsset).order_by(MediaAsset.id).all():
by_title[asset.title].append(asset)
questions = dict(db.execute(sa_text(
"SELECT media_id, MIN(question_id) FROM question_media GROUP BY media_id")).fetchall())
numbered = 0
for title, assets in by_title.items():
if len(assets) < 2:
continue
for asset in assets:
question_id = questions.get(asset.id)
asset.title = (f"{title} · Q{question_id}" if question_id
else f"{title} · #{asset.id}")[:300]
numbered += 1
db.commit() db.commit()
print(f" disambiguated: {numbered} repeated subjects")
print(f"\n retitled: {len(changed)} (no link changed; the id is the link)") print(f"\n retitled: {len(changed)} (no link changed; the id is the link)")
finally: finally:
db.close() db.close()

View file

@ -36,11 +36,31 @@ Captured so nothing is lost while the article writing runs.
- [ ] **Adaptive session** — questions ordered by what would help most, with an - [ ] **Adaptive session** — questions ordered by what would help most, with an
explanation of how it decides. explanation of how it decides.
### Settings ### Settings and access
- [ ] **Revamp the settings page** — called out as the worst screen in the app. - [ ] **Revamp the settings page** — called out as the worst screen in the app.
Work out what belongs there at all before restyling it: account, theme, Work out what belongs there at all before restyling it: account, theme,
exam objective, voice, integrations, data. Several of those have drifted exam objective, voice, integrations, data. Several of those have drifted
in because there was nowhere else to put them. in because there was nowhere else to put them.
- [ ] **Access as a tree** — grant a person articles, an image library, a
question folder, or a category branch, at any depth, with "everything" as
one option rather than a checkbox per row. Three grant tables already
exist (`category_grants`, `media_library_grants`, question folders); they
need one way in rather than three screens, and inheritance down the tree
so granting a branch grants what is under it.
### Media
- [x] **Audio and video** — a murmur cannot be shown as a picture. Uploads take
MP3/WAV/OGG/M4A and MP4/WebM/MOV alongside images, with their own 60 MB
ceiling, and each renders as what it is.
- [x] **Editing opens over the grid**, not inside a cell that stretches its
column and shoves the neighbours out of line.
- [x] **Every figure named by its subject**, with the question number appended
only where a subject repeats — a legend a reader sees in an explanation
should not be twelve identical words.
- [x] **Deleting a figure in use is refused** unless forced, with the count
shown. Renaming and moving are safe because the link is the id.
- [ ] **Library filter in the picker** is in; a library *rail* in the picker
would be better on a wide screen.
### Editor and figures ### Editor and figures
- [x] **Rich editing on the question page** — no new platform needed: Milkdown - [x] **Rich editing on the question page** — no new platform needed: Milkdown

View file

@ -1,37 +1,14 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown' import RichText from './RichText'
import remarkGfm from 'remark-gfm' /**
import ArticleLink from './ArticleLink' * Article prose goes through the same renderer as everything else.
import { markdownImageUrl } from '../utils/uploads' *
* This was a second Markdown pipeline with its own cross-reference regex, and it
// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an * only understood the old slug form so `[[292|Febrile seizures]]`, which is
// educator writes a cross-reference without having to know an article's numeric * what the linker writes, printed as literal brackets.
// id, which changes nothing for them and everything for a link that has to last. */
const WIKI_LINK = /\[\[([^\]|]+?)(?:\|([a-z0-9-]+))?\]\]/g
const expandWikiLinks = (text) => (text || '').replace(WIKI_LINK, (_m, label, slug) =>
`[${label.trim()}](/articles/${(slug || label).trim().toLowerCase()})`)
const internalSlug = (href) => {
const match = /^\/articles\/(?:s\/)?([a-z0-9-]+)\/?$/.exec(href || '')
return match ? match[1] : null
}
export function Markdown({ children, attemptId }) { export function Markdown({ children, attemptId }) {
// Educator content renders as Markdown only; raw HTML is escaped, not executed. return <RichText value={children} attemptId={attemptId} linkArticles />
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={{
img: ({ node, src, ...props }) => <img {...props} src={markdownImageUrl(src, attemptId)} />,
a: ({ node, href, children: kids, ...props }) => {
const slug = internalSlug(href)
// A link into the library stays in the app and shows what it leads to;
// only links off the site get a new tab.
if (slug) return <ArticleLink slug={slug}>{kids}</ArticleLink>
return <a {...props} href={href} target="_blank" rel="noopener noreferrer">{kids}</a>
},
}}>
{expandWikiLinks(children)}
</ReactMarkdown>
)
} }
/** /**
@ -76,6 +53,16 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
const present = VIEWS.filter(v => everySection.some(sec => sec.variant === v.key)) const present = VIEWS.filter(v => everySection.some(sec => sec.variant === v.key))
const [view, setView] = useState(present[0]?.key || 'long') const [view, setView] = useState(present[0]?.key || 'long')
const allSections = everySection.filter(sec => sec.variant === view) const allSections = everySection.filter(sec => sec.variant === view)
// One section is not a contents page. Short and Clinical are usually a single
// section each, so collapsing them by default showed a heading and a blank
// space which read as the view having no content at all.
useEffect(() => {
const tops = allSections.filter(sec => !sec.parent_id)
if (tops.length === 1) {
setOpenIds(Object.fromEntries(allSections.map(sec => [sec.id, true])))
}
}, [view, article])
// Element ids are prefixed because two readers can share a page, and a // Element ids are prefixed because two readers can share a page, and a
// duplicate id would point the pane's contents at the article behind it. // duplicate id would point the pane's contents at the article behind it.
const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`) const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`)

View file

@ -53,3 +53,12 @@
.ip-panel { max-height: 92vh; border-radius: 14px 14px 0 0; } .ip-panel { max-height: 92vh; border-radius: 14px 14px 0 0; }
.ip-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } .ip-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
} }
.ip-library {
min-height: 40px; padding: 8px 11px; cursor: pointer;
border: 1px solid var(--border); border-radius: 9px;
background: var(--input-bg); color: var(--text); font: inherit; font-size: 0.85rem;
}
/* Audio has no thumbnail to speak of; it gets the row rather than a square. */
.ip-thumb audio, .media-thumb audio { width: 100%; }
.ip-thumb video, .media-thumb video { max-width: 100%; max-height: 100%; }

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client' import api from '../api/client'
import { uploadUrl } from '../utils/uploads' import MediaTile from './MediaTile'
import './ImagePicker.css' import './ImagePicker.css'
/** /**
@ -16,6 +16,10 @@ import './ImagePicker.css'
export default function ImagePicker({ open, onPick, onClose, title = 'Choose an image' }) { export default function ImagePicker({ open, onPick, onClose, title = 'Choose an image' }) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [images, setImages] = useState([]) const [images, setImages] = useState([])
const [libraries, setLibraries] = useState([])
// Narrowing by library is how you find a figure when the bank holds hundreds:
// "the radiology one" is a far better first cut than a search term.
const [library, setLibrary] = useState('all')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@ -23,11 +27,14 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true) setLoading(true)
api.get('/media/', { params: query.trim() ? { q: query.trim(), limit: 40 } : { limit: 40 } }) const params = { limit: 40 }
if (query.trim()) params.q = query.trim()
if (library !== 'all') params.library_id = library
api.get('/media/', { params })
.then(res => setImages(res.data?.images || [])) .then(res => setImages(res.data?.images || []))
.catch(() => setError('Could not load the image bank')) .catch(() => setError('Could not load the image bank'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [query]) }, [query, library])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -35,6 +42,11 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [open, load, query]) }, [open, load, query])
useEffect(() => {
if (!open || libraries.length) return
api.get('/media/libraries').then(res => setLibraries(res.data || [])).catch(() => setLibraries([]))
}, [open, libraries.length])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onKey = (event) => { if (event.key === 'Escape') onClose() } const onKey = (event) => { if (event.key === 'Escape') onClose() }
@ -73,8 +85,17 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
<div className="ip-tools"> <div className="ip-tools">
<input className="ip-search" value={query} autoFocus <input className="ip-search" value={query} autoFocus
onChange={e => setQuery(e.target.value)} onChange={e => setQuery(e.target.value)}
placeholder="Search by what the image shows" aria-label="Search images" /> placeholder="Search by what it shows" aria-label="Search images" />
<input type="file" ref={fileInput} accept="image/*" onChange={upload} hidden aria-label="Image file" /> <input type="file" ref={fileInput} accept="image/*,audio/*,video/*" onChange={upload} hidden aria-label="Image file" />
{libraries.length > 0 && (
<select className="ip-library" value={library} aria-label="Library"
onChange={e => setLibrary(e.target.value)}>
<option value="all">Every library</option>
{libraries.map(lib => (
<option key={lib.id} value={lib.id}>{lib.name} ({lib.image_count})</option>
))}
</select>
)}
<button className="btn btn-secondary btn-sm" disabled={busy} <button className="btn btn-secondary btn-sm" disabled={busy}
onClick={() => fileInput.current?.click()}>Upload new</button> onClick={() => fileInput.current?.click()}>Upload new</button>
</div> </div>
@ -90,7 +111,7 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
<li key={image.id}> <li key={image.id}>
<button type="button" className="ip-item" onClick={() => onPick(image.path, image)}> <button type="button" className="ip-item" onClick={() => onPick(image.path, image)}>
<span className="ip-thumb"> <span className="ip-thumb">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} loading="lazy" /> <MediaTile item={image} controls={false} />
<span className="ip-id">#{image.id}</span> <span className="ip-id">#{image.id}</span>
</span> </span>
<span className="ip-title">{image.title || 'Untitled'}</span> <span className="ip-title">{image.title || 'Untitled'}</span>

View file

@ -1,50 +0,0 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import ConfirmButton from './ConfirmButton'
export default function InProgressQuizzes() {
const [inProgress, setInProgress] = useState([])
const navigate = useNavigate()
useEffect(() => {
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
}, [])
const deleteAttempt = async (attemptId) => {
await api.delete(`/attempts/${attemptId}`)
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
}
if (inProgress.length === 0) return null
return (
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
In Progress ({inProgress.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inProgress.map(a => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
<ConfirmButton
label="Delete" confirmLabel="Yes, delete"
onConfirm={() => deleteAttempt(a.attempt_id)}
/>
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,19 @@
import { uploadUrl } from '../utils/uploads'
/**
* One item from the bank, drawn as whatever it is.
*
* Heart sounds are the reason this exists: an audio file rendered as an <img>
* is a broken icon, and a video rendered as one is the same. The kind decides
* the element; everything else about the tile stays identical.
*/
export default function MediaTile({ item, className = '', controls = true }) {
const src = uploadUrl(item.path)
if (item.kind === 'audio') {
return <audio className={className} src={src} controls={controls} preload="none" />
}
if (item.kind === 'video') {
return <video className={className} src={src} controls={controls} preload="metadata" />
}
return <img className={className} src={src} alt={item.alt_text || item.title || ''} loading="lazy" />
}

View file

@ -72,10 +72,10 @@ describe('reading a cross-reference beside the article', () => {
await openSplit('meningitis') await openSplit('meningitis')
const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' }) const pane = await screen.findByRole('region', { name: 'Split view: Meningitis' })
expect(within(pane).queryByText('Neck stiffness')).not.toBeInTheDocument() // Contents rail and collapsible heading both, the same as the page behind
// Contents rail and collapsible heading both, the same as the page behind it. // it. A single-section view opens on arrival rather than showing a heading
// over a blank space.
expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument() expect(within(pane.querySelector('.article-sections')).getByRole('button', { name: 'Signs' })).toBeInTheDocument()
await userEvent.click(within(pane.querySelector('.asec-list')).getByRole('button', { name: /Signs/ }))
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument() expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
}) })

View file

@ -10,7 +10,6 @@ import CategoryColumns from '../components/CategoryColumns'
import { resolveArticleId } from '../components/ArticleLink' import { resolveArticleId } from '../components/ArticleLink'
import ArticleReader from '../components/ArticleReader' import ArticleReader from '../components/ArticleReader'
import ArticleSplitPane from '../components/ArticleSplitPane' import ArticleSplitPane from '../components/ArticleSplitPane'
import CommentSection from '../components/CommentSection'
import PractiseTopic from '../components/PractiseTopic' import PractiseTopic from '../components/PractiseTopic'
import './ArticlesPage.css' import './ArticlesPage.css'
@ -374,7 +373,6 @@ export function ArticlePage() {
))} ))}
</div> </div>
)} )}
<CommentSection articleId={article.id} />
</ArticleReader> </ArticleReader>
</SplitViewProvider> </SplitViewProvider>
</div> </div>

View file

@ -83,16 +83,16 @@ describe('topic reading', () => {
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument() expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology') expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
expect(screen.getByText('Introduction markdown')).toBeInTheDocument() expect(screen.getByText('Introduction markdown')).toBeInTheDocument()
// Headings show; the prose waits to be asked for. Nothing opens in a modal. // A view of one section is not a contents page, so it opens: a lone
// heading over a blank space reads as a view with nothing in it.
expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument() expect(screen.getByRole('heading', { name: /Initial workup/ })).toBeInTheDocument()
expect(screen.queryByText('Section markdown')).not.toBeInTheDocument() expect(screen.getByText('Section markdown')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// The contents rail opens a section, marks it active, and shows its body. // The contents rail still marks the section it jumps to.
const toc = document.querySelector('.article-sections') const toc = document.querySelector('.article-sections')
await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' })) await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' }))
expect(within(toc).getByRole('button', { name: 'Initial workup' })).toHaveClass('active') expect(within(toc).getByRole('button', { name: 'Initial workup' })).toHaveClass('active')
expect(screen.getByText('Section markdown')).toBeInTheDocument()
// Reading a topic offers a test; it never prints the stem, answer or explanation. // Reading a topic offers a test; it never prints the stem, answer or explanation.
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument() expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument() expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()

View file

@ -3,7 +3,6 @@ import ContinueStudy from '../components/ContinueStudy'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import LineChart from '../components/LineChart' import LineChart from '../components/LineChart'
import InProgressQuizzes from '../components/InProgressQuizzes'
import MyNote from '../components/MyNote' import MyNote from '../components/MyNote'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@ -65,27 +64,6 @@ export default function DashboardPage() {
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1> <h1 style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text)' }}>{greetingText}</h1>
</div> </div>
<ContinueStudy /> <ContinueStudy />
{stats && (
<div className="stats-grid">
{/* What is being worked through is questions. A count of quizzes
describes how the material happens to be packaged. */}
{[
{ value: stats.bank_total ? `${stats.questions_seen}/${stats.bank_total}` : stats.questions_seen,
label: 'Questions seen' },
{ value: stats.questions_correct, label: 'Answered correctly' },
{ value: `${stats.average_score}%`, label: 'Average score' },
].map(s => (
<div className="stat-card" key={s.label}>
<div className="stat-value">{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
)}
<InProgressQuizzes />
<MyNote variant="card" /> <MyNote variant="card" />
</div> </div>

View file

@ -102,3 +102,5 @@
.media-modal-body { grid-template-columns: 1fr; } .media-modal-body { grid-template-columns: 1fr; }
.media-modal-foot .btn { flex: 1; } .media-modal-foot .btn { flex: 1; }
} }
.media-used { font-size: 0.72rem; color: var(--primary); font-weight: 600; }

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import api from '../api/client' import api from '../api/client'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { uploadUrl } from '../utils/uploads' import MediaTile from '../components/MediaTile'
import './MediaPage.css' import './MediaPage.css'
const apiError = (err, fallback) => { const apiError = (err, fallback) => {
@ -80,10 +80,10 @@ export default function MediaPage() {
finally { setBusy(false) } finally { setBusy(false) }
} }
const remove = async (image) => { const remove = async (image, force = false) => {
setBusy(true); setError('') setBusy(true); setError('')
try { try {
await api.delete(`/media/${image.id}`) await api.delete(`/media/${image.id}`, { params: force ? { force: true } : {} })
setConfirmDelete(null); setEditing(null); setNotice('Deleted.'); load(); loadLibraries() setConfirmDelete(null); setEditing(null); setNotice('Deleted.'); load(); loadLibraries()
} catch (err) { setError(apiError(err, 'Could not delete this image')) } } catch (err) { setError(apiError(err, 'Could not delete this image')) }
finally { setBusy(false) } finally { setBusy(false) }
@ -121,11 +121,10 @@ export default function MediaPage() {
<div className="media-page"> <div className="media-page">
<div className="media-header"> <div className="media-header">
<div> <div>
<h1>Images</h1> <h1>Media</h1>
<p>Images are found by what they show. A caption is not decoration it is the index.</p>
</div> </div>
<div className="media-header-actions"> <div className="media-header-actions">
<input type="file" ref={fileInput} accept="image/*" onChange={upload} hidden aria-label="Image file" /> <input type="file" ref={fileInput} accept="image/*,audio/*,video/*" onChange={upload} hidden aria-label="Image file" />
<button className="btn btn-primary" disabled={busy} onClick={() => fileInput.current?.click()}>Upload image</button> <button className="btn btn-primary" disabled={busy} onClick={() => fileInput.current?.click()}>Upload image</button>
</div> </div>
</div> </div>
@ -182,7 +181,7 @@ export default function MediaPage() {
{images.map(image => ( {images.map(image => (
<li key={image.id} className={`media-card${editing === image.id ? ' is-editing' : ''}`}> <li key={image.id} className={`media-card${editing === image.id ? ' is-editing' : ''}`}>
<div className="media-thumb"> <div className="media-thumb">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} loading="lazy" /> <MediaTile item={image} />
{/* The id is what you type into a question, so it has to be {/* The id is what you type into a question, so it has to be
readable without opening anything. */} readable without opening anything. */}
<span className="media-id">#{image.id}</span> <span className="media-id">#{image.id}</span>
@ -209,8 +208,18 @@ export default function MediaPage() {
{confirmDelete === image.id && ( {confirmDelete === image.id && (
<div className="media-confirm" role="alert"> <div className="media-confirm" role="alert">
<span>Delete image #{image.id}? Questions using it will lose their picture.</span> {/* Renaming and moving are safe a question refers to a
<button className="btn btn-danger btn-sm" disabled={busy} onClick={() => remove(image)}>Delete</button> figure by id. Deleting is the one act that breaks the
link, so the count is faced before the button. */}
<span>
{image.used_by > 0
? `${image.used_by} question${image.used_by > 1 ? 's' : ''} use this. Deleting takes the figure off ${image.used_by > 1 ? 'them' : 'it'}.`
: 'Nothing uses this. Delete it?'}
</span>
<button className="btn btn-danger btn-sm" disabled={busy}
onClick={() => remove(image, image.used_by > 0)}>
{image.used_by > 0 ? 'Delete anyway' : 'Delete'}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => setConfirmDelete(null)}>Cancel</button> <button className="btn btn-secondary btn-sm" onClick={() => setConfirmDelete(null)}>Cancel</button>
</div> </div>
)} )}
@ -236,7 +245,7 @@ export default function MediaPage() {
<button type="button" onClick={() => setEditing(null)} aria-label="Close"></button> <button type="button" onClick={() => setEditing(null)} aria-label="Close"></button>
</div> </div>
<div className="media-modal-body"> <div className="media-modal-body">
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} /> <MediaTile item={image} />
<div className="media-edit"> <div className="media-edit">
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`} <label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label> onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>

View file

@ -74,14 +74,24 @@ describe('image bank', () => {
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/media/', { params: { library_id: 1 } })) await waitFor(() => expect(api.get).toHaveBeenCalledWith('/media/', { params: { library_id: 1 } }))
}) })
it('warns what a deletion costs before doing it', async () => { it('counts what a deletion costs before doing it', async () => {
mockApi([{ ...images[0], used_by: 3 }, { ...images[1], used_by: 0 }])
mount() mount()
await screen.findByText('#11') await screen.findByText('#11')
api.delete.mockResolvedValue({}) api.delete.mockResolvedValue({})
// Renaming and moving are safe a question refers to a figure by id.
// Deleting is the one act that breaks the link, so the count is faced first.
await userEvent.click(screen.getByRole('button', { name: 'Delete image 11' })) await userEvent.click(screen.getByRole('button', { name: 'Delete image 11' }))
expect(screen.getByRole('alert')).toHaveTextContent('Questions using it will lose their picture') expect(screen.getByRole('alert')).toHaveTextContent('3 questions use this')
await userEvent.click(screen.getByRole('button', { name: 'Delete anyway' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/media/11', { params: { force: true } }))
// Nothing pointing at it needs no forcing.
await userEvent.click(screen.getByRole('button', { name: 'Delete image 12' }))
expect(screen.getByRole('alert')).toHaveTextContent('Nothing uses this')
await userEvent.click(screen.getByRole('button', { name: 'Delete' })) await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/media/11')) await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/media/12', { params: {} }))
}) })
it('keeps library creation and deletion to moderators', async () => { it('keeps library creation and deletion to moderators', async () => {