Files
docu_build/lib/fortna_inventory.py
T
Michael StanglandClaude Opus 4.8 e7aa3b4e4b feat: Fortna-Extraktion nach chapters/ + Sphinx-PDF-Build
Skripte zum Befuellen der Kapitelstruktur aus dem Fortna-Rohmaterial und
Aufbau eines vollstaendigen Sphinx/LaTeX-Builds (nur Code/Config/Doku,
generierte RST/Bilder/PDFs und Rohmaterial via .gitignore ausgeschlossen).

- lib/fortna_inventory.py: Rohmaterial scannen, Sprache+Zielkapitel+Aktion
  zuordnen, results/fortna_inventory.json schreiben
- lib/fortna_convert_chapters.py: 5 Hauptanleitungen volltextig (Text+Bilder)
  konvertieren, Verzeichnislisten (index.de.rst) fuer uebrige Kapitel
- lib/fortna_init_project.py: projekte/fortna/ (anlage.json, overrides, images)
- lib/build_index.py: index.rst mit toctrees aus chapters/**/*.de.rst generieren
- lib/extract_products.py: Symbolfont-Zeichen (PUA) auf Unicode mappen (clean_pua)
- conf.py: Sphinx-Build (lualatex, babel statt polyglossia, iconpackage=none)
- bin/: .bat/.sh-Wrapper fuer alle Skripte + build_pdf
- doc/: Konzept-Plan (plan_fortna_extraktion.md) und lib-Skript-Uebersicht
- requirements.txt: Sphinx + myst-parser ergaenzt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-23 13:23:32 +02:00

318 lines
12 KiB
Python

"""Inventarisiert das Fortna-Rohmaterial und ordnet jede Datei einem Zielkapitel zu.
Scannt das Fortna-Quellverzeichnis (Standard: PV_FORTNA_SOURCE oder --source-dir)
rekursiv, erkennt fuer jede PDF/DOCX-Datei die Sprache (aus dem Sprachordner) und
das Ziel-Kapitel unter chapters/ (anhand einer praefix-basierten Mapping-Tabelle,
abgeleitet aus doc/kapitelstruktur.md).
Ergebnis wird als results/fortna_inventory.json geschrieben und tabellarisch auf
stdout ausgegeben. Reines Lese-/Analyseskript - schreibt nichts nach chapters/.
Verwendung:
python fortna_inventory.py
python fortna_inventory.py --source-dir "c:/10-Develop/projekte/Fortna"
python fortna_inventory.py --lang de # nur deutsche Dateien im Report
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from collections import Counter
from dataclasses import dataclass, asdict
from pathlib import Path
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Sprach-Erkennung aus Ordnernamen
# ---------------------------------------------------------------------------
# Reihenfolge wichtig: spezifische Muster zuerst. Ein Segment des relativen
# Pfades wird gegen diese Muster geprueft.
LANG_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"_DE(_|$)|_DE_original|DE_f[uü]r|01_DE\b", re.I), "de"),
(re.compile(r"_EN(_|$)|_EN_original|EN_for|1_EN_original", re.I), "en"),
(re.compile(r"_CZ(_|$)|02_CZ|03_CZ|CZ_pro", re.I), "cz"),
]
# Sprach-Suffixe im Dateinamen (Fallback, wenn Ordner nichts hergibt)
FILENAME_LANG = [
(re.compile(r"[_\-\[ ]DE[_\-\.\] ]|_DE\b", re.I), "de"),
(re.compile(r"[_\-\[ ]EN[_\-\.\] ]|_EN\b", re.I), "en"),
(re.compile(r"[_\-\[ ]CZ[_\-\.\] ]|_CZ\b", re.I), "cz"),
]
def detect_lang(rel_path: Path) -> str:
"""Bestimmt die Sprache einer Datei anhand Pfadsegmenten und Dateiname.
Fallback auf 'de', wenn nichts eindeutig erkennbar (z.B. sprachneutrale
Datenblaetter oder gemischte DE-EN-Dokumente).
"""
# Zuerst Ordnersegmente pruefen (aussagekraeftiger als Dateiname)
for part in rel_path.parts[:-1]:
for pattern, lang in LANG_PATTERNS:
if pattern.search(part):
return lang
# Dann Dateiname
name = rel_path.name
for pattern, lang in FILENAME_LANG:
if pattern.search(name):
return lang
return "de"
# ---------------------------------------------------------------------------
# Kapitel-Mapping: Fortna-Quellpfad-Praefix -> (Ziel-Kapitel, Aktion)
# ---------------------------------------------------------------------------
# Aktion: "convert" = Volltext+Bilder nach RST, "list" = nur Verzeichnisliste.
# Regeln werden der Reihe nach geprueft; erste passende gewinnt (praefix-Match
# gegen den mit '/' normalisierten relativen Pfad, case-insensitive).
@dataclass
class MapRule:
pattern: re.Pattern[str]
chapter: str
action: str # "convert" | "list"
MAPPING: list[MapRule] = [
# --- Volltext-Konvertierung: die 5 Hauptanleitungen (nur DE_original) ---
MapRule(re.compile(r"02_INSTRUCTIONS.*/01_BETRIEBSANLEITUNG", re.I),
"04_betrieb/betriebsanleitung", "convert"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/02_WARTUNGSANLEITUNG", re.I),
"05_wartung/wartungsanleitung", "convert"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/03_FUNKTION_STEUERUNG_ILS", re.I),
"06_steuerung/ils", "convert"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/04_FUNKTION_STEUERUNG_OMNIFLO", re.I),
"06_steuerung/omniflo", "convert"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/05_FUNKTION_STEUERUNG_LOADING_LOOP", re.I),
"06_steuerung/loading_loop", "convert"),
# Fremdsprach-Varianten der Anleitungen (EN: USER_MANUAL / MAINTENANCE /
# FUNKTION_CONTROL, CZ: NAVOD / RIZENI). In diesem Durchlauf (nur DE) werden
# sie NICHT konvertiert (action="list"), aber sie sollen nicht als UNMAPPED
# erscheinen. Zuordnung nach Anleitungstyp anhand der fuehrenden Nummer.
MapRule(re.compile(r"02_INSTRUCTIONS.*/01_", re.I),
"04_betrieb/betriebsanleitung", "list"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/02_", re.I),
"05_wartung/wartungsanleitung", "list"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/03_", re.I),
"06_steuerung/ils", "list"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/04_", re.I),
"06_steuerung/omniflo", "list"),
MapRule(re.compile(r"02_INSTRUCTIONS.*/05_", re.I),
"06_steuerung/loading_loop", "list"),
# --- Verzeichnislisten: alles uebrige nach Kapitel ---
# 00 Inhaltsverzeichnis / Notes
MapRule(re.compile(r"^00_CONTENTS", re.I),
"00_inhaltsverzeichnis", "list"),
# 01 Zertifikate
MapRule(re.compile(r"^01_CERTIFICATES.*/01_SCH[OÖ]NENBERGER", re.I),
"01_zertifikate/konformitaetserklaerung", "list"),
MapRule(re.compile(r"^01_CERTIFICATES.*/(02_)?CONFIG", re.I),
"01_zertifikate/konfigurationsbeschreibung", "list"),
MapRule(re.compile(r"^01_CERTIFICATES", re.I),
"01_zertifikate/konformitaetserklaerung", "list"),
# 03 Ersatzteile (Fortna 03_SPARE_PARTS)
MapRule(re.compile(r"^03_SPARE_PARTS", re.I),
"07_ersatzteile", "list"),
# 04 Technical Doc -> 09 / 10
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/01_LAYOUT.*/01_MECHANICAL", re.I),
"09_technische_unterlagen/layouts/mechanisch", "list"),
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/01_LAYOUT.*/02_ELECTRICAL", re.I),
"09_technische_unterlagen/layouts/elektrisch", "list"),
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/02_AREA_STOP", re.I),
"09_technische_unterlagen/bereichsstopp", "list"),
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/03_EMERGENCY_STOP", re.I),
"09_technische_unterlagen/notstopp", "list"),
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/04_DIAGRAMS", re.I),
"09_technische_unterlagen/schaltplaene", "list"),
MapRule(re.compile(r"^04_TECHNICAL_DOC.*/05_REPORTS", re.I),
"10_anhang/pruefberichte", "list"),
# 05 Supplier Doc -> 08 Lieferantendoku (nach Gewerk)
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/01_MiTek", re.I),
"08_lieferantendoku/stahlbau", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/02_ABS", re.I),
"08_lieferantendoku/sicherheitseinrichtungen", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/(03_DTS|04_Leantechnik)", re.I),
"08_lieferantendoku/antriebe", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/05_Electrics.*/01_Instructions.*/01_MOTORS", re.I),
"08_lieferantendoku/antriebe", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/05_Electrics", re.I),
"08_lieferantendoku/elektrik", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC.*/(06_|07_)?Pneumatic", re.I),
"08_lieferantendoku/pneumatik", "list"),
MapRule(re.compile(r"^05_SUPPLIER_DOC", re.I),
"08_lieferantendoku", "list"),
]
def classify(rel_path: Path) -> tuple[str | None, str | None]:
"""Ordnet einen relativen Pfad Kapitel + Aktion zu.
Returns (chapter, action) oder (None, None), wenn keine Regel greift.
"""
norm = rel_path.as_posix()
for rule in MAPPING:
if rule.pattern.search(norm):
return rule.chapter, rule.action
return None, None
# ---------------------------------------------------------------------------
# Inventar-Aufbau
# ---------------------------------------------------------------------------
@dataclass
class InvItem:
rel_path: str
filename: str
ext: str
size_bytes: int
lang: str
fortna_top: str # oberster Fortna-Ordner
chapter: str | None # Ziel-Kapitel unter chapters/
action: str | None # "convert" | "list" | None
def resolve_source(arg_dir: str | None) -> Path:
"""Bestimmt das Fortna-Quellverzeichnis."""
if arg_dir:
src = Path(arg_dir).expanduser().resolve()
elif os.environ.get("PV_FORTNA_SOURCE"):
src = Path(os.environ["PV_FORTNA_SOURCE"]).expanduser().resolve()
else:
# Sinnvoller Default fuer diese Umgebung
src = Path("c:/10-Develop/projekte/Fortna").resolve()
return src
def build_inventory(source: Path) -> list[InvItem]:
"""Scannt das Quellverzeichnis und baut die Inventarliste."""
items: list[InvItem] = []
exts = {".pdf", ".docx"}
for path in sorted(source.rglob("*")):
if not path.is_file():
continue
if path.suffix.lower() not in exts:
continue
rel = path.relative_to(source)
chapter, action = classify(rel)
items.append(InvItem(
rel_path=rel.as_posix(),
filename=path.name,
ext=path.suffix.lower(),
size_bytes=path.stat().st_size,
lang=detect_lang(rel),
fortna_top=rel.parts[0] if rel.parts else "",
chapter=chapter,
action=action,
))
return items
def human_size(n: int) -> str:
"""Formatiert Bytes menschenlesbar (KB/MB)."""
if n >= 1024 * 1024:
return f"{n / (1024 * 1024):.1f}M"
if n >= 1024:
return f"{n / 1024:.0f}K"
return f"{n}B"
def print_summary(items: list[InvItem], lang_filter: str | None) -> None:
"""Gibt eine tabellarische Zusammenfassung auf stdout aus."""
shown = [i for i in items if lang_filter is None or i.lang == lang_filter]
# Uebersicht nach Kapitel + Aktion
by_chapter: Counter = Counter()
for it in shown:
key = f"{it.chapter or '(UNMAPPED)'} [{it.action or '-'}]"
by_chapter[key] += 1
print("\n=== Dateien je Ziel-Kapitel ===")
for key in sorted(by_chapter):
print(f" {by_chapter[key]:4d} {key}")
# Sprachverteilung
by_lang: Counter = Counter(i.lang for i in items)
print("\n=== Sprachverteilung (alle) ===")
for lang in sorted(by_lang):
print(f" {by_lang[lang]:4d} {lang}")
# Nicht zugeordnete Dateien explizit auflisten
unmapped = [i for i in shown if i.chapter is None]
if unmapped:
print(f"\n=== NICHT ZUGEORDNET ({len(unmapped)}) ===")
for it in unmapped:
print(f" {it.rel_path}")
# Konvertierungs-Kandidaten (Volltext) explizit auflisten
conv = [i for i in items if i.action == "convert"]
print(f"\n=== Volltext-Konvertierung ({len(conv)} Kandidaten, alle Sprachen) ===")
for it in sorted(conv, key=lambda x: (x.chapter or "", x.lang)):
print(f" [{it.lang}] {it.chapter:32s} <- {it.rel_path} ({human_size(it.size_bytes)})")
print(f"\nGesamt: {len(items)} Dateien, davon {len(shown)} in Filter '{lang_filter or 'alle'}'.")
def main() -> None:
parser = argparse.ArgumentParser(
description="Inventarisiert Fortna-Rohmaterial und ordnet Zielkapitel zu."
)
parser.add_argument("--source-dir", default=None,
help="Fortna-Quellverzeichnis (default: PV_FORTNA_SOURCE "
"oder c:/10-Develop/projekte/Fortna)")
parser.add_argument("--lang", default=None, choices=["de", "en", "cz"],
help="Report auf eine Sprache filtern (JSON enthaelt immer alles)")
parser.add_argument("--output", default=None,
help="Ausgabe-JSON (default: results/fortna_inventory.json)")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)-7s %(message)s",
)
source = resolve_source(args.source_dir)
if not source.exists() or not source.is_dir():
log.error("Quellverzeichnis nicht gefunden: %s", source)
sys.exit(1)
log.info("Scanne: %s", source)
items = build_inventory(source)
log.info("Gefunden: %d Dateien (.pdf/.docx)", len(items))
# JSON schreiben
project_root = Path(os.environ.get("PROJECT", Path(__file__).resolve().parent.parent))
results_dir = project_root / "results"
results_dir.mkdir(exist_ok=True)
out_path = Path(args.output) if args.output else results_dir / "fortna_inventory.json"
if not out_path.is_absolute():
out_path = results_dir / out_path
payload = {
"source": str(source),
"count": len(items),
"items": [asdict(i) for i in items],
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
log.info("Inventar geschrieben: %s", out_path)
print_summary(items, args.lang)
if __name__ == "__main__":
main()