264 lines
8.8 KiB
Python
264 lines
8.8 KiB
Python
import base64
|
|
import html
|
|
import os
|
|
import re
|
|
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 = "#f5f7fb"
|
|
|
|
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 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'<g\s+transform="translate\((?P<x>[-\d.]+)\s+(?P<y>[-\d.]+)\)">\s*'
|
|
r'<rect\s+x="0"\s+y="0"\s+width="(?P<w>[-\d.]+)"\s+height="(?P<h>[-\d.]+)"[^>]*>'
|
|
r'(?P<body>.*?)</g>',
|
|
re.DOTALL,
|
|
)
|
|
for match in pattern.finditer(text):
|
|
width = float(match.group("w"))
|
|
height = float(match.group("h"))
|
|
if width < 250 or height < 120:
|
|
continue
|
|
title_match = re.search(r'<text[^>]*>(?P<title>.*?)</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)
|