Added replacements. Added command line switch for dry-run

This commit is contained in:
Alexander Rossmanith
2026-09-16 22:19:57 +02:00
parent e417fc95ac
commit 43a62aab0e
3 changed files with 155 additions and 51 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__
@@ -1,14 +1,11 @@
import argparse
import hashlib import hashlib
import json import json
import re import re
import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
# Set to False after checking the output of a test run.
DRY_RUN = True
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "configuration.json" CONFIG_FILE = SCRIPT_DIR / "configuration.json"
LOG_FILENAME = "caya-clean.log" LOG_FILENAME = "caya-clean.log"
@@ -19,6 +16,30 @@ DATE_PATTERN = re.compile(r"^(?P<date>\d{8})T\d{4}Z\s+")
CAYA_ID_PATTERN = re.compile(r"\s+[a-z0-9]{20,35}$", re.IGNORECASE) CAYA_ID_PATTERN = re.compile(r"\s+[a-z0-9]{20,35}$", re.IGNORECASE)
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Clean up and rename downloaded Caya letter files."
)
)
parser.add_argument(
"folder",
nargs="?",
type=Path,
default=Path.cwd(),
help="Folder containing the letter files.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show planned changes without modifying or deleting files.",
)
return parser.parse_args()
def log_message(folder: Path, message: str): def log_message(folder: Path, message: str):
"""Append a timestamped message to the folder logfile.""" """Append a timestamped message to the folder logfile."""
log_file = folder / LOG_FILENAME log_file = folder / LOG_FILENAME
@@ -31,31 +52,44 @@ def log_message(folder: Path, message: str):
def load_config() -> dict: def load_config() -> dict:
"""Load the configuration from configuration.json.""" """Load and validate configuration.json."""
if not CONFIG_FILE.exists(): if not CONFIG_FILE.exists():
print(f"Configuration file not found: {CONFIG_FILE}") raise FileNotFoundError(
sys.exit(1) f"Configuration file not found: {CONFIG_FILE}"
)
try: try:
with CONFIG_FILE.open("r", encoding="utf-8") as file: with CONFIG_FILE.open("r", encoding="utf-8") as file:
config = json.load(file) config = json.load(file)
except json.JSONDecodeError as error: except json.JSONDecodeError as error:
print(f"Invalid JSON in configuration file: {error}") raise ValueError(
sys.exit(1) f"Invalid JSON in configuration file: {error}"
) from error
exceptions = config.get("umlaut_exceptions", []) umlaut_exceptions = config.get("umlaut_exceptions", [])
if not isinstance(exceptions, list): if not isinstance(umlaut_exceptions, list):
print("'umlaut_exceptions' must be a JSON array.") raise ValueError("'umlaut_exceptions' must be a JSON array.")
sys.exit(1)
config["umlaut_exceptions"] = { replacements = config.get("replacements", {})
str(word).lower()
for word in exceptions if not isinstance(replacements, dict):
raise ValueError("'replacements' must be a JSON object.")
for source, target in replacements.items():
if not isinstance(source, str) or not isinstance(target, str):
raise ValueError(
"Replacement keys and values must both be strings."
)
return {
"umlaut_exceptions": {
str(word).lower()
for word in umlaut_exceptions
},
"replacements": replacements,
} }
return config
def file_hash(path: Path) -> str: def file_hash(path: Path) -> str:
"""Calculate the SHA-256 hash of a file.""" """Calculate the SHA-256 hash of a file."""
@@ -68,10 +102,15 @@ def file_hash(path: Path) -> str:
return hasher.hexdigest() return hasher.hexdigest()
def replace_umlauts(text: str, umlaut_exceptions: set[str]) -> str: def replace_umlauts(
text: str,
umlaut_exceptions: set[str],
) -> str:
""" """
Replace ae/oe/ue with ä/ö/ü. Replace ae/oe/ue with ä/ö/ü.
Words from the exception list are protected temporarily.
Exception strings are matched case-insensitively and protected
before the replacements are applied.
""" """
protected_words = {} protected_words = {}
@@ -98,7 +137,30 @@ def replace_umlauts(text: str, umlaut_exceptions: set[str]) -> str:
return text return text
def clean_stem(stem: str, umlaut_exceptions: set[str]) -> str | None: def apply_replacements(text: str, replacements: dict[str, str]) -> str:
"""Apply configured replacements case-insensitively."""
# Replace longer keys first to avoid partial matches.
replacement_items = sorted(
replacements.items(),
key=lambda item: len(item[0]),
reverse=True,
)
for source, target in replacement_items:
if not source:
continue
pattern = re.compile(re.escape(source), re.IGNORECASE)
text = pattern.sub(lambda _: target, text)
return text
def clean_stem(
stem: str,
umlaut_exceptions: set[str],
replacements: dict[str, str],
) -> str | None:
""" """
Create a cleaned filename stem. Create a cleaned filename stem.
@@ -121,8 +183,17 @@ def clean_stem(stem: str, umlaut_exceptions: set[str]) -> str | None:
# Normalize whitespace and remove unnecessary separators. # Normalize whitespace and remove unnecessary separators.
remainder = re.sub(r"\s+", " ", remainder).strip(" -_.") remainder = re.sub(r"\s+", " ", remainder).strip(" -_.")
# Convert ae/oe/ue to ä/ö/ü, respecting configured exceptions. # Convert ae/oe/ue to ä/ö/ü.
remainder = replace_umlauts(remainder, umlaut_exceptions) remainder = replace_umlauts(
remainder,
umlaut_exceptions,
)
# Apply configured replacements such as GMBH -> GmbH.
remainder = apply_replacements(
remainder,
replacements,
)
if not remainder: if not remainder:
return formatted_date return formatted_date
@@ -135,10 +206,14 @@ def is_clean_filename(path: Path) -> bool:
return bool(re.match(r"^\d{4}-\d{2}-\d{2}\b", path.stem)) return bool(re.match(r"^\d{4}-\d{2}-\d{2}\b", path.stem))
def safely_delete(path: Path, folder: Path): def safely_delete(
"""Delete a file or log the planned action in test mode.""" path: Path,
if DRY_RUN: folder: Path,
log_message(folder, f"[TEST] DELETE {path.name}") dry_run: bool,
):
"""Delete a file or log the planned action in dry-run mode."""
if dry_run:
log_message(folder, f"[DRY-RUN] DELETE {path.name}")
return return
try: try:
@@ -151,12 +226,17 @@ def safely_delete(path: Path, folder: Path):
) )
def safely_rename(old_path: Path, new_path: Path, folder: Path): def safely_rename(
"""Rename a file or log the planned action in test mode.""" old_path: Path,
if DRY_RUN: new_path: Path,
folder: Path,
dry_run: bool,
):
"""Rename a file or log the planned action in dry-run mode."""
if dry_run:
log_message( log_message(
folder, folder,
f"[TEST] RENAME {old_path.name} -> {new_path.name}", f"[DRY-RUN] RENAME {old_path.name} -> {new_path.name}",
) )
return return
@@ -175,18 +255,27 @@ def safely_rename(old_path: Path, new_path: Path, folder: Path):
def main(): def main():
config = load_config() arguments = parse_arguments()
umlaut_exceptions = config["umlaut_exceptions"]
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() try:
config = load_config()
except (FileNotFoundError, ValueError) as error:
print(f"[ERROR] {error}")
return 1
folder = arguments.folder
if not folder.is_dir(): if not folder.is_dir():
print(f"Folder not found: {folder}") print(f"[ERROR] Folder not found: {folder}")
sys.exit(1) return 1
dry_run = arguments.dry_run
umlaut_exceptions = config["umlaut_exceptions"]
replacements = config["replacements"]
log_message( log_message(
folder, folder,
f"[START] Processing folder; test_mode={DRY_RUN}", f"[START] Processing folder; dry_run={dry_run}",
) )
files = [ files = [
@@ -202,7 +291,7 @@ def main():
if not files: if not files:
log_message(folder, "[INFO] No files found") log_message(folder, "[INFO] No files found")
log_message(folder, "[END] Processing completed") log_message(folder, "[END] Processing completed")
return return 0
log_message(folder, f"[INFO] Found {len(files)} file(s)") log_message(folder, f"[INFO] Found {len(files)} file(s)")
@@ -222,7 +311,7 @@ def main():
files_to_keep = set() files_to_keep = set()
files_to_delete = set() files_to_delete = set()
# For identical files, prefer a file that already has a clean filename. # Prefer an already-clean filename for identical files.
for group in hash_groups.values(): for group in hash_groups.values():
if len(group) == 1: if len(group) == 1:
files_to_keep.add(group[0]) files_to_keep.add(group[0])
@@ -248,16 +337,21 @@ def main():
# Delete duplicate files. # Delete duplicate files.
for path in files_to_delete: for path in files_to_delete:
safely_delete(path, folder) safely_delete(path, folder, dry_run)
# Rename remaining Caya original files. # Rename remaining Caya original files.
for path in files_to_keep: for path in files_to_keep:
new_stem = clean_stem(path.stem, umlaut_exceptions) new_stem = clean_stem(
path.stem,
umlaut_exceptions,
replacements,
)
if new_stem is None: if new_stem is None:
log_message( log_message(
folder, folder,
f"[SKIP] Filename does not match Caya format: {path.name}", f"[SKIP] Filename does not match Caya format: "
f"{path.name}",
) )
continue continue
@@ -276,30 +370,33 @@ def main():
except OSError as error: except OSError as error:
log_message( log_message(
folder, folder,
f"[ERROR] Could not compare {path.name} " f"[ERROR] Could not compare "
f"and {target.name}: {error}", f"{path.name} and {target.name}: {error}",
) )
continue continue
if source_hash == target_hash: if source_hash == target_hash:
log_message( log_message(
folder, folder,
f"[DUPLICATE] {path.name} matches existing {target.name}", f"[DUPLICATE] {path.name} matches "
f"existing {target.name}",
) )
safely_delete(path, folder) safely_delete(path, folder, dry_run)
else: else:
log_message( log_message(
folder, folder,
"[CONFLICT] Target already exists with different " "[CONFLICT] Target already exists with "
f"content: {path.name} -> {target.name}", f"different content: {path.name} -> {target.name}",
) )
continue continue
safely_rename(path, target, folder) safely_rename(path, target, folder, dry_run)
log_message(folder, "[END] Processing completed") log_message(folder, "[END] Processing completed")
return 0
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())
+7 -1
View File
@@ -1,5 +1,11 @@
{ {
"umlaut_exceptions": [ "umlaut_exceptions": [
"aktuell",
"eue" "eue"
] ],
"replacements": {
"TOMTEC IMAGING SYSTEMS": "TOMTEC Imaging Systems",
"OPTIK VITZTHUM": "Optik Vitzthum",
"GMBH": "GmbH"
}
} }