import html import os import re import shutil import stat from pathlib import Path ROOT = Path(__file__).resolve().parents[2] LESSON_RELATIVE = Path( "contenus_pedagogiques/cycle_3/mathematiques/cm1/" "01_nombres_calcul_resolution/01A_nombres_entiers" ) MIRROR_RELATIVE = Path( "Programme/contenus_pedagogiques/cycle_3/mathematiques/cm1/" "01_nombres_calcul_resolution/01A_nombres_entiers" ) LESSON_DIRS = [ROOT / "backend" / LESSON_RELATIVE, ROOT / MIRROR_RELATIVE] SOURCE_BLOCK = ( "\n\n## Sources\n\n" "- Source principale locale : `Programme/pdf_collecteur/state/synthese_cycle3_cm1_mathematiques.md`.\n" "- Documents officiels de reference : PDF du programme de mathematiques cycle 3 conserves dans `Programme/Cycle 3/Math/`.\n" "- Niveau : cycle 3, CM1, mathematiques, nombres/calcul/resolution, nombres entiers.\n" ) 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 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) -> 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 "") sections.append( { "index": len(sections), "title": html.unescape(raw_title).strip() or f"Card {len(sections) + 1}", "view_box": [ max(float(match.group("x")) - 28, 0), max(float(match.group("y")) - 28, 0), width + 56, height + 56, ], } ) return sections def render_svg_section(svg_path: Path, section: dict) -> str: x, y, width, height = section["view_box"] text = svg_path.read_text(encoding="utf-8", errors="ignore") 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 def move_file(source: Path, target: Path) -> None: if not source.exists(): return target.parent.mkdir(parents=True, exist_ok=True) if target.exists(): target.unlink() shutil.move(str(source), str(target)) def ensure_source_block(path: Path) -> None: if not path.exists(): return text = path.read_text(encoding="utf-8", errors="ignore").rstrip() if "## Sources" not in text and "# Sources" not in text: path.write_text(text + SOURCE_BLOCK + "\n", encoding="utf-8") def write_if_missing(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) if not path.exists(): path.write_text(text, encoding="utf-8") def migrate_fiches_and_cards(lesson_dir: Path) -> list[str]: old_svg_dir = lesson_dir / "svg" old_card_dir = lesson_dir / "card_contexts" fiche_slugs = sorted({path.stem for path in list(lesson_dir.glob("[0-9][0-9]_*.md")) + list(old_svg_dir.glob("*.svg"))}) for index, slug in enumerate(fiche_slugs, start=1): fiche_dir = lesson_dir / "fiches" / slug move_file(lesson_dir / f"{slug}.md", fiche_dir / "fiche.md") move_file(old_svg_dir / f"{slug}.svg", fiche_dir / "fiche.svg") ensure_source_block(fiche_dir / "fiche.md") fiche_svg = fiche_dir / "fiche.svg" if not fiche_svg.exists(): continue card_dir = lesson_dir / "cards" / slug legacy_context_dir = old_card_dir / slug sections = extract_svg_sections(fiche_svg) if not sections: sections = [{"index": 0, "title": title_from_slug(slug), "view_box": [0, 0, 1600, 900]}] for section in sections: card_number = section["index"] + 1 target_md = card_dir / f"card_{card_number:02d}.md" for legacy_name in [ legacy_context_dir / f"Fiche{index}Card{card_number}.md", legacy_context_dir / f"card_{card_number:02d}.md", ]: if legacy_name.exists(): move_file(legacy_name, target_md) break write_if_missing( target_md, ( f"# {section['title']}\n\n" f"Contexte de card pour la fiche `{slug}`.\n\n" "## Intention\n\n" "Faire verbaliser le point cle de la zone affichee avant de passer a la suite.\n" + SOURCE_BLOCK ), ) ensure_source_block(target_md) target_svg = card_dir / f"card_{card_number:02d}.svg" target_svg.write_text(render_svg_section(fiche_svg, section), encoding="utf-8") return fiche_slugs def migrate_adaptive_content(lesson_dir: Path) -> None: kit_dir = lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif" if not kit_dir.exists(): return tests_dir = lesson_dir / "tests" for md_path in sorted((kit_dir / "test_diagnostic").glob("*.md")): if md_path.name == "README.md": continue move_file(md_path, tests_dir / md_path.name) ensure_source_block(tests_dir / md_path.name) for support_dir in [kit_dir / "svg" / "tests" / "questions", kit_dir / "svg" / "tests" / "reponses"]: for svg_path in sorted(support_dir.glob("*.svg")): move_file(svg_path, tests_dir / svg_path.name) for md_name in ["00_principe_adaptatif.md", "NOTES_SESSION_2026-04-24.md"]: move_file(kit_dir / md_name, tests_dir / md_name) ensure_source_block(tests_dir / md_name) parcours_dir = lesson_dir / "exercices" / "parcours_adaptes" for exercise_md in sorted((kit_dir / "exercices").glob("*/exercices.md")): target_dir = parcours_dir / exercise_md.parent.name move_file(exercise_md, target_dir / "exercices.md") ensure_source_block(target_dir / "exercices.md") svg_path = kit_dir / "svg" / "exercices" / "profils" / f"{exercise_md.parent.name}.svg" move_file(svg_path, target_dir / "exercices.svg") def build_lesson_markdown(lesson_dir: Path) -> None: parts = [] source_lesson = lesson_dir / "sources" / "lecon_originale.md" kit_lesson = lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif" / "lecon" / "lecon.md" if kit_lesson.exists(): source_lesson.parent.mkdir(parents=True, exist_ok=True) shutil.move(str(kit_lesson), str(source_lesson)) ensure_source_block(source_lesson) parts.append(source_lesson.read_text(encoding="utf-8", errors="ignore").strip()) readme = lesson_dir / "README.md" if readme.exists(): parts.append(readme.read_text(encoding="utf-8", errors="ignore").strip()) for fiche_md in sorted((lesson_dir / "fiches").glob("*/fiche.md")): parts.append(fiche_md.read_text(encoding="utf-8", errors="ignore").strip()) body = "\n\n---\n\n".join(part for part in parts if part) text = ( "# Lecon globale - CM1 - Nombres entiers\n\n" "Cette lecon globale sert de contexte general a Professeur TOP pour la lecon nombres entiers.\n" + SOURCE_BLOCK + "\n## Contenu consolide\n\n" + body + "\n" ) (lesson_dir / "lecon.md").write_text(text, encoding="utf-8") def cleanup_legacy_dirs(lesson_dir: Path) -> None: def handle_remove_error(function, path, exc_info) -> None: os.chmod(path, stat.S_IWRITE) function(path) for legacy_dir in [ lesson_dir / "svg", lesson_dir / "card_contexts", lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif", ]: if legacy_dir.exists(): shutil.rmtree(legacy_dir, onerror=handle_remove_error) def migrate_lesson(lesson_dir: Path) -> None: if not lesson_dir.exists(): return fiche_slugs = migrate_fiches_and_cards(lesson_dir) migrate_adaptive_content(lesson_dir) build_lesson_markdown(lesson_dir) cleanup_legacy_dirs(lesson_dir) print(f"migrated: {lesson_dir} ({len(fiche_slugs)} fiches)") def main() -> None: for lesson_dir in LESSON_DIRS: migrate_lesson(lesson_dir) if __name__ == "__main__": main()