Created
July 1, 2026 19:55
-
-
Save me-suzy/4199664193a574b533fe4165a66f479a to your computer and use it in GitHub Desktop.
replace-flags.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| r"""Replace the Categories -> BOOKS START block in Romanian 2022 ART pages. | |
| Defaults are set for this folder: | |
| source: ..\ro\profetia-ghicitoarei-din-desert.html | |
| target: .\2022 - ro | |
| Usage: | |
| python replace-flags.py --dry-run | |
| python replace-flags.py | |
| python replace-flags.py --self-test | |
| python replace-flags.py --source "E:\...\source.html" --target "E:\...\2022 - ro" | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import datetime as dt | |
| import re | |
| import sys | |
| import tempfile | |
| import zipfile | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| SECTION_RE = re.compile( | |
| rb"(?m)^[^\S\r\n]*<!-- Categories -->\r?\n.*?^[^\S\r\n]*<!-- BOOKS START -->\r?\n?", | |
| re.S, | |
| ) | |
| @dataclass | |
| class ReplaceStats: | |
| scanned_files: int = 0 | |
| changed_files: int = 0 | |
| missing_section: int = 0 | |
| already_identical: int = 0 | |
| @dataclass | |
| class PlannedChange: | |
| path: Path | |
| original: bytes | |
| updated: bytes | |
| def default_source() -> Path: | |
| script_dir = Path(__file__).resolve().parent | |
| return script_dir.parent / "ro" / "profetia-ghicitoarei-din-desert.html" | |
| def default_target() -> Path: | |
| return Path(__file__).resolve().parent / "2022 - ro" | |
| def read_source_block(source: Path) -> bytes: | |
| data = source.read_bytes() | |
| matches = list(SECTION_RE.finditer(data)) | |
| if len(matches) != 1: | |
| raise ValueError(f"Expected exactly one source section in {source}, found {len(matches)}.") | |
| return matches[0].group(0) | |
| def replace_one_file(path: Path, source_block: bytes) -> tuple[bytes | None, str]: | |
| data = path.read_bytes() | |
| matches = list(SECTION_RE.finditer(data)) | |
| if len(matches) == 0: | |
| return None, "missing" | |
| if len(matches) > 1: | |
| raise ValueError(f"Expected one target section in {path}, found {len(matches)}.") | |
| match = matches[0] | |
| updated = data[: match.start()] + source_block + data[match.end() :] | |
| if updated == data: | |
| return None, "identical" | |
| return updated, "changed" | |
| def plan_changes(target_root: Path, source_block: bytes) -> tuple[ReplaceStats, list[PlannedChange]]: | |
| stats = ReplaceStats() | |
| changes: list[PlannedChange] = [] | |
| for path in sorted(target_root.rglob("*.html")): | |
| stats.scanned_files += 1 | |
| original = path.read_bytes() | |
| updated, status = replace_one_file(path, source_block) | |
| if status == "missing": | |
| stats.missing_section += 1 | |
| elif status == "identical": | |
| stats.already_identical += 1 | |
| elif status == "changed" and updated is not None: | |
| stats.changed_files += 1 | |
| changes.append(PlannedChange(path=path, original=original, updated=updated)) | |
| return stats, changes | |
| def create_backup(target_root: Path, changes: list[PlannedChange], backup_dir: Path) -> Path: | |
| stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") | |
| backup_path = backup_dir / f"leadership-art-categories-backup-{stamp}.zip" | |
| with zipfile.ZipFile(backup_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: | |
| for change in changes: | |
| archive.writestr(change.path.relative_to(target_root).as_posix(), change.original) | |
| return backup_path | |
| def apply_changes( | |
| target_root: Path, | |
| source_block: bytes, | |
| *, | |
| dry_run: bool, | |
| backup: bool, | |
| backup_dir: Path, | |
| ) -> tuple[ReplaceStats, Path | None]: | |
| stats, changes = plan_changes(target_root, source_block) | |
| if dry_run or not changes: | |
| return stats, None | |
| backup_path = create_backup(target_root, changes, backup_dir) if backup else None | |
| for change in changes: | |
| change.path.write_bytes(change.updated) | |
| return stats, backup_path | |
| def validate_target(target_root: Path, source_block: bytes, *, after_rewrite: bool = False) -> list[str]: | |
| problems: list[str] = [] | |
| for path in sorted(target_root.rglob("*.html")): | |
| data = path.read_bytes() | |
| matches = list(SECTION_RE.finditer(data)) | |
| if len(matches) == 0: | |
| continue | |
| if len(matches) > 1: | |
| problems.append(f"{path}: found {len(matches)} sections") | |
| continue | |
| if after_rewrite: | |
| match = matches[0] | |
| data = data[: match.start()] + source_block + data[match.end() :] | |
| matches = list(SECTION_RE.finditer(data)) | |
| if matches[0].group(0) != source_block: | |
| problems.append(f"{path}: section differs from source") | |
| return problems | |
| def self_test() -> None: | |
| source_html = b"""<html> | |
| <!-- Categories --> | |
| <div>NEW CATEGORIES</div> | |
| <!-- BOOKS START --> | |
| <section>books</section> | |
| </html> | |
| """ | |
| old_target_html = b"""<html> | |
| <main>article</main> | |
| <!-- Categories --> | |
| <div>OLD CATEGORIES</div> | |
| <!-- BOOKS START --> | |
| <section>books</section> | |
| </html> | |
| """ | |
| missing_html = b"<html><p>No target section here.</p></html>\n" | |
| with tempfile.TemporaryDirectory() as tmp: | |
| root = Path(tmp) | |
| source = root / "ro" / "profetia-ghicitoarei-din-desert.html" | |
| target = root / "Leadership ART" / "2022 - ro" | |
| source.parent.mkdir(parents=True) | |
| (target / "nested").mkdir(parents=True) | |
| source.write_bytes(source_html) | |
| (target / "a.html").write_bytes(old_target_html) | |
| (target / "nested" / "b.html").write_bytes(old_target_html) | |
| (target / "nested" / "poza-sus-SLIDER.html").write_bytes(missing_html) | |
| source_block = read_source_block(source) | |
| stats, backup_path = apply_changes( | |
| target, | |
| source_block, | |
| dry_run=False, | |
| backup=False, | |
| backup_dir=root, | |
| ) | |
| problems = validate_target(target, source_block) | |
| if backup_path is not None: | |
| raise AssertionError("self-test should not create a backup") | |
| if stats.scanned_files != 3: | |
| raise AssertionError(f"expected 3 scanned files, got {stats.scanned_files}") | |
| if stats.changed_files != 2: | |
| raise AssertionError(f"expected 2 changed files, got {stats.changed_files}") | |
| if stats.missing_section != 1: | |
| raise AssertionError(f"expected 1 missing section, got {stats.missing_section}") | |
| if problems: | |
| raise AssertionError("; ".join(problems)) | |
| def parse_args(argv: list[str]) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Replace Categories -> BOOKS START blocks in HTML files." | |
| ) | |
| parser.add_argument("--source", type=Path, default=default_source(), help="HTML file to copy the section from.") | |
| parser.add_argument("--target", type=Path, default=default_target(), help="Folder with target HTML files.") | |
| parser.add_argument("--dry-run", action="store_true", help="Show what would change without writing files.") | |
| parser.add_argument("--no-backup", action="store_true", help="Do not create a ZIP backup before editing.") | |
| parser.add_argument( | |
| "--backup-dir", | |
| type=Path, | |
| default=Path(tempfile.gettempdir()), | |
| help="Folder for backup ZIP files. Defaults to the system temp folder.", | |
| ) | |
| parser.add_argument("--self-test", action="store_true", help="Run built-in tests on temporary files.") | |
| return parser.parse_args(argv) | |
| def main(argv: list[str] | None = None) -> int: | |
| args = parse_args(sys.argv[1:] if argv is None else argv) | |
| if args.self_test: | |
| self_test() | |
| print("Self-test OK.") | |
| return 0 | |
| source = args.source.resolve() | |
| target = args.target.resolve() | |
| backup_dir = args.backup_dir.resolve() | |
| if not source.exists() or not source.is_file(): | |
| print(f"Source file does not exist: {source}", file=sys.stderr) | |
| return 2 | |
| if not target.exists() or not target.is_dir(): | |
| print(f"Target folder does not exist: {target}", file=sys.stderr) | |
| return 2 | |
| try: | |
| source_block = read_source_block(source) | |
| stats, backup_path = apply_changes( | |
| target, | |
| source_block, | |
| dry_run=args.dry_run, | |
| backup=not args.no_backup, | |
| backup_dir=backup_dir, | |
| ) | |
| problems = validate_target(target, source_block, after_rewrite=args.dry_run) | |
| except ValueError as exc: | |
| print(str(exc), file=sys.stderr) | |
| return 1 | |
| print("Dry run." if args.dry_run else "Done.") | |
| print(f"Source: {source}") | |
| print(f"Target: {target}") | |
| print(f"HTML files scanned: {stats.scanned_files}") | |
| print(f"HTML files changed: {stats.changed_files}") | |
| print(f"Already identical: {stats.already_identical}") | |
| print(f"Missing section: {stats.missing_section}") | |
| if backup_path: | |
| print(f"Backup ZIP: {backup_path}") | |
| if problems: | |
| print("Validation problems:", file=sys.stderr) | |
| for problem in problems: | |
| print(f"- {problem}", file=sys.stderr) | |
| return 1 | |
| print("Validation OK.") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment