Files
50ohm/src/build.py
T

734 lines
30 KiB
Python

import json
import os
import random
import re
import shutil
import zipfile
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn, TimeRemainingColumn
from tqdm import tqdm
from renderer.document import Document
from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer
from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer
from renderer.index import Index
from .config import Config
class Build:
def __init__(self, config: Config):
self.config = config
self.env = Environment(loader=FileSystemLoader(self.config.p_templates))
self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers
with self.config.p_data_fragenkatalog.open(encoding="utf-8") as file:
self.fragenkatalog = json.load(file)
with self.config.p_data_metadata.open(encoding="utf-8") as file:
self.question_metadata = json.load(file)
self.questions = self.__parse_katalog(self.fragenkatalog)
self.question_index = {}
self.question_token_pattern = re.compile(r"^\s*\[question:([\w\d]+)\]", re.MULTILINE)
self.index_entries = {}
self.index_token_pattern = Index.pattern
@staticmethod
def __parse_katalog(fragenkatalog):
questions = {}
def collect(section):
for question in section.get("questions", []):
questions[question["number"]] = question
for child in section.get("sections", []):
collect(child)
for exam_part in fragenkatalog["sections"]:
collect(exam_part)
return questions
def __build_question(self, number, template_file="html/question.html"):
"""Combines the original question dataset from BNetzA with our internal metadata"""
question_template = self.env.get_template(template_file)
question = self.questions.get(number)
metadata = self.question_metadata.get(number)
if question is None or metadata is None:
tqdm.write(
f"\033[31mQuestion #{number} is missing"
+ (" (Question not in question pool)" if question is None else "")
+ (" (Question not in metadata)" if metadata is None else "")
+ "\033[0m"
)
metadata = {"layout": "not-found", "picture_a": ""}
number = 404
question = {"question": f"Frage {input} nicht gefunden"}
if "answer_a" in question:
answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]]
else:
answers = []
if metadata["picture_a"] != "":
alt_text_a = self.__picture_handler(metadata["picture_a"])
alt_text_b = self.__picture_handler(metadata["picture_b"])
alt_text_c = self.__picture_handler(metadata["picture_c"])
alt_text_d = self.__picture_handler(metadata["picture_d"])
answer_pictures = [
metadata["picture_a"],
metadata["picture_b"],
metadata["picture_c"],
metadata["picture_d"],
]
alt_text_answers = [
alt_text_a,
alt_text_b,
alt_text_c,
alt_text_d,
]
else:
answer_pictures = []
alt_text_answers = []
if "picture_question" in question and metadata["picture_question"] != "":
picture_question = metadata["picture_question"]
alt_text_question = self.__picture_handler(picture_question)
else:
picture_question = ""
alt_text_question = ""
solution_file = self.config.p_data_solutions / f"{number}.md"
return question_template.render(
question=question["question"],
number=number,
layout=metadata["layout"],
picture_question=picture_question,
answers=answers,
answer_pictures=answer_pictures,
alt_text_answers=alt_text_answers,
alt_text_question=alt_text_question,
has_solution=solution_file.exists(),
)
def __build_question_slide(self, input):
return self.__build_question(input, template_file="slide/question.html")
def __build_page(
self,
content,
course_wrapper=False,
sidebar=None,
page_stylesheets=None,
page_scripts=None,
social_image=None,
):
page_template = self.env.get_template("html/page.html")
return page_template.render(
content=content,
course_wrapper=course_wrapper,
sidebar=sidebar,
page_stylesheets=page_stylesheets or [],
page_scripts=page_scripts or [],
social_image=social_image,
)
def __build_exam_simulator(self):
self.config.p_build_assets.mkdir(parents=True, exist_ok=True)
shutil.copyfile(self.config.p_data_fragenkatalog, self.config.p_build_assets / "fragenkatalog.json")
shutil.copyfile(self.config.p_data_metadata, self.config.p_build_assets / "metadata.json")
(self.config.p_build_assets / "exam-questions.json").unlink(missing_ok=True)
picture_fields = ("picture_question", "picture_a", "picture_b", "picture_c", "picture_d")
picture_ids = {
metadata[field]
for metadata in self.question_metadata.values()
for field in picture_fields
if metadata.get(field)
}
for picture_id in picture_ids:
self.__picture_handler(picture_id)
exam_template = self.env.get_template("html/exam-simulator.html")
content = exam_template.render()
page = self.__build_page(
content,
page_stylesheets=["assets/exam-simulator.css?ts=20260806"],
page_scripts=[
"assets/vue.global.prod.js",
"assets/question-catalog.js?ts=20260806",
"assets/exam-simulator.js?ts=202608064",
],
)
with (self.config.p_build / "simulation.html").open("w", encoding="utf-8") as file:
file.write(page)
with (self.config.p_build / "result.html").open("w", encoding="utf-8") as file:
file.write(page)
for social_image in sorted((self.config.p_assets / "images").glob("exam-*.jpeg")):
result_page = self.__build_page(
content,
page_stylesheets=["assets/exam-simulator.css?ts=20260806"],
page_scripts=[
"assets/vue.global.prod.js",
"assets/question-catalog.js?ts=20260806",
"assets/exam-simulator.js?ts=202608064",
],
social_image=f"assets/images/{social_image.name}",
)
result_name = f"result-{social_image.stem.removeprefix('exam-')}.html"
with (self.config.p_build / result_name).open("w", encoding="utf-8") as file:
file.write(result_page)
def __build_homework_tool(self):
toc_target = self.config.p_build_assets / "toc"
toc_target.mkdir(parents=True, exist_ok=True)
for edition in ("N", "NE", "NEA", "E", "EA", "A"):
shutil.copyfile(self.config.p_data_toc / f"{edition}.json", toc_target / f"{edition}.json")
homework_template = self.env.get_template("html/homework.html")
page = self.__build_page(
homework_template.render(),
page_stylesheets=["assets/homework.css?ts=20260806"],
page_scripts=[
"assets/vue.global.prod.js",
"assets/question-catalog.js?ts=20260806",
"assets/homework.js?ts=202608061",
],
)
with (self.config.p_build / "hausaufgabe.html").open("w", encoding="utf-8") as file:
file.write(page)
def __copy_picture(self, id):
file = f"{id}.svg"
try:
shutil.copyfile(self.config.p_data_pictures / file, self.config.p_build_pictures / file)
description_source = self.config.p_data_pictures / f"{id}.txt"
if description_source.exists():
shutil.copyfile(description_source, self.config.p_build_pictures / description_source.name)
return description_source.read_text(encoding="utf-8")
else:
return "Bildbeschreibung noch nicht verfügbar"
except FileNotFoundError:
tqdm.write(f"\033[31mPicture #{id} not found\033[0m")
def __picture_handler(self, id):
self.config.p_build_pictures.mkdir(parents=True, exist_ok=True)
return self.__copy_picture(id)
def __photo_handler(self, id):
self.config.p_build_photos.mkdir(parents=True, exist_ok=True)
file = f"{id}.png"
try:
shutil.copyfile(self.config.p_data_photos / file, self.config.p_build_photos / file)
if (self.config.p_data_photos / f"{id}.txt").exists():
return (self.config.p_data_photos / f"{id}.txt").read_text(encoding="utf-8")
else:
return "Bildbeschreibung noch nicht verfügbar"
except FileNotFoundError:
tqdm.write(f"\033[31mPhoto #{id} not found\033[0m")
def __build_chapter_index(self, edition, edition_name, number, chapter, next_chapter=None):
chapter_template = self.env.get_template("html/chapter.html")
next_chapter_template = self.env.get_template("html/next_chapter.html")
with (self.config.p_build / f"{edition}_chapter_{chapter['ident']}.html").open("w", encoding="utf-8") as file:
result = chapter_template.render(
edition=edition,
name=edition_name,
number=number,
chapter=chapter,
)
if next_chapter is not None:
result += next_chapter_template.render(
url=f"{edition}_chapter_{next_chapter['ident']}.html",
title=next_chapter["title"],
)
result = self.__build_page(result, course_wrapper=True)
file.write(result)
def __include_handler(self, include):
with (self.config.p_data_html / f"{include}.html").open(encoding="utf-8") as file:
code = file.read()
svg_list = re.findall(r"(\d+)\.svg", code or "")
for id in svg_list:
self.__copy_picture(id)
return code
def __build_section(
self,
edition,
edition_name,
section,
section_id,
chapter,
next_section=None,
next_chapter=None,
chapter_number=None,
):
section_template = self.env.get_template("html/section.html")
next_section_template = self.env.get_template("html/next_section.html")
next_chapter_template = self.env.get_template("html/next_chapter.html")
with (self.config.p_build / f"{edition}_{section['ident']}.html").open("w", encoding="utf-8") as file:
# Use provided chapter_number or fall back to chapter dict
chapter_num = str(chapter_number) if chapter_number is not None else chapter.get("number", "0")
section_num = str(section_id)
# Set the section URL for references
section_filename = f"{edition}_{section['ident']}.html"
with FiftyOhmHtmlRenderer(
question_renderer=self.__build_question,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
edition=edition,
chapter=chapter_num,
section=section_num,
section_url=section_filename,
) as renderer:
section["content"] = renderer.render(Document(section["content"]))
result = section_template.render(
edition=edition,
name=edition_name,
section=section,
section_id=section_id,
chapter=chapter,
)
if next_section is not None:
result += next_section_template.render(
url=f"{edition}_{next_section['ident']}.html",
title=next_section["title"],
)
elif next_chapter is not None:
result += next_chapter_template.render(
url=f"{edition}_chapter_{next_chapter['ident']}.html",
title=next_chapter["title"],
)
result = self.__build_page(result, course_wrapper=True)
file.write(result)
def __build_chapter_slidedeck(
self, edition, chapter, sections, next_chapter, chapter_number=None, progress: Progress = None
):
with (self.config.p_build / f"{edition}_slide_{chapter['ident']}.html").open("w", encoding="utf-8") as file:
slide_template = self.env.get_template("slide/slide.html")
help_template = self.env.get_template("slide/help.html")
next_template = self.env.get_template("slide/next.html")
# Use provided chapter_number or fall back to chapter dict
chapter_num = str(chapter_number) if chapter_number is not None else chapter.get("number", "0")
# Set the slide URL for references (all sections in one file)
# Note: filename uses original edition (e.g., "N"), not modified edition with "S" (e.g., "NS")
slide_filename = f"{edition}_slide_{chapter['ident']}.html"
result = "<section>\n"
result += f'<section data-background="#DAEEFA">\n<h1>{chapter["title"]}</h1>\n</section>\n'
result += help_template.render()
result += "</section>\n"
section_counter = 0
slides_task = progress.add_task("Rendering slides ...")
for section in progress.track(sections, task_id=slides_task):
progress.update(slides_task, description=f"Rendering slides of {section['title']}")
if section["slide"] is None:
continue
section_counter += 1
if not section["slide"].startswith("---"):
section["slide"] = "---\n" + section["slide"]
# A fresh renderer per section keeps the figure counter and
# reference map scoped to that section.
with FiftyOhmHtmlSlideRenderer(
question_renderer=self.__build_question_slide,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
edition=edition,
chapter=chapter_num,
section=str(section_counter),
section_url=slide_filename,
) as renderer:
tmp = f'<section data-background="#DAEEFA">\n<h1>{section["title"]}</h1>\n</section>\n'
tmp += renderer.render(Document(section["slide"]))
result += f"<section>{tmp}</section>\n"
progress.remove_task(slides_task)
result += next_template.render(
edition=edition,
next_chapter=next_chapter,
chapter=chapter,
)
result = slide_template.render(content=result)
file.write(result)
def __filter_shuffle_answers(self, seq):
answers = []
firstrun = True
for answer in seq:
if firstrun:
answers.append({"content": answer, "correct": True})
firstrun = False
else:
answers.append({"content": answer, "correct": False})
random.shuffle(answers)
return answers
def __build_book_index(self, book):
template = self.env.get_template("html/course_index.html")
with (self.config.p_build / f"{book['edition']}_course_index.html").open("w", encoding="utf-8") as file:
result = template.render(
book=book,
)
result = self.__build_page(result)
file.write(result)
def __build_slide_index(self, book):
template = self.env.get_template("slide/slide_index.html")
with (self.config.p_build / f"{book['edition']}_slide_index.html").open("w", encoding="utf-8") as file:
result = template.render(
book=book,
)
result = self.__build_page(result)
file.write(result)
def build_edition(self, edition: str):
self.config.p_build.mkdir(exist_ok=True)
edition = edition.upper()
with (
(self.config.p_data_toc / f"{edition}.json").open(encoding="utf-8") as file,
Progress(
TaskProgressColumn(),
BarColumn(),
TimeRemainingColumn(),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress,
):
chapter_task = progress.add_task(f"Building edition {edition} ...")
book = json.load(file)
edition_name = book["title"]
# Create index pages for chapters and slides.
self.__build_book_index(book)
self.__build_slide_index(book)
chapters = book["chapters"]
for chapter_number, chapter in enumerate(progress.track(chapters, task_id=chapter_task), 1):
progress.update(chapter_task, description=f"Building edition {edition}: Chapter {chapter['title']}")
# Determine next chapter for navigation (None if this is the last chapter)
next_chapter = chapters[chapter_number] if chapter_number < len(chapters) else None
self.__build_chapter_index(edition, edition_name, chapter_number, chapter, next_chapter)
# Open, parse and render each section.
section_task = progress.add_task(description="Rendering sections ...")
for section_number, section in enumerate(progress.track(chapter["sections"], task_id=section_task), 1):
progress.update(section_task, description=f"Rendering section {section['title']}")
# Read section and slide content from the corresponding files.
ident = section["ident"]
section["content"] = None
section["slide"] = None
with (self.config.p_data_sections / f"{ident}.md").open(encoding="utf-8") as sfile:
section_content = sfile.read()
section["content"] = section_content
with (self.config.p_data_slides / f"{ident}.md").open(encoding="utf-8") as sfile:
section_content = sfile.read()
section["slide"] = section_content
if section["content"] is not None:
self.__collect_question_occurrences(
edition,
chapter["title"],
ident,
section["title"],
section["content"],
)
self.__collect_index_occurrences(
edition,
chapter["title"],
ident,
section["title"],
section["content"],
)
next_section = (
chapter["sections"][section_number] if section_number < len(chapter["sections"]) else None
)
self.__build_section(
edition,
edition_name,
section,
section_number,
chapter,
next_section,
next_chapter,
chapter_number,
)
progress.remove_task(section_task)
# Build the slidedeck after all sections have been processed, as they require the files to be read.
self.__build_chapter_slidedeck(
edition, chapter, chapter["sections"], next_chapter, chapter_number, progress
)
progress.remove_task(chapter_task)
def build_assets(self):
self.config.p_build.mkdir(exist_ok=True)
shutil.copytree(
self.config.p_assets, self.config.p_build_assets, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".git")
)
def __parse_snippets(self):
snippets = {}
for md_file in Path(self.config.p_data_snippets).glob("*.md"):
with md_file.open(encoding="utf-8") as file:
snippets[md_file.stem] = file.read()
with FiftyOhmHtmlRenderer(
question_renderer=self.__build_question,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
) as renderer:
for key, value in snippets.items():
snippets[key] = renderer.render_inner(Document(value))
# Remove leading <p> and trailing </p>:
snippets[key] = snippets[key][3:-4]
return snippets
def __parse_contents(self):
static = []
for static_file in Path(self.config.p_data_static).glob("*.html"):
if "sidebar" not in static_file.stem:
with static_file.open(encoding="utf-8") as file:
content = file.read()
sidebar_file = self.config.p_data_static / f"{static_file.stem}_sidebar.html"
if not sidebar_file.exists():
sidebar = ""
else:
with sidebar_file.open(encoding="utf-8") as file:
sidebar = file.read()
static.append(
{
"url_part": static_file.stem,
"content": content,
"sidebar": sidebar if sidebar != "" else None,
}
)
return static
def __build_index(self, snippets):
template = self.env.get_template("html/index.html")
result = template.render({"snippets": snippets})
with (self.config.p_build / "index.html").open("w", encoding="utf-8") as file:
result = self.__build_page(result)
file.write(result)
def __build_course_page(self, snippets, template, page):
template = self.env.get_template(f"html/{template}.html")
result = template.render({"snippets": snippets})
with (self.config.p_build / f"{page}.html").open("w", encoding="utf-8") as file:
result = self.__build_page(result)
file.write(result)
def __build_html_page(self, contents, page):
for content in contents:
if content["url_part"] == page:
with (self.config.p_build / f"{page}.html").open("w", encoding="utf-8") as file:
result = self.__build_page(content=content["content"], sidebar=content["sidebar"])
file.write(result)
def build_website(self):
self.config.p_build.mkdir(exist_ok=True)
snippets = self.__parse_snippets()
contents = self.__parse_contents()
self.__build_index(snippets)
self.__build_course_page(snippets, "kurse-karte", "kurse_vor_ort_karte")
self.__build_course_page(snippets, "kurse-liste", "kurse_vor_ort_liste")
self.__build_course_page(snippets, "patenkarte", "patenkarte")
self.__build_html_page(contents, "pruefung")
self.__build_html_page(contents, "infos")
self.__build_html_page(contents, "suche")
self.__build_exam_simulator()
self.__build_homework_tool()
def build_solutions(self):
for solution_file in self.config.p_data_solutions.glob("*.md"):
with solution_file.open(encoding="utf-8") as file:
content = file.read()
with (self.config.p_build / f"{solution_file.stem}.html").open("w", encoding="utf-8") as file:
with FiftyOhmHtmlRenderer(
question_renderer=self.__build_question,
picture_handler=self.__picture_handler,
photo_handler=self.__photo_handler,
include_handler=self.__include_handler,
) as renderer:
question = self.__build_question(
solution_file.stem, template_file="html/solution_question.html"
)
solution_template = self.env.get_template("html/solution.html")
solution = renderer.render(Document(content))
page = solution_template.render(question=question, solution=solution, number=solution_file.stem)
page = self.__build_page(page, course_wrapper=False)
file.write(page)
def __collect_question_occurrences(
self,
edition: str,
chapter_title: str,
section_ident: str,
section_title: str,
section_content: str,
):
for question_number in self.question_token_pattern.findall(section_content or ""):
question_entry = self.question_index.setdefault(
question_number,
{
"has_solution": self.__has_solution(question_number),
"section": section_ident,
"chapter_title": chapter_title,
"section_title": section_title,
"editions": [],
},
)
if edition not in question_entry["editions"]:
question_entry["editions"].append(edition)
def __has_solution(self, question_number: str) -> bool:
return (self.config.p_data_solutions / f"{question_number}.md").exists()
def __collect_index_occurrences(
self,
edition: str,
chapter_title: str,
section_ident: str,
section_title: str,
section_content: str,
):
for match in self.index_token_pattern.finditer(section_content or ""):
first = match.group(1).strip()
second = match.group(2).strip() if match.group(2) else None
normalized_first = FiftyOhmHtmlRenderer._normalize_index_part(first)
if second:
normalized_second = FiftyOhmHtmlRenderer._normalize_index_part(second)
anchor_id = f"index_{normalized_first}__{normalized_second}"
else:
anchor_id = f"index_{normalized_first}"
entry_key = f"{section_ident}#{anchor_id}"
index_entry = self.index_entries.setdefault(
entry_key,
{
"term": first,
"subterm": second,
"anchor_id": anchor_id,
"section": section_ident,
"chapter_title": chapter_title,
"section_title": section_title,
"editions": [],
},
)
if edition not in index_entry["editions"]:
index_entry["editions"].append(edition)
def build_question_index(self):
tqdm.write("Creating question index")
for question_data in self.question_index.values():
question_data["editions"] = sorted(question_data["editions"])
path = self.config.p_build_assets / "question_index.json"
with (path).open("w", encoding="utf-8") as file:
json.dump(self.question_index, file, ensure_ascii=False, indent=2, sort_keys=True)
file.write("\n")
def build_index(self):
tqdm.write("Creating index")
for index_data in self.index_entries.values():
index_data["editions"] = sorted(index_data["editions"])
entries = sorted(
self.index_entries.values(),
key=lambda item: (
item["term"].casefold(),
(item["subterm"] or "").casefold(),
item["section"],
item["anchor_id"],
),
)
path = self.config.p_build_assets / "index.json"
with (path).open("w", encoding="utf-8") as file:
json.dump(entries, file, ensure_ascii=False, indent=2)
file.write("\n")
def build_zip(self, zip_name: str | None = None) -> Path:
"""Create a zip archive of the complete build output directory.
The zip file is written into the build directory itself and is excluded
from the archive contents.
"""
build_dir = self.config.p_build
build_dir.mkdir(parents=True, exist_ok=True)
zip_path = build_dir / (zip_name if zip_name is not None else f"{build_dir.name}.zip")
zip_path = zip_path.resolve()
# If a previous archive exists, remove it first to avoid zipping stale data.
if zip_path.exists():
zip_path.unlink()
tqdm.write(f"Creating zip archive: {zip_path}")
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# os.walk includes dotfiles; sort for stable archives.
for root, dirs, files in os.walk(build_dir):
dirs.sort()
files.sort()
root_path = Path(root)
for filename in files:
file_path = (root_path / filename).resolve()
if file_path == zip_path:
continue
arcname = file_path.relative_to(build_dir.resolve())
zf.write(file_path, arcname=arcname)
return zip_path