generate_bible_manifest.py

assets/bibeln/generate_bible_manifest.py
from __future__ import annotations import json import math import re from datetime import datetime, timezone from pathlib import Path BIBLES_DIR = Path(__file__).resolve().parent PROJECT_ROOT = BIBLES_DIR.parent.parent MANIFEST_PATH = BIBLES_DIR / "bibles.json" PROJECT_BIBLE_PATH = BIBLES_DIR / "0nefinity.txt" TARGET_FONT_SIZE = 12 LINE_HEIGHT_FACTOR = 1.2 MONOSPACE_CHAR_WIDTH = 7.224609375 DOCUMENT_SEPARATOR = "--------------------" EXCLUDED_DIRECTORY_PATHS = { Path(".git"), Path(".ssh"), Path(".vscode"), Path("assets/bibeln"), Path("tools/tools"), } EXCLUDED_FILE_PATHS = { Path(".gitattributes"), Path(".gitignore"), Path(".gitmodules"), Path(".htaccess"), Path(".htpasswd"), Path(".sitemap-cache"), } EXCLUDED_FILE_SUFFIXES = { ".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".pdn", ".png", ".svg", ".webp", } def round_float(value: float, digits: int = 4) -> float: return round(float(value), digits) def clean_display_name(file_name: str) -> str: return Path(file_name).stem.replace("_", " ") def normalize_text(raw_text: str) -> str: return re.sub(r"\s+", " ", raw_text.replace("\ufeff", "")).strip() def is_excluded_project_path(path: Path) -> bool: relative_path = path.relative_to(PROJECT_ROOT) if any(relative_path == excluded or excluded in relative_path.parents for excluded in EXCLUDED_DIRECTORY_PATHS): return True if relative_path in EXCLUDED_FILE_PATHS: return True if relative_path.name == ".env" or relative_path.name.startswith(".env."): return True return any(part.startswith(".") for part in relative_path.parts[:-1]) def read_project_text(path: Path) -> str | None: if path.suffix.lower() in EXCLUDED_FILE_SUFFIXES: return None try: raw_bytes = path.read_bytes() except OSError: return None if b"\x00" in raw_bytes: return None try: return raw_bytes.decode("utf-8") except UnicodeDecodeError: return None def build_project_bible_text() -> tuple[str, int]: documents: list[str] = [] included_count = 0 for path in sorted(PROJECT_ROOT.rglob("*"), key=lambda item: item.relative_to(PROJECT_ROOT).as_posix().lower()): if not path.is_file() or is_excluded_project_path(path): continue raw_text = read_project_text(path) if raw_text is None: continue relative_path = path.relative_to(PROJECT_ROOT).as_posix() document = f"{relative_path}\n{raw_text}" if not raw_text.endswith("\n"): document += "\n" document += f"{DOCUMENT_SEPARATOR}\n" documents.append(document) included_count += 1 return "\n".join(documents), included_count def write_project_bible() -> int: project_bible_text, included_count = build_project_bible_text() PROJECT_BIBLE_PATH.write_text(project_bible_text, encoding="utf-8") return included_count def solve_circle_radius(text_length: int, char_width: float, line_height: float) -> int: min_radius = max(char_width * 0.75, line_height * 0.75, 1) estimated_radius = math.sqrt(max(text_length, 1) * char_width * line_height / math.pi) low = min_radius high = max(min_radius * 2, estimated_radius * 1.8) def capacity_for_radius(radius: float) -> int: capacity = 0 y = -radius + line_height / 2 while y <= radius - line_height / 2: row_width = 2 * math.sqrt(max(0, radius * radius - y * y)) capacity += math.floor(row_width / char_width) y += line_height return capacity while capacity_for_radius(high) < text_length: low = high high *= 2 for _ in range(40): mid = (low + high) / 2 capacity = capacity_for_radius(mid) if capacity >= text_length: high = mid else: low = mid return math.ceil(high) def build_circle_layout_preset(text_length: int) -> dict: line_height = TARGET_FONT_SIZE * LINE_HEIGHT_FACTOR radius = solve_circle_radius(text_length, MONOSPACE_CHAR_WIDTH, line_height) return { "fontSize": TARGET_FONT_SIZE, "charWidth": round_float(MONOSPACE_CHAR_WIDTH), "lineHeight": round_float(line_height), "radius": radius, "bounds": { "minX": -radius, "maxX": radius, "minY": -radius, "maxY": radius, "width": radius * 2, "height": radius * 2, }, "worldSpan": radius * 2, } def analyze_file(path: Path) -> dict: raw_text = path.read_text(encoding="utf-8") normalized_text = normalize_text(raw_text) unique_chars = "".join(sorted(set(normalized_text))) return { "file": path.name, "name": clean_display_name(path.name), "rawCharCount": len(raw_text), "charCount": len(normalized_text), "uniqueCharCount": len(unique_chars), "uniqueChars": unique_chars, "layoutPresets": { "circle": build_circle_layout_preset(len(normalized_text)), }, } def build_manifest() -> dict: items = [ analyze_file(path) for path in sorted(BIBLES_DIR.glob("*.txt"), key=lambda item: item.name.lower()) ] return { "version": 1, "generatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "items": items, } def main() -> None: project_file_count = write_project_bible() manifest = build_manifest() MANIFEST_PATH.write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(f"0nefinity geschrieben: {PROJECT_BIBLE_PATH}") print(f"projektdateien in 0nefinity: {project_file_count}") print(f"manifest geschrieben: {MANIFEST_PATH}") print(f"texte: {len(manifest['items'])}") if __name__ == "__main__": main()