#!/usr/bin/env python3 """Safely append or replace one form button in a 1C patch workspace Form.xml.""" from __future__ import annotations import argparse import html import json import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Any from validate_1c_patch_workspace_semantics import child_text, local_name, validate_workspace TAG_RE = re.compile(r"<(?P/)?(?P[A-Za-z_:][\w:.-]*)(?P[^<>]*?)(?P/)?>", re.DOTALL) def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8-sig")) def write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def read_text(path: Path) -> str: try: return path.read_text(encoding="utf-8-sig") except UnicodeDecodeError: return path.read_text(encoding="cp1251", errors="replace") def xml_text(value: str) -> str: return html.escape(value, quote=False) def xml_attr(value: str) -> str: return html.escape(value, quote=True) def safe_relative(relative_path: str) -> str: normalized = relative_path.replace("\\", "/") path = Path(normalized) if path.is_absolute() or ".." in path.parts or not normalized: raise SystemExit(f"Unsafe relative path: {relative_path}") return normalized def manifest_record(workspace: Path, relative_path: str) -> dict[str, Any]: manifest = load_json(workspace / "manifest.json") wanted = safe_relative(relative_path) for record in manifest.get("files") or []: record_relative = str(record.get("relative_path") or "").replace("\\", "/") if record_relative == wanted: if record.get("kind") != "form_xml": raise SystemExit(f"Manifest target is not Form.xml: {relative_path}") return record raise SystemExit(f"Form.xml is not present in workspace manifest: {relative_path}") def ensure_inside_working(workspace: Path, relative_path: str) -> Path: working_root = (workspace / "working").resolve() path = (working_root / Path(safe_relative(relative_path))).resolve() if not str(path).casefold().startswith(str(working_root).casefold()): raise SystemExit(f"Refusing path outside working/: {path}") if not path.exists(): raise SystemExit(f"Working Form.xml does not exist: {path}") return path def attr_value(attrs: str, name: str) -> str | None: match = re.search(rf"\b{re.escape(name)}\s*=\s*(['\"])(.*?)\1", attrs, re.DOTALL) return html.unescape(match.group(2)) if match else None def tag_local(tag: str) -> str: return tag.rsplit(":", 1)[-1] def is_void_or_self(match: re.Match[str]) -> bool: return bool(match.group("self")) or match.group(0).rstrip().endswith("/>") def find_named_element_span(text: str, name: str) -> tuple[int, int, int, int, str]: for match in TAG_RE.finditer(text): if match.group("close") or is_void_or_self(match): continue if attr_value(match.group("attrs") or "", "name") != name: continue start_tag = match.group("tag") stack = [tag_local(start_tag)] for next_match in TAG_RE.finditer(text, match.end()): next_tag = tag_local(next_match.group("tag")) if next_match.group("close"): if stack and stack[-1] == next_tag: stack.pop() if not stack: return match.start(), next_match.end(), match.end(), next_match.start(), re.match(r"^[ \t]*", text[match.start() :].splitlines()[0]).group(0) continue if not is_void_or_self(next_match): stack.append(next_tag) raise SystemExit(f"Could not find closing tag for form element: {name}") raise SystemExit(f"Parent form element not found by name: {name}") def find_named_self_closing_element(text: str, name: str) -> tuple[int, int, str, str] | None: for match in TAG_RE.finditer(text): if match.group("close") or not is_void_or_self(match): continue if attr_value(match.group("attrs") or "", "name") != name: continue line_start = text.rfind("\n", 0, match.start()) + 1 indent = re.match(r"^[ \t]*", text[line_start : match.start()]).group(0) return match.start(), match.end(), match.group("tag"), indent return None def find_direct_childitems(text: str, element_start_tag_end: int, element_end_tag_start: int) -> tuple[int, int, int, str] | None: depth = 1 for match in TAG_RE.finditer(text, element_start_tag_end, element_end_tag_start): tag = tag_local(match.group("tag")) if match.group("close"): if tag == "ChildItems" and depth == 2: line_start = text.rfind("\n", 0, match.start()) + 1 indent = re.match(r"^[ \t]*", text[line_start : match.start()]).group(0) return line_start, match.start(), match.end(), indent depth -= 1 continue if tag == "ChildItems" and depth == 1 and not is_void_or_self(match): depth += 1 continue if not is_void_or_self(match): depth += 1 return None def form_state(path: Path, command_name: str, button_name: str) -> dict[str, Any]: root = ET.parse(path).getroot() max_id = 0 normalized_command = normalize_command_name(command_name) command_found = normalized_command.startswith("Form.StandardCommand.") local_command_name = normalized_command.removeprefix("Form.Command.") if normalized_command.startswith("Form.Command.") else normalized_command button_found = None for element in root.iter(): raw_id = element.attrib.get("id") if raw_id and raw_id.lstrip("-").isdigit(): max_id = max(max_id, int(raw_id)) if local_name(element.tag) == "Command" and element.attrib.get("name") == local_command_name: command_found = True if local_name(element.tag) == "Button" and element.attrib.get("name") == button_name: button_found = { "name": button_name, "id": element.attrib.get("id"), "command_name": child_text(element, "CommandName"), } return {"max_id": max_id, "command_found": command_found, "button": button_found} def normalize_command_name(command_name: str) -> str: value = str(command_name or "").strip() if value.startswith("Form.Command.") or value.startswith("Form.StandardCommand."): return value return f"Form.Command.{value}" def render_localized(tag: str, value: str, *, indent: str) -> list[str]: return [ f"{indent}<{tag}>", f"{indent}\t", f"{indent}\t\tru", f"{indent}\t\t{xml_text(value)}", f"{indent}\t", f"{indent}", ] def render_button(*, name: str, button_id: str, title: str, command_name: str, button_type: str, indent: str) -> str: normalized_command = normalize_command_name(command_name) lines = [f'{indent}") return "\n".join(lines) def button_block_pattern(name: str) -> re.Pattern[str]: escaped = re.escape(name) return re.compile(rf"(?P^[ \t]*)]*\bname=\"{escaped}\")[\s\S]*?[ \t]*(?:\r?\n)?", re.MULTILINE) def insert_or_replace_button(text: str, *, parent_name: str, button_name: str, block: str, operation: str) -> tuple[str, str]: pattern = button_block_pattern(button_name) match = pattern.search(text) exists = match is not None if operation == "append" and exists: raise SystemExit(f"Form button already exists, append refused: {button_name}") if operation == "replace" and not exists: raise SystemExit(f"Form button does not exist, replace refused: {button_name}") if exists and match: indent = match.group("indent") or "\t" replacement = "\n".join((indent + line.lstrip("\t")) if line.strip() else line for line in block.splitlines()) + "\n" return text[: match.start()] + replacement + text[match.end() :], "replaced" self_closing = find_named_self_closing_element(text, parent_name) if self_closing: start, end, tag, parent_indent = self_closing child_indent = parent_indent + "\t" button_indent = parent_indent + "\t\t" open_tag = text[start:end].rstrip() if open_tag.endswith("/>"): open_tag = open_tag[:-2].rstrip() + ">" elif open_tag.endswith(">"): open_tag = open_tag[:-1].rstrip() + ">" replacement = ( f"{open_tag}\n" f"{child_indent}\n" f"{render_reindented(block, button_indent)}\n" f"{child_indent}\n" f"{parent_indent}" ) return text[:start] + replacement + text[end:], "appended" _start, _end, start_tag_end, end_tag_start, parent_indent = find_named_element_span(text, parent_name) childitems = find_direct_childitems(text, start_tag_end, end_tag_start) if childitems: _line_start, closing_start, _closing_end, child_indent = childitems insert = render_reindented(block, child_indent + "\t") + "\n" return text[:closing_start] + insert + text[closing_start:], "appended" child_indent = parent_indent + "\t" button_indent = parent_indent + "\t\t" childitems_block = f"{child_indent}\n{render_reindented(block, button_indent)}\n{child_indent}\n" return text[:end_tag_start] + childitems_block + text[end_tag_start:], "appended" def render_reindented(block: str, indent: str) -> str: return "\n".join((indent + line.lstrip("\t")) if line.strip() else line for line in block.splitlines()) def edit_workspace( workspace: Path, relative_path: str, *, parent_name: str, name: str, title: str, command_name: str, button_id: str | None, button_type: str, operation: str, keep_on_failure: bool, ) -> dict[str, Any]: record = manifest_record(workspace, relative_path) path = ensure_inside_working(workspace, str(record.get("relative_path") or relative_path)) state = form_state(path, command_name, name) if not state["command_found"]: raise SystemExit(f"Form command does not exist: {command_name}") selected_id = button_id or (state["button"] or {}).get("id") or str(max(int(state["max_id"]) + 1, 1000000)) before = read_text(path) block = render_button(name=name, button_id=selected_id, title=title, command_name=command_name, button_type=button_type, indent="\t") updated, status = insert_or_replace_button(before, parent_name=parent_name, button_name=name, block=block, operation=operation) path.write_text(updated, encoding="utf-8") semantic = validate_workspace(workspace) rolled_back = False if not semantic.get("passed") and not keep_on_failure: path.write_text(before, encoding="utf-8") rolled_back = True return { "schema": "onec_form_button_edit.v1", "workspace": str(workspace), "relative_path": str(record.get("relative_path")), "operation": operation, "edit": { "status": status, "button": { "name": name, "id": selected_id, "title": title, "command_name": normalize_command_name(command_name), "parent_name": parent_name, "type": button_type, }, "path": str(path), }, "rolled_back": rolled_back, "semantic_validation": { "schema": semantic.get("schema"), "passed": semantic.get("passed"), "counts": semantic.get("counts"), "findings": semantic.get("findings"), }, "passed": bool(semantic.get("passed")) and not rolled_back, } def edit_form_path( form_path: Path, *, parent_name: str, name: str, title: str, command_name: str, button_id: str | None, button_type: str, operation: str, keep_on_failure: bool, ) -> dict[str, Any]: path = form_path.resolve() if not path.exists(): raise SystemExit(f"Form.xml does not exist: {path}") state = form_state(path, command_name, name) if not state["command_found"]: raise SystemExit(f"Form command does not exist: {command_name}") selected_id = button_id or (state["button"] or {}).get("id") or str(max(int(state["max_id"]) + 1, 1000000)) before = read_text(path) block = render_button(name=name, button_id=selected_id, title=title, command_name=command_name, button_type=button_type, indent="\t") updated, status = insert_or_replace_button(before, parent_name=parent_name, button_name=name, block=block, operation=operation) path.write_text(updated, encoding="utf-8") rolled_back = False findings: list[dict[str, Any]] = [] try: ET.parse(path) passed = True except ET.ParseError as exc: passed = False findings.append({"severity": "error", "message": str(exc), "path": str(path)}) if not keep_on_failure: path.write_text(before, encoding="utf-8") rolled_back = True return { "schema": "onec_form_button_edit.v1", "path": str(path), "operation": operation, "edit": { "status": status, "button": { "name": name, "id": selected_id, "title": title, "command_name": normalize_command_name(command_name), "parent_name": parent_name, "type": button_type, }, "path": str(path), }, "rolled_back": rolled_back, "semantic_validation": { "schema": "onec_form_xml_direct_validation.v1", "passed": passed, "counts": {"findings": len(findings)}, "findings": findings, }, "passed": passed and not rolled_back, } def main() -> int: parser = argparse.ArgumentParser(description="Append/replace one form button in a 1C patch workspace Form.xml.") parser.add_argument("--workspace", type=Path) parser.add_argument("--relative-path") parser.add_argument("--form-path", type=Path, help="Direct Form.xml path for extension-export XML editing without a patch workspace.") parser.add_argument("--operation", choices=["append", "replace", "upsert"], default="upsert") parser.add_argument("--parent-name", required=True) parser.add_argument("--name", required=True) parser.add_argument("--title", required=True) parser.add_argument("--command-name", required=True) parser.add_argument("--id") parser.add_argument("--type", default="CommandBarButton") parser.add_argument("--keep-on-failure", action="store_true", help="Keep the edit even when semantic validation fails.") parser.add_argument("--output", type=Path) args = parser.parse_args() if args.form_path: result = edit_form_path( args.form_path, parent_name=args.parent_name, name=args.name, title=args.title, command_name=args.command_name, button_id=args.id, button_type=args.type, operation=args.operation, keep_on_failure=args.keep_on_failure, ) else: if not args.workspace or not args.relative_path: parser.error("either --form-path or both --workspace and --relative-path are required") result = edit_workspace( args.workspace, args.relative_path, parent_name=args.parent_name, name=args.name, title=args.title, command_name=args.command_name, button_id=args.id, button_type=args.type, operation=args.operation, keep_on_failure=args.keep_on_failure, ) if args.output: write_json(args.output, result) print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "rolled_back": result["rolled_back"], "edit": result["edit"], "semantic": result["semantic_validation"]["counts"]}, ensure_ascii=False)) return 0 if result["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())