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.*?)', 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(" -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'(]*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]