forked from fa/breadcrumb-the-shire
263 lines
7.8 KiB
Python
263 lines
7.8 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Replace ASCII-fallback German umlauts (ue, ae, oe, ss) with proper umlauts
|
|||
|
|
inside .md prose, while preserving:
|
|||
|
|
- fenced code blocks (``` ... ```)
|
|||
|
|
- inline code spans (` ... `)
|
|||
|
|
- markdown link targets ([label](path)) — only the (path) part is preserved
|
|||
|
|
- HTML comments (<!-- ... -->)
|
|||
|
|
|
|||
|
|
Usage: python3 bin/fix-md-umlauts.py <file.md> [<file.md> ...]
|
|||
|
|
--dry-run print proposed changes per file (default: write in place)
|
|||
|
|
--check exit 1 if any file would change (CI mode)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
# Order matters: longer patterns first to prevent partial matches
|
|||
|
|
# (e.g. "aenderung" must be tried before "aender" so prose-ending stays clean).
|
|||
|
|
SUBSTITUTIONS: list[tuple[str, str]] = [
|
|||
|
|
# ß / ss
|
|||
|
|
("massgeblich", "maßgeblich"),
|
|||
|
|
("Massgeblich", "Maßgeblich"),
|
|||
|
|
("gemaess", "gemäß"),
|
|||
|
|
("Gemaess", "Gemäß"),
|
|||
|
|
("regelmaessig", "regelmäßig"),
|
|||
|
|
("Regelmaessig", "Regelmäßig"),
|
|||
|
|
("groesst", "größt"),
|
|||
|
|
("Groesst", "Größt"),
|
|||
|
|
# ä – longer first
|
|||
|
|
("aenderung", "änderung"),
|
|||
|
|
("Aenderung", "Änderung"),
|
|||
|
|
("aenderbar", "änderbar"),
|
|||
|
|
("aendert", "ändert"),
|
|||
|
|
("aendern", "ändern"),
|
|||
|
|
("Aender", "Änder"),
|
|||
|
|
("aender", "änder"),
|
|||
|
|
("naechst", "nächst"),
|
|||
|
|
("Naechst", "Nächst"),
|
|||
|
|
("naemlich", "nämlich"),
|
|||
|
|
("Naemlich", "Nämlich"),
|
|||
|
|
("waehrend", "während"),
|
|||
|
|
("Waehrend", "Während"),
|
|||
|
|
("waehl", "wähl"),
|
|||
|
|
("Waehl", "Wähl"),
|
|||
|
|
("haeufig", "häufig"),
|
|||
|
|
("Haeufig", "Häufig"),
|
|||
|
|
("spaeter", "später"),
|
|||
|
|
("Spaeter", "Später"),
|
|||
|
|
("aufzaehl", "aufzähl"),
|
|||
|
|
("Aufzaehl", "Aufzähl"),
|
|||
|
|
("gefaehr", "gefähr"),
|
|||
|
|
("Gefaehr", "Gefähr"),
|
|||
|
|
("erlaeut", "erläut"),
|
|||
|
|
("Erlaeut", "Erläut"),
|
|||
|
|
("bestaetig", "bestätig"),
|
|||
|
|
("Bestaetig", "Bestätig"),
|
|||
|
|
("Geschaeft", "Geschäft"),
|
|||
|
|
("geschaeft", "geschäft"),
|
|||
|
|
# ü – longer first
|
|||
|
|
("ausfuehr", "ausführ"),
|
|||
|
|
("Ausfuehr", "Ausführ"),
|
|||
|
|
("durchfuehr", "durchführ"),
|
|||
|
|
("Durchfuehr", "Durchführ"),
|
|||
|
|
("einfuehr", "einführ"),
|
|||
|
|
("Einfuehr", "Einführ"),
|
|||
|
|
("fuehrun", "führun"),
|
|||
|
|
("Fuehrun", "Führun"),
|
|||
|
|
("fuehrt", "führt"),
|
|||
|
|
("fuehren", "führen"),
|
|||
|
|
("Fuehren", "Führen"),
|
|||
|
|
("verfuegb", "verfügb"),
|
|||
|
|
("Verfuegb", "Verfügb"),
|
|||
|
|
("unterstuetz", "unterstütz"),
|
|||
|
|
("Unterstuetz", "Unterstütz"),
|
|||
|
|
("nuetzlich", "nützlich"),
|
|||
|
|
("Nuetzlich", "Nützlich"),
|
|||
|
|
("Schluess", "Schlüss"),
|
|||
|
|
("schluess", "schlüss"),
|
|||
|
|
("wuerde", "würde"),
|
|||
|
|
("Wuerde", "Würde"),
|
|||
|
|
("wuerden", "würden"),
|
|||
|
|
("MUESSEN", "MÜSSEN"),
|
|||
|
|
("muessen", "müssen"),
|
|||
|
|
("Muessen", "Müssen"),
|
|||
|
|
("muess", "müss"),
|
|||
|
|
("Muess", "Müss"),
|
|||
|
|
("KOENNEN", "KÖNNEN"),
|
|||
|
|
("koennen", "können"),
|
|||
|
|
("Koennen", "Können"),
|
|||
|
|
("koenn", "könn"),
|
|||
|
|
("Koenn", "Könn"),
|
|||
|
|
("natuerli", "natürli"),
|
|||
|
|
("Natuerli", "Natürli"),
|
|||
|
|
("tatsaechli", "tatsächli"),
|
|||
|
|
("Tatsaechli", "Tatsächli"),
|
|||
|
|
("aehnlich", "ähnlich"),
|
|||
|
|
("Aehnlich", "Ähnlich"),
|
|||
|
|
("standardmaessig", "standardmäßig"),
|
|||
|
|
("Standardmaessig", "Standardmäßig"),
|
|||
|
|
("gleichmaessig", "gleichmäßig"),
|
|||
|
|
("Gleichmaessig", "Gleichmäßig"),
|
|||
|
|
("zugaengli", "zugängli"),
|
|||
|
|
("Zugaengli", "Zugängli"),
|
|||
|
|
("uebersicht", "übersicht"),
|
|||
|
|
("Uebersicht", "Übersicht"),
|
|||
|
|
("uebersetzun", "übersetzun"),
|
|||
|
|
("Uebersetzun", "Übersetzun"),
|
|||
|
|
("uebernahme", "übernahme"),
|
|||
|
|
("Uebernahme", "Übernahme"),
|
|||
|
|
("uebernehm", "übernehm"),
|
|||
|
|
("Uebernehm", "Übernehm"),
|
|||
|
|
("hoeher", "höher"),
|
|||
|
|
("Hoeher", "Höher"),
|
|||
|
|
("pruefen", "prüfen"),
|
|||
|
|
("Pruefen", "Prüfen"),
|
|||
|
|
("prueft", "prüft"),
|
|||
|
|
("Prueft", "Prüft"),
|
|||
|
|
("Pruefung", "Prüfung"),
|
|||
|
|
("pruefung", "prüfung"),
|
|||
|
|
("pruef", "prüf"),
|
|||
|
|
("Pruef", "Prüf"),
|
|||
|
|
("ueber", "über"),
|
|||
|
|
("Ueber", "Über"),
|
|||
|
|
("fuer", "für"),
|
|||
|
|
# ö
|
|||
|
|
("oeffn", "öffn"),
|
|||
|
|
("Oeffn", "Öffn"),
|
|||
|
|
("loescht", "löscht"),
|
|||
|
|
("Loescht", "Löscht"),
|
|||
|
|
("loeschen", "löschen"),
|
|||
|
|
("Loeschen", "Löschen"),
|
|||
|
|
("loesung", "lösung"),
|
|||
|
|
("Loesung", "Lösung"),
|
|||
|
|
("moegli", "mögli"),
|
|||
|
|
("Moegli", "Mögli"),
|
|||
|
|
("moeglich", "möglich"),
|
|||
|
|
("Moeglich", "Möglich"),
|
|||
|
|
("noetig", "nötig"),
|
|||
|
|
("Noetig", "Nötig"),
|
|||
|
|
("hoer", "hör"), # gehör, höher
|
|||
|
|
("Hoer", "Hör"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# Regex that splits a line into "preserve" tokens and replaceable text.
|
|||
|
|
# Order matters: longer / more specific tokens first.
|
|||
|
|
TOKENIZER = re.compile(
|
|||
|
|
r"""
|
|||
|
|
( `[^`\n]*` ) # inline code: `...`
|
|||
|
|
| ( !?\[[^\]]*\]\([^)]*\) ) # full markdown link [label](target)
|
|||
|
|
| ( <!--.*?--> ) # HTML comment
|
|||
|
|
""",
|
|||
|
|
re.VERBOSE | re.DOTALL,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def transform_text(text: str) -> str:
|
|||
|
|
"""Apply substitutions to non-preserved spans only."""
|
|||
|
|
for needle, replacement in SUBSTITUTIONS:
|
|||
|
|
text = text.replace(needle, replacement)
|
|||
|
|
return text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def transform_line(line: str) -> str:
|
|||
|
|
"""Tokenise a line into [preserved | text | preserved | text | ...] and replace only text segments."""
|
|||
|
|
out: list[str] = []
|
|||
|
|
pos = 0
|
|||
|
|
for match in TOKENIZER.finditer(line):
|
|||
|
|
# Replaceable run before the preserved span
|
|||
|
|
if match.start() > pos:
|
|||
|
|
out.append(transform_text(line[pos : match.start()]))
|
|||
|
|
out.append(match.group(0)) # keep preserved span verbatim
|
|||
|
|
pos = match.end()
|
|||
|
|
# Tail after last preserved span
|
|||
|
|
if pos < len(line):
|
|||
|
|
out.append(transform_text(line[pos:]))
|
|||
|
|
return "".join(out)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def transform_file(content: str) -> str:
|
|||
|
|
"""Walk lines, skip fenced code blocks, transform the rest line-by-line."""
|
|||
|
|
out_lines: list[str] = []
|
|||
|
|
in_fence = False
|
|||
|
|
fence_marker = ""
|
|||
|
|
|
|||
|
|
for line in content.splitlines(keepends=True):
|
|||
|
|
stripped = line.lstrip()
|
|||
|
|
|
|||
|
|
if in_fence:
|
|||
|
|
out_lines.append(line)
|
|||
|
|
if stripped.startswith(fence_marker):
|
|||
|
|
in_fence = False
|
|||
|
|
fence_marker = ""
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Detect fenced code start (``` or ~~~)
|
|||
|
|
if stripped.startswith("```"):
|
|||
|
|
in_fence = True
|
|||
|
|
fence_marker = "```"
|
|||
|
|
out_lines.append(line)
|
|||
|
|
continue
|
|||
|
|
if stripped.startswith("~~~"):
|
|||
|
|
in_fence = True
|
|||
|
|
fence_marker = "~~~"
|
|||
|
|
out_lines.append(line)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
out_lines.append(transform_line(line))
|
|||
|
|
|
|||
|
|
return "".join(out_lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main(argv: list[str]) -> int:
|
|||
|
|
args = list(argv[1:])
|
|||
|
|
dry_run = False
|
|||
|
|
check = False
|
|||
|
|
while args and args[0].startswith("--"):
|
|||
|
|
flag = args.pop(0)
|
|||
|
|
if flag == "--dry-run":
|
|||
|
|
dry_run = True
|
|||
|
|
elif flag == "--check":
|
|||
|
|
check = True
|
|||
|
|
elif flag == "--":
|
|||
|
|
break
|
|||
|
|
else:
|
|||
|
|
print(f"Unknown flag: {flag}", file=sys.stderr)
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
if not args:
|
|||
|
|
print(__doc__.strip(), file=sys.stderr)
|
|||
|
|
return 2
|
|||
|
|
|
|||
|
|
changed_files: list[Path] = []
|
|||
|
|
for raw in args:
|
|||
|
|
path = Path(raw)
|
|||
|
|
if not path.is_file():
|
|||
|
|
print(f"Skipping (not a file): {path}", file=sys.stderr)
|
|||
|
|
continue
|
|||
|
|
original = path.read_text(encoding="utf-8")
|
|||
|
|
transformed = transform_file(original)
|
|||
|
|
if transformed != original:
|
|||
|
|
changed_files.append(path)
|
|||
|
|
if dry_run or check:
|
|||
|
|
# Brief summary; full diff is what `git diff` shows after a real run.
|
|||
|
|
added = sum(1 for c in transformed if c in "äöüÄÖÜß") - sum(
|
|||
|
|
1 for c in original if c in "äöüÄÖÜß"
|
|||
|
|
)
|
|||
|
|
print(f"WOULD CHANGE {path} (+{added} umlauts)")
|
|||
|
|
else:
|
|||
|
|
path.write_text(transformed, encoding="utf-8")
|
|||
|
|
print(f"updated {path}")
|
|||
|
|
|
|||
|
|
if check and changed_files:
|
|||
|
|
return 1
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main(sys.argv))
|