#!/usr/bin/env python3 """Safely append or replace one form command 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 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 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 iter_commands(root: ET.Element) -> list[ET.Element]: for child in root: if local_name(child.tag) == "Commands": return [item for item in child if local_name(item.tag) == "Command"] return [] def command_info(path: Path, name: str) -> tuple[dict[str, Any] | None, int]: root = ET.parse(path).getroot() max_id = 0 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)) for command in iter_commands(root): if command.attrib.get("name") == name: found = { "name": name, "id": command.attrib.get("id"), "action": child_text(command, "Action"), } break return found, max_id 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 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_command(*, name: str, command_id: str, title: str, tooltip: str, action: str, call_type: str, indent: str = "\t") -> str: lines = [f'{indent}'] lines.extend(render_localized("Title", title, indent=indent + "\t")) lines.extend(render_localized("ToolTip", tooltip, indent=indent + "\t")) lines.append(f'{indent}\t{xml_text(action)}') lines.append(f"{indent}") return "\n".join(lines) def command_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_command(text: str, *, name: str, block: str, operation: str) -> tuple[str, str]: pattern = command_block_pattern(name) match = pattern.search(text) exists = match is not None if operation == "append" and exists: raise SystemExit(f"Form command already exists, append refused: {name}") if operation == "replace" and not exists: raise SystemExit(f"Form command does not exist, replace refused: {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" closing = re.search(r"^[ \t]*[ \t]*$", text, re.MULTILINE) if closing: insert = block.rstrip() + "\n" return text[: closing.start()] + insert + text[closing.start() :], "appended" self_closing = re.search(r"^[ \t]*[ \t]*$", text, re.MULTILINE) if self_closing: indent = re.match(r"^[ \t]*", self_closing.group(0)).group(0) commands_block = f"{indent}\n{block.rstrip()}\n{indent}" return text[: self_closing.start()] + commands_block + text[self_closing.end() :], "appended" raise SystemExit("Could not locate container in Form.xml.") def edit_workspace( workspace: Path, relative_path: str, *, name: str, title: str, action: str, tooltip: str | None, command_id: str | None, call_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)) existing, max_id = command_info(path, name) selected_id = command_id or (existing or {}).get("id") or str(max(max_id + 1, 1000000)) before = read_text(path) block = render_command(name=name, command_id=selected_id, title=title, tooltip=tooltip or title, action=action, call_type=call_type) updated, status = insert_or_replace_command(before, 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_command_edit.v1", "workspace": str(workspace), "relative_path": str(record.get("relative_path")), "operation": operation, "edit": { "status": status, "command": { "name": name, "id": selected_id, "title": title, "tooltip": tooltip or title, "action": action, "call_type": call_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, *, name: str, title: str, action: str, tooltip: str | None, command_id: str | None, call_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}") existing, max_id = command_info(path, name) selected_id = command_id or (existing or {}).get("id") or str(max(max_id + 1, 1000000)) before = read_text(path) block = render_command(name=name, command_id=selected_id, title=title, tooltip=tooltip or title, action=action, call_type=call_type) updated, status = insert_or_replace_command(before, 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_command_edit.v1", "path": str(path), "operation": operation, "edit": { "status": status, "command": { "name": name, "id": selected_id, "title": title, "tooltip": tooltip or title, "action": action, "call_type": call_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 command 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("--name", required=True) parser.add_argument("--title", required=True) parser.add_argument("--action", required=True) parser.add_argument("--tooltip") parser.add_argument("--id") parser.add_argument("--call-type", default="Override") 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, name=args.name, title=args.title, action=args.action, tooltip=args.tooltip, command_id=args.id, call_type=args.call_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, name=args.name, title=args.title, action=args.action, tooltip=args.tooltip, command_id=args.id, call_type=args.call_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())