import base64 import html import json import os import re from datetime import datetime from functools import lru_cache from pathlib import Path from urllib.parse import quote def default_content_root() -> Path: current = Path(__file__).resolve() candidates = [ current.parents[1] / "contenus_pedagogiques", current.parents[1] / "Programme" / "contenus_pedagogiques", ] candidates.extend(parent / "Programme" / "contenus_pedagogiques" for parent in current.parents) candidates.extend( [ Path("/programme/contenus_pedagogiques"), Path("/app/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() LESSON_BACKGROUND = "#f8fafc" REVIEW_STATE_PATH = Path(os.getenv("PROGRAM_REVIEW_STATE_PATH", CONTENT_ROOT / ".program_review_state.json")) 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))}" def build_section_url(relative_path: str, section_index: int) -> str: token = quote(encode_asset_path(relative_path)) return f"/api/program/assets/{token}/sections/{section_index}" def find_section_context_path(svg_path: Path, asset_number: int, section_number: int) -> Path | None: context_dir = svg_path.parent.parent / "card_contexts" / svg_path.stem candidates = [ context_dir / f"Fiche{asset_number}Card{section_number}.md", context_dir / f"fiche_{asset_number:02d}_card_{section_number:02d}.md", context_dir / f"card_{section_number:02d}.md", ] return next((candidate for candidate in candidates if candidate.exists()), None) def svg_attr(attrs: str, name: str, default: float | None = None) -> float | None: match = re.search(rf'\b{name}="(?P[-\d.]+)"', attrs) if match: return float(match.group("value")) return default def extract_svg_sections(svg_path: Path, asset_number: int | None = None) -> list[dict]: text = svg_path.read_text(encoding="utf-8", errors="ignore") sections: list[dict] = [] pattern = re.compile( r'[-\d.]+)\s+(?P[-\d.]+)\)">\s*' r'[^>]*)/?>' r'(?P.*?)', re.DOTALL, ) for match in pattern.finditer(text): attrs = match.group("attrs") rect_x = svg_attr(attrs, "x", 0) rect_y = svg_attr(attrs, "y", 0) width = svg_attr(attrs, "width") height = svg_attr(attrs, "height") if rect_x != 0 or rect_y != 0 or width is None or height is None: continue if width < 250 or height < 100: continue title_match = re.search(r']*>(?P.*?)</text>', match.group("body"), re.DOTALL) raw_title = re.sub(r"<[^>]+>", "", title_match.group("title") if title_match else "") title = html.unescape(raw_title).strip() or f"Étape {len(sections) + 1}" margin = 28 section_number = len(sections) + 1 context_path = find_section_context_path(svg_path, asset_number or 1, section_number) sections.append( { "index": len(sections), "title": title, "context_key": f"Fiche{asset_number or 1}Card{section_number}", "context": context_path.read_text(encoding="utf-8", errors="ignore").strip() if context_path else "", "view_box": [ max(float(match.group("x")) - margin, 0), max(float(match.group("y")) - margin, 0), width + margin * 2, height + margin * 2, ], } ) return sections def render_svg_section(path: Path, section_index: int) -> str: sections = extract_svg_sections(path) if section_index < 0 or section_index >= len(sections): raise IndexError("Section introuvable") x, y, width, height = sections[section_index]["view_box"] text = path.read_text(encoding="utf-8", errors="ignore") first_group_index = text.find("<g ") if first_group_index > -1: before_groups = text[:first_group_index] after_groups = text[first_group_index:] before_groups = re.sub( r"\.bg\s*\{[^}]*\}", f".bg {{ fill: {LESSON_BACKGROUND}; }}", before_groups, count=1, ) before_groups = re.sub( r"\.panel\s*\{[^}]*\}", ( ".panel { fill: #ffffff; stroke: #d5e0dc; stroke-width: 3; " "filter: drop-shadow(0 10px 24px rgba(22, 42, 45, 0.08)); }" ), before_groups, count=1, ) before_groups = re.sub( r'(<rect\b(?=[^>]*class="panel")(?=[^>]*/>)[^>]*)/>', rf'\1 style="fill:{LESSON_BACKGROUND};stroke:none;filter:none"/>', before_groups, count=1, ) text = before_groups + after_groups text = re.sub(r'\swidth="[^"]+"', f' width="{int(width)}"', text, count=1) text = re.sub(r'\sheight="[^"]+"', f' height="{int(height)}"', text, count=1) text = re.sub( r'\sviewBox="[^"]+"', f' viewBox="{x:g} {y:g} {width:g} {height:g}"', text, count=1, ) return text @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 = [] for asset_index, svg_path in enumerate(svg_files, start=1): relative_path = svg_path.relative_to(CONTENT_ROOT).as_posix() sections = extract_svg_sections(svg_path, asset_index) assets.append( { "name": svg_path.stem, "title": title_from_slug(svg_path.stem), "path": relative_path, "url": build_asset_url(relative_path), "sections": [ { **section, "url": build_section_url(relative_path, section["index"]), } for section in sections ], } ) 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_content_status() -> dict: lessons = list_lessons() by_grade: dict[str, int] = {} for lesson in lessons: by_grade[lesson["grade"]] = by_grade.get(lesson["grade"], 0) + 1 return { "content_root": str(CONTENT_ROOT), "content_root_exists": CONTENT_ROOT.exists(), "lesson_count": len(lessons), "lessons_by_grade": by_grade, } def get_lesson(lesson_id: str) -> dict | None: return next((lesson for lesson in list_lessons() if lesson["id"] == lesson_id), None) def load_review_state() -> dict: if not REVIEW_STATE_PATH.exists(): return {} try: return json.loads(REVIEW_STATE_PATH.read_text(encoding="utf-8")) except json.JSONDecodeError: return {} def save_review_state(state: dict) -> None: REVIEW_STATE_PATH.parent.mkdir(parents=True, exist_ok=True) REVIEW_STATE_PATH.write_text( json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def collect_lesson_status(lesson: dict) -> dict: lesson_dir = CONTENT_ROOT / lesson["id"] return { "micro_fiches": len([path for path in lesson_dir.glob("*.md") if path.name.startswith(tuple("0123456789"))]), "lesson_svg": len(lesson.get("assets") or []), "cards": sum(len(asset.get("sections") or []) for asset in lesson.get("assets") or []), "contexts": len(list((lesson_dir / "card_contexts").rglob("Fiche*Card*.md"))), "text_exercises": len(list((lesson_dir / "exercices").rglob("exercices.md"))), "exercise_svg": len(list((lesson_dir / "exercices_svg").glob("*.svg"))), "validations": sorted(path.stem for path in lesson_dir.glob("VALIDATION_PHASE_*.md")), } def append_tree_child(parent: dict, key: str, label: str, node_type: str, path: str = "") -> dict: children = parent.setdefault("children", []) for child in children: if child["key"] == key: return child child = {"key": key, "label": label, "type": node_type, "path": path, "children": []} children.append(child) return child def build_program_tree() -> dict: reviews = load_review_state() root = { "key": "program", "label": "Programme", "type": "root", "path": "", "children": [], "review": reviews.get("program", {}), } for lesson in list_lessons(): parts = lesson["id"].split("/") cycle, subject, grade = parts[0], parts[1], parts[2] theme_parts = parts[3:-1] lesson_slug = parts[-1] cycle_node = append_tree_child(root, cycle, title_from_slug(cycle), "cycle", cycle) grade_key = f"{cycle}/{grade}" grade_node = append_tree_child(cycle_node, grade_key, grade.upper(), "grade", grade_key) subject_key = f"{grade_key}/{subject}" subject_node = append_tree_child(grade_node, subject_key, title_from_slug(subject), "subject", subject_key) parent = subject_node current_path = f"{cycle}/{subject}/{grade}" for theme_slug in theme_parts: current_path = f"{current_path}/{theme_slug}" parent = append_tree_child(parent, current_path, title_from_slug(theme_slug), "theme", current_path) lesson_node = append_tree_child(parent, lesson["id"], lesson["title"], "lesson", lesson["id"]) lesson_node["status"] = collect_lesson_status(lesson) lesson_node["review"] = reviews.get(lesson["id"], {}) lesson_node["assets"] = lesson.get("assets") or [] for asset in lesson.get("assets") or []: asset_key = asset["path"] asset_node = append_tree_child(lesson_node, asset_key, asset["title"], "fiche", asset_key) asset_node["url"] = asset.get("url") asset_node["review"] = reviews.get(asset_key, {}) for section in asset.get("sections") or []: section_key = f"{asset_key}#{section.get('context_key') or section.get('index')}" section_node = append_tree_child( asset_node, section_key, section.get("title") or section_key, "card", section_key, ) section_node["url"] = section.get("url") section_node["review"] = reviews.get(section_key, {}) return root def update_program_review(node_key: str, status: str, comment: str, reviewer: str) -> dict: state = load_review_state() state[node_key] = { "status": status, "comment": comment, "reviewer": reviewer, "updated_at": datetime.utcnow().isoformat(timespec="seconds") + "Z", } save_review_state(state) return state[node_key]