diff --git a/scripts/merge_prefs.py b/scripts/merge_prefs.py new file mode 100644 index 0000000..306e821 --- /dev/null +++ b/scripts/merge_prefs.py @@ -0,0 +1,102 @@ +#!/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 shutil +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 _find_or_create(parent: ET.Element, id_val: str) -> ET.Element: + """Return child with matching id, or create and append a new one.""" + for child in parent: + if child.get("id") == id_val: + return child + child = ET.SubElement(parent, "group") + child.set("id", id_val) + return child + + +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 = _find_or_create(live_parent, child_id) + + # Merge attributes: only set attrs present in patch; preserve unknown attrs + for attr, value in patch_child.attrib.items(): + if attr != "id": + live_child.set(attr, value) + + # Recurse into children + _merge_nodes(live_child, patch_child) + + +def _apply_preset(root: ET.Element, preset: str) -> None: + """Apply preset-specific overrides (active keys file, document units).""" + for id_path, attrs in PRESET_OVERRIDES[preset].items(): + node = root + for id_val in id_path: + node = _find_or_create(node, id_val) + 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) + live_tree.write(str(prefs_path), encoding="unicode", xml_declaration=True) + 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() diff --git a/tests/test_merge_prefs.py b/tests/test_merge_prefs.py new file mode 100644 index 0000000..3ed2e1a --- /dev/null +++ b/tests/test_merge_prefs.py @@ -0,0 +1,140 @@ +"""Tests for scripts/merge_prefs.py""" +import shutil +import sys +from pathlib import Path +from xml.etree import ElementTree as ET + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +import merge_prefs + +FIXTURES = Path(__file__).parent / "fixtures" + + +def _parse(path: Path) -> ET.Element: + return ET.parse(path).getroot() + + +def _attr(root: ET.Element, *id_path: str) -> dict: + """Walk id_path through nested elements, return attribs.""" + node = root + for id_val in id_path: + node = next( + (c for c in node if c.get("id") == id_val), None + ) + assert node is not None, f"Node with id={id_val!r} not found" + return dict(node.attrib) + + +# ── merge into existing prefs ───────────────────────────────────────────────── + +def test_merge_changes_patched_attributes(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "options", "scroll")["mousewheel-zoom"] == "false" + assert _attr(root, "options", "zoom")["mousewheel-zoom-ctrl"] == "true" + assert _attr(root, "tools", "nodes")["square-handles"] == "true" + + +def test_merge_creates_missing_nodes(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "options", "new-group")["new-attr"] == "new-value" + + +def test_merge_preserves_user_nodes(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-user-custom.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "options", "user-custom-setting")["my-value"] == "keep-this" + assert _attr(root, "tools", "user-tool-pref")["color"] == "#ff0000" + + +def test_merge_preserves_existing_unknown_attributes(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-user-custom.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "tools", "nodes").get("user-pref") == "preserved" + + +def test_merge_is_idempotent(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + content_after_first = prefs.read_text() + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + content_after_second = prefs.read_text() + assert content_after_first == content_after_second + + +# ── lenient: no prefs file ──────────────────────────────────────────────────── + +def test_merge_creates_prefs_when_missing(tmp_path): + prefs = tmp_path / "inkscape" / "preferences.xml" + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + assert prefs.exists() + root = _parse(prefs) + assert _attr(root, "options", "scroll")["mousewheel-zoom"] == "false" + + +def test_merge_creates_parent_dirs_when_missing(tmp_path): + prefs = tmp_path / "deep" / "nested" / "dir" / "preferences.xml" + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + assert prefs.exists() + + +# ── preset overrides ────────────────────────────────────────────────────────── + +def test_preset_cc_sets_keys_file(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "options", "keyboard")["file"] == "illustrator-cc" + + +def test_preset_cs6_sets_keys_file(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cs6") + root = _parse(prefs) + assert _attr(root, "options", "keyboard")["file"] == "illustrator-cs6" + + +def test_preset_cc_sets_px_units(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + root = _parse(prefs) + assert _attr(root, "options", "units")["doc"] == "px" + + +def test_preset_cs6_sets_pt_units(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cs6") + root = _parse(prefs) + assert _attr(root, "options", "units")["doc"] == "pt" + + +# ── error handling ──────────────────────────────────────────────────────────── + +def test_merge_raises_on_malformed_prefs(tmp_path): + prefs = tmp_path / "preferences.xml" + prefs.write_text("this is not xml") + with pytest.raises(ET.ParseError): + merge_prefs.merge(prefs, FIXTURES / "patch-minimal.xml", "cc") + + +def test_merge_raises_on_missing_patch(tmp_path): + prefs = tmp_path / "preferences.xml" + shutil.copy(FIXTURES / "prefs-fresh.xml", prefs) + with pytest.raises(FileNotFoundError): + merge_prefs.merge(prefs, tmp_path / "nonexistent-patch.xml", "cc")