#!/usr/bin/env python3 import os import shutil import subprocess import sys import tempfile import xml.etree.ElementTree as ET from datetime import datetime, timezone from pathlib import Path INKSCAPE_NS = "http://www.inkscape.org/namespace/inkscape/extension" ET.register_namespace("", INKSCAPE_NS) REPOS = [ { "name": "botbox3000", "url": "https://github.com/jondale/botbox3000.git", "branch": "main", "directory": "botbox3000", "inx": [ { "file": "boxbot.inx", "id": "org.knoxmakers.botbox", "submenu": ["Knox Makers", "Laser"], }, ], }, { "name": "km-living-hinge", "url": "https://github.com/KnoxMakers/km-living-hinge.git", "branch": "main", "directory": "km-living-hinge", }, { "name": "km-plot", "url": "https://git.knoxmakers.org/KnoxMakers/km-plot.git", "branch": "main", "directory": "km-plot", }, { "name": "km-hatch", "url": "https://git.knoxmakers.org/KnoxMakers/km-hatch.git", "branch": "main", "directory": "km-hatch", }, ] def run(cmd: list[str], cwd: str | None = None) -> str: result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=True) return result.stdout.strip() def has_staged_changes(root: Path) -> bool: result = subprocess.run( ["git", "diff", "--cached", "--quiet"], cwd=root, capture_output=True, ) return result.returncode != 0 def sync_repo(repo: dict, ext_dir: Path, tmp_dir: Path) -> None: name = repo["name"] url = repo["url"] branch = repo.get("branch", "main") directory = repo["directory"] work = tmp_dir / name target = ext_dir / directory print(f"==> Sync {name} from {url} ({branch}) -> extensions/{directory}") subprocess.run( ["git", "clone", "--depth", "1", "--branch", branch, url, str(work)], check=True, ) if target.exists(): shutil.rmtree(target) shutil.copytree(work, target) git_dir = target / ".git" if git_dir.exists(): shutil.rmtree(git_dir) commit = run(["git", "rev-parse", "HEAD"], cwd=str(work)) (target / ".upstream_commit").write_text(commit + "\n") (target / ".upstream_url").write_text(url + "\n") (target / ".upstream_branch").write_text(branch + "\n") def set_extension_id(root: ET.Element, new_id: str) -> bool: ns = {"ink": INKSCAPE_NS} id_elem = root.find("ink:id", ns) if id_elem is None: id_elem = root.find("id") if id_elem is not None: if id_elem.text != new_id: id_elem.text = new_id return True return False name_elem = root.find("ink:name", ns) if name_elem is None: name_elem = root.find("name") id_elem = ET.Element("id") id_elem.text = new_id if name_elem is not None: idx = list(root).index(name_elem) root.insert(idx + 1, id_elem) else: root.insert(0, id_elem) return True def set_effects_submenu(root: ET.Element, submenus: list[str]) -> bool: ns = {"ink": INKSCAPE_NS} effect_elem = root.find(".//ink:effect", ns) if effect_elem is None: effect_elem = root.find(".//effect") if effect_elem is None: return False menu_elem = None for tag in ["effects-menu", "effectsmenu"]: menu_elem = effect_elem.find(tag) if menu_elem is not None: break menu_elem = effect_elem.find(f"ink:{tag}", ns) if menu_elem is not None: break if menu_elem is None: return False menu_elem.clear() menu_elem.tag = "effects-menu" parent = menu_elem for i, submenu_name in enumerate(submenus): submenu = ET.SubElement(parent, "submenu") submenu.set("_name", submenu_name) if i < len(submenus) - 1: parent = submenu return True def patch_inx(inx_path: Path, extension_id: str | None, submenus: list[str] | None) -> bool: if not inx_path.exists(): print(f"ERROR: expected file not found: {inx_path}") sys.exit(1) print(f"Patching {inx_path}") tree = ET.parse(inx_path) root = tree.getroot() modified = False if extension_id is not None: if set_extension_id(root, extension_id): modified = True if submenus is not None: if set_effects_submenu(root, submenus): modified = True if modified: ET.indent(tree, space=" ") tree.write(inx_path, encoding="UTF-8", xml_declaration=True) content = inx_path.read_text(encoding="utf-8") if not content.endswith("\n"): inx_path.write_text(content + "\n", encoding="utf-8") return modified def process_repo(repo: dict, ext_dir: Path) -> None: inx_list = repo.get("inx", []) directory = repo["directory"] for inx_config in inx_list: inx_file = inx_config.get("file") extension_id = inx_config.get("id") submenus = inx_config.get("submenu") if inx_file and (extension_id or submenus): inx_path = ext_dir / directory / inx_file patch_inx(inx_path, extension_id, submenus) def main() -> None: root = Path(__file__).resolve().parent.parent ext_dir = root / "extensions" ext_dir.mkdir(exist_ok=True) os.chdir(root) with tempfile.TemporaryDirectory() as tmp: tmp_dir = Path(tmp) for repo in REPOS: sync_repo(repo, ext_dir, tmp_dir) subprocess.run(["git", "add", "extensions"], check=True) if not has_staged_changes(root): print("No bundle changes.") sys.exit(0) subprocess.run(["git", "checkout", "extensions"], check=True) with tempfile.TemporaryDirectory() as tmp: tmp_dir = Path(tmp) for repo in REPOS: sync_repo(repo, ext_dir, tmp_dir) process_repo(repo, ext_dir) subprocess.run(["git", "add", "extensions"], check=True) date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") subprocess.run( ["git", "commit", "-m", f"bundle: update ({date_str})"], check=True, ) if __name__ == "__main__": main()