import base64 import os from functools import lru_cache from pathlib import Path from urllib.parse import quote def default_content_root() -> Path: current = Path(__file__).resolve() candidates = [ parent / "Programme" / "contenus_pedagogiques" for parent in current.parents ] candidates.extend( [ Path("/programme/contenus_pedagogiques"), Path("/app/Programme/contenus_pedagogiques"), ] ) return next((candidate for candidate in candidates if candidate.exists()), candidates[0]) CONTENT_ROOT = Path(os.getenv("PROGRAM_CONTENT_ROOT", default_content_root())).resolve() GRADE_ALIASES = { "CM1": "cm1", "CM2": "cm2", "6e": "6e", "6eme": "6e", "6ieme": "6e", "5e": "5e", "5eme": "5e", "5ieme": "5e", "4e": "4e", "4eme": "4e", "4ieme": "4e", "3e": "3e", "3eme": "3e", "3ieme": "3e", } def normalize_grade(grade: str) -> str: compact = (grade or "").strip().replace("è", "e").replace("é", "e") return GRADE_ALIASES.get(compact, compact.lower()) def title_from_slug(slug: str) -> str: parts = slug.split("_") if parts and parts[0][:2].isdigit(): parts = parts[1:] return " ".join(part.capitalize() for part in parts if part) def encode_asset_path(relative_path: str) -> str: payload = relative_path.encode("utf-8") return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") def decode_asset_token(token: str) -> Path: padding = "=" * (-len(token) % 4) relative = base64.urlsafe_b64decode(f"{token}{padding}").decode("utf-8") path = (CONTENT_ROOT / relative).resolve() if CONTENT_ROOT not in path.parents: raise ValueError("Chemin de ressource invalide") return path def build_asset_url(relative_path: str) -> str: return f"/api/program/assets/{quote(encode_asset_path(relative_path))}" @lru_cache(maxsize=1) def list_lessons() -> list[dict]: lessons: list[dict] = [] if not CONTENT_ROOT.exists(): return lessons for svg_dir in sorted(CONTENT_ROOT.glob("cycle_*/*/*/**/svg")): if not svg_dir.is_dir(): continue svg_files = sorted(svg_dir.glob("*.svg")) if not svg_files: continue lesson_dir = svg_dir.parent relative_lesson = lesson_dir.relative_to(CONTENT_ROOT).as_posix() parts = relative_lesson.split("/") if len(parts) < 4: continue cycle, subject, grade = parts[0], parts[1], parts[2] title = title_from_slug(lesson_dir.name) readme = lesson_dir / "README.md" if readme.exists(): first_heading = next( ( line.lstrip("#").strip() for line in readme.read_text(encoding="utf-8", errors="ignore").splitlines() if line.startswith("#") ), "", ) title = first_heading or title assets = [ { "name": svg_path.stem, "title": title_from_slug(svg_path.stem), "path": svg_path.relative_to(CONTENT_ROOT).as_posix(), "url": build_asset_url(svg_path.relative_to(CONTENT_ROOT).as_posix()), } for svg_path in svg_files ] lessons.append( { "id": relative_lesson, "title": title, "subject": title_from_slug(subject), "grade": grade, "cycle": cycle, "asset_count": len(assets), "assets": assets, } ) return lessons def list_lessons_for_grade(grade: str) -> list[dict]: normalized = normalize_grade(grade) return [lesson for lesson in list_lessons() if lesson["grade"] == normalized] def get_lesson(lesson_id: str) -> dict | None: return next((lesson for lesson in list_lessons() if lesson["id"] == lesson_id), None)