#!/usr/bin/env python3 """ merge_prefs.py — Merge Illuscape config patch into Inkscape preferences.xml Usage: python3 merge_prefs.py --prefs ~/.config/inkscape/preferences.xml \ --patch config/preferences-patch.xml \ --preset cc|cs6 """ import argparse import os import shutil import tempfile from pathlib import Path from xml.etree import ElementTree as ET PRESET_OVERRIDES = { "cc": { ("options", "keyboard"): {"file": "illustrator-cc"}, ("options", "units"): {"doc": "px", "font": "px"}, }, "cs6": { ("options", "keyboard"): {"file": "illustrator-cs6"}, ("options", "units"): {"doc": "pt", "font": "pt"}, }, } def _atomic_write(tree: ET.ElementTree, path: Path) -> None: """Write XML tree atomically — prevents corruption on interrupt.""" fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: tree.write(fh, encoding="unicode", xml_declaration=True) os.replace(tmp, path) except Exception: try: os.unlink(tmp) except OSError: pass raise def _merge_nodes(live_parent: ET.Element, patch_parent: ET.Element) -> None: """Recursively upsert patch_parent's children into live_parent.""" for patch_child in patch_parent: child_id = patch_child.get("id") if child_id is None: continue live_child = next( (c for c in live_parent if c.get("id") == child_id), None ) if live_child is None: live_child = ET.SubElement(live_parent, patch_child.tag) live_child.set("id", child_id) for attr, value in patch_child.attrib.items(): if attr != "id": live_child.set(attr, value) _merge_nodes(live_child, patch_child) def _apply_preset(root: ET.Element, preset: str) -> None: """Apply preset-specific overrides (active keys file, document units).""" if preset not in PRESET_OVERRIDES: raise ValueError(f"Unknown preset {preset!r}. Valid: {list(PRESET_OVERRIDES)}") for id_path, attrs in PRESET_OVERRIDES[preset].items(): node = root for id_val in id_path: child = next((c for c in node if c.get("id") == id_val), None) if child is None: child = ET.SubElement(node, "group") child.set("id", id_val) node = child for attr, value in attrs.items(): node.set(attr, value) def merge(prefs_path: Path, patch_path: Path, preset: str) -> None: """Merge patch into prefs, applying preset overrides. Lenient: creates prefs if missing.""" patch_tree = ET.parse(patch_path) # raises FileNotFoundError or ParseError patch_root = patch_tree.getroot() if not prefs_path.exists(): # Lenient: seed from patch, let Inkscape fill in the rest on first launch prefs_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy(patch_path, prefs_path) live_tree = ET.parse(prefs_path) _apply_preset(live_tree.getroot(), preset) _atomic_write(live_tree, prefs_path) return live_tree = ET.parse(prefs_path) # raises ParseError on malformed XML live_root = live_tree.getroot() _merge_nodes(live_root, patch_root) _apply_preset(live_root, preset) live_tree.write(str(prefs_path), encoding="unicode", xml_declaration=True) def main() -> None: parser = argparse.ArgumentParser( description="Merge Illuscape preferences patch into Inkscape preferences.xml" ) parser.add_argument("--prefs", required=True, type=Path) parser.add_argument("--patch", required=True, type=Path) parser.add_argument("--preset", required=True, choices=["cc", "cs6"]) args = parser.parse_args() merge(args.prefs, args.patch, args.preset) print(f"Illuscape preferences merged ({args.preset} preset)") if __name__ == "__main__": main()