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:
parent
6abe46d1ea
commit
1f77d421c7
27 changed files with 364 additions and 371 deletions
|
|
@ -21,7 +21,7 @@ if _db_url:
|
|||
if config.config_file_name is not None:
|
||||
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
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
|
|
|||
35
backend/alembic/versions/a1b2c3d4e5f6_drop_reminders.py
Normal file
35
backend/alembic/versions/a1b2c3d4e5f6_drop_reminders.py
Normal 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()),
|
||||
)
|
||||
|
|
@ -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 study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
||||
def seed_admin():
|
||||
|
|
@ -574,15 +573,10 @@ async def lifespan(app: FastAPI):
|
|||
os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
|
||||
seed_admin()
|
||||
seed_default_models()
|
||||
# Scheduler and backfill must only run in one worker to avoid duplicate jobs / race conditions
|
||||
is_primary = _acquire_singleton_lock()
|
||||
if is_primary:
|
||||
# The backfill must run in one worker only, or several race each other.
|
||||
if _acquire_singleton_lock():
|
||||
backfill_embeddings()
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
if is_primary:
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from app.models.section import Section
|
|||
from app.models.quiz import Quiz
|
||||
from app.models.question import Question
|
||||
from app.models.attempt import QuizAttempt, AttemptAnswer
|
||||
from app.models.reminder import ReminderSchedule
|
||||
from app.models.ai_model_config import AIModelConfig
|
||||
from app.models.favorite import Favorite
|
||||
from app.models.user_note import UserNote
|
||||
|
|
@ -23,7 +22,6 @@ __all__ = [
|
|||
"Question",
|
||||
"QuizAttempt",
|
||||
"AttemptAnswer",
|
||||
"ReminderSchedule",
|
||||
"AIModelConfig",
|
||||
"Favorite",
|
||||
"UserNote",
|
||||
|
|
|
|||
|
|
@ -43,4 +43,3 @@ class Quiz(Base):
|
|||
viewonly=True, # mutations handled explicitly via QuizQuestionLink
|
||||
)
|
||||
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
|
||||
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -25,7 +25,6 @@ class User(Base):
|
|||
documents = relationship("PDFDocument", back_populates="user")
|
||||
quizzes = relationship("Quiz", 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")
|
||||
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -176,13 +176,6 @@ def submit_attempt(
|
|||
|
||||
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(
|
||||
id=attempt.id,
|
||||
|
|
@ -401,7 +394,7 @@ def delete_attempt(
|
|||
db: Session = Depends(get_db),
|
||||
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(
|
||||
QuizAttempt.id == attempt_id,
|
||||
QuizAttempt.user_id == current_user.id,
|
||||
|
|
@ -416,11 +409,6 @@ def delete_attempt(
|
|||
|
||||
quiz_id = attempt.quiz_id
|
||||
# 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.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.database import get_db
|
|||
from app.models.media import MediaAsset, MediaLibrary, MediaLibraryGrant, MediaTagLink
|
||||
from app.models.user import User
|
||||
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.utils.auth import get_current_user, require_moderator
|
||||
|
||||
|
|
@ -23,7 +24,28 @@ router = APIRouter()
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
|
|
@ -159,7 +181,14 @@ def list_media(
|
|||
total = query.count()
|
||||
assets = query.order_by(MediaAsset.id.desc()).offset(offset).limit(limit).all()
|
||||
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)
|
||||
|
|
@ -180,10 +209,13 @@ def upload_media(
|
|||
raise HTTPException(400, "Choose one of your libraries for this image")
|
||||
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Upload a PNG, JPEG, GIF, WebP or SVG image")
|
||||
data = file.file.read(MAX_IMAGE_BYTES + 1)
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Keep images under {MAX_IMAGE_BYTES // (1024 * 1024)} MB")
|
||||
raise HTTPException(400, "Upload an image (PNG, JPEG, GIF, WebP, SVG), "
|
||||
"audio (MP3, WAV, OGG, M4A) or video (MP4, WebM, MOV)")
|
||||
kind = kind_for(file.content_type)
|
||||
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:
|
||||
raise HTTPException(400, "That file is empty")
|
||||
|
||||
|
|
@ -193,7 +225,7 @@ def upload_media(
|
|||
|
||||
asset = MediaAsset(
|
||||
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),
|
||||
)
|
||||
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)
|
||||
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)):
|
||||
"""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)
|
||||
if not asset:
|
||||
raise HTTPException(404, "Image not found")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
116
backend/scripts/link_articles.py
Normal file
116
backend/scripts/link_articles.py
Normal 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())
|
||||
|
|
@ -75,7 +75,29 @@ def main():
|
|||
|
||||
for asset, _old, new in changed:
|
||||
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()
|
||||
print(f" disambiguated: {numbered} repeated subjects")
|
||||
print(f"\n retitled: {len(changed)} (no link changed; the id is the link)")
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
22
docs/TODO.md
22
docs/TODO.md
|
|
@ -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
|
||||
explanation of how it decides.
|
||||
|
||||
### Settings
|
||||
### Settings and access
|
||||
- [ ] **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,
|
||||
exam objective, voice, integrations, data. Several of those have drifted
|
||||
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
|
||||
- [x] **Rich editing on the question page** — no new platform needed: Milkdown
|
||||
|
|
|
|||
|
|
@ -1,37 +1,14 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import ArticleLink from './ArticleLink'
|
||||
import { markdownImageUrl } from '../utils/uploads'
|
||||
|
||||
// [[febrile-seizures]] and [[Febrile seizures|febrile-seizures]] are how an
|
||||
// educator writes a cross-reference without having to know an article's numeric
|
||||
// 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
|
||||
}
|
||||
|
||||
import RichText from './RichText'
|
||||
/**
|
||||
* Article prose goes through the same renderer as everything else.
|
||||
*
|
||||
* This was a second Markdown pipeline with its own cross-reference regex, and it
|
||||
* only understood the old slug form — so `[[292|Febrile seizures]]`, which is
|
||||
* what the linker writes, printed as literal brackets.
|
||||
*/
|
||||
export function Markdown({ children, attemptId }) {
|
||||
// Educator content renders as Markdown only; raw HTML is escaped, not executed.
|
||||
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>
|
||||
)
|
||||
return <RichText value={children} attemptId={attemptId} linkArticles />
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,6 +53,16 @@ export default function ArticleReader({ article, activeSection = '', onOpenSecti
|
|||
const present = VIEWS.filter(v => everySection.some(sec => sec.variant === v.key))
|
||||
const [view, setView] = useState(present[0]?.key || 'long')
|
||||
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
|
||||
// duplicate id would point the pane's contents at the article behind it.
|
||||
const sectionElement = (secId) => root.current?.querySelector(`#section-${idPrefix}${secId}`)
|
||||
|
|
|
|||
|
|
@ -53,3 +53,12 @@
|
|||
.ip-panel { max-height: 92vh; border-radius: 14px 14px 0 0; }
|
||||
.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%; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import MediaTile from './MediaTile'
|
||||
import './ImagePicker.css'
|
||||
|
||||
/**
|
||||
|
|
@ -16,6 +16,10 @@ import './ImagePicker.css'
|
|||
export default function ImagePicker({ open, onPick, onClose, title = 'Choose an image' }) {
|
||||
const [query, setQuery] = 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 [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
|
@ -23,11 +27,14 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
|
|||
|
||||
const load = useCallback(() => {
|
||||
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 || []))
|
||||
.catch(() => setError('Could not load the image bank'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [query])
|
||||
}, [query, library])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
|
@ -35,6 +42,11 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
|
|||
return () => clearTimeout(timer)
|
||||
}, [open, load, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || libraries.length) return
|
||||
api.get('/media/libraries').then(res => setLibraries(res.data || [])).catch(() => setLibraries([]))
|
||||
}, [open, libraries.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
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">
|
||||
<input className="ip-search" value={query} autoFocus
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search by what the image shows" aria-label="Search images" />
|
||||
<input type="file" ref={fileInput} accept="image/*" onChange={upload} hidden aria-label="Image file" />
|
||||
placeholder="Search by what it shows" aria-label="Search images" />
|
||||
<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}
|
||||
onClick={() => fileInput.current?.click()}>Upload new</button>
|
||||
</div>
|
||||
|
|
@ -90,7 +111,7 @@ export default function ImagePicker({ open, onPick, onClose, title = 'Choose an
|
|||
<li key={image.id}>
|
||||
<button type="button" className="ip-item" onClick={() => onPick(image.path, image)}>
|
||||
<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>
|
||||
<span className="ip-title">{image.title || 'Untitled'}</span>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
19
frontend/src/components/MediaTile.jsx
Normal file
19
frontend/src/components/MediaTile.jsx
Normal 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" />
|
||||
}
|
||||
|
|
@ -72,10 +72,10 @@ describe('reading a cross-reference beside the article', () => {
|
|||
await openSplit('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 it.
|
||||
// Contents rail and collapsible heading both, the same as the page behind
|
||||
// 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()
|
||||
await userEvent.click(within(pane.querySelector('.asec-list')).getByRole('button', { name: /Signs/ }))
|
||||
expect(within(pane).getByText('Neck stiffness')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import CategoryColumns from '../components/CategoryColumns'
|
|||
import { resolveArticleId } from '../components/ArticleLink'
|
||||
import ArticleReader from '../components/ArticleReader'
|
||||
import ArticleSplitPane from '../components/ArticleSplitPane'
|
||||
import CommentSection from '../components/CommentSection'
|
||||
import PractiseTopic from '../components/PractiseTopic'
|
||||
import './ArticlesPage.css'
|
||||
|
||||
|
|
@ -374,7 +373,6 @@ export function ArticlePage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
<CommentSection articleId={article.id} />
|
||||
</ArticleReader>
|
||||
</SplitViewProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -83,16 +83,16 @@ describe('topic reading', () => {
|
|||
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toHaveTextContent('Neurology')
|
||||
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.queryByText('Section markdown')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Section markdown')).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')
|
||||
await userEvent.click(within(toc).getByRole('button', { name: 'Initial workup' }))
|
||||
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.
|
||||
expect(await screen.findByRole('heading', { name: 'Practise this topic' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Linked question text')).not.toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import ContinueStudy from '../components/ContinueStudy'
|
|||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import LineChart from '../components/LineChart'
|
||||
import InProgressQuizzes from '../components/InProgressQuizzes'
|
||||
import MyNote from '../components/MyNote'
|
||||
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>
|
||||
</div>
|
||||
<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" />
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -102,3 +102,5 @@
|
|||
.media-modal-body { grid-template-columns: 1fr; }
|
||||
.media-modal-foot .btn { flex: 1; }
|
||||
}
|
||||
|
||||
.media-used { font-size: 0.72rem; color: var(--primary); font-weight: 600; }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { uploadUrl } from '../utils/uploads'
|
||||
import MediaTile from '../components/MediaTile'
|
||||
import './MediaPage.css'
|
||||
|
||||
const apiError = (err, fallback) => {
|
||||
|
|
@ -80,10 +80,10 @@ export default function MediaPage() {
|
|||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async (image) => {
|
||||
const remove = async (image, force = false) => {
|
||||
setBusy(true); setError('')
|
||||
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()
|
||||
} catch (err) { setError(apiError(err, 'Could not delete this image')) }
|
||||
finally { setBusy(false) }
|
||||
|
|
@ -121,11 +121,10 @@ export default function MediaPage() {
|
|||
<div className="media-page">
|
||||
<div className="media-header">
|
||||
<div>
|
||||
<h1>Images</h1>
|
||||
<p>Images are found by what they show. A caption is not decoration — it is the index.</p>
|
||||
<h1>Media</h1>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -182,7 +181,7 @@ export default function MediaPage() {
|
|||
{images.map(image => (
|
||||
<li key={image.id} className={`media-card${editing === image.id ? ' is-editing' : ''}`}>
|
||||
<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
|
||||
readable without opening anything. */}
|
||||
<span className="media-id">#{image.id}</span>
|
||||
|
|
@ -209,8 +208,18 @@ export default function MediaPage() {
|
|||
|
||||
{confirmDelete === image.id && (
|
||||
<div className="media-confirm" role="alert">
|
||||
<span>Delete image #{image.id}? Questions using it will lose their picture.</span>
|
||||
<button className="btn btn-danger btn-sm" disabled={busy} onClick={() => remove(image)}>Delete</button>
|
||||
{/* 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 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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -236,7 +245,7 @@ export default function MediaPage() {
|
|||
<button type="button" onClick={() => setEditing(null)} aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div className="media-modal-body">
|
||||
<img src={uploadUrl(image.path)} alt={image.alt_text || image.title || ''} />
|
||||
<MediaTile item={image} />
|
||||
<div className="media-edit">
|
||||
<label>Title<input value={draft.title} aria-label={`Title for image ${image.id}`}
|
||||
onChange={e => setDraft(d => ({ ...d, title: e.target.value }))} /></label>
|
||||
|
|
|
|||
|
|
@ -74,14 +74,24 @@ describe('image bank', () => {
|
|||
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()
|
||||
await screen.findByText('#11')
|
||||
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' }))
|
||||
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 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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue