#!/usr/bin/env python3 """Safely change CommandName for an existing 1C managed form button.""" 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 edit_1c_form_button import ( attr_value, ensure_inside_working, is_void_or_self, load_json, local_name, manifest_record, normalize_command_name, read_text, render_reindented, safe_relative, tag_local, write_json, xml_text, TAG_RE, ) from validate_1c_patch_workspace_semantics import child_text, validate_workspace def form_command_exists(path: Path, command_name: str) -> bool: normalized = normalize_command_name(command_name) if normalized.startswith("Form.StandardCommand."): return True local_command_name = normalized.removeprefix("Form.Command.") root = ET.parse(path).getroot() return any(local_name(element.tag) == "Command" and element.attrib.get("name") == local_command_name for element in root.iter()) def button_state(path: Path, button_name: str) -> dict[str, Any] | None: root = ET.parse(path).getroot() for element in root.iter(): if local_name(element.tag) == "Button" and element.attrib.get("name") == button_name: return {"name": button_name, "id": element.attrib.get("id"), "command_name": child_text(element, "CommandName")} return None def find_button_span(text: str, button_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 tag_local(match.group("tag")) != "Button": continue if attr_value(match.group("attrs") or "", "name") != button_name: continue stack = ["Button"] 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: line_start = text.rfind("\n", 0, match.start()) + 1 indent = re.match(r"^[ \t]*", text[line_start : match.start()]).group(0) return match.start(), next_match.end(), match.end(), next_match.start(), indent continue if not is_void_or_self(next_match): stack.append(next_tag) raise SystemExit(f"Could not find closing for: {button_name}") raise SystemExit(f"Form button not found by name: {button_name}") def replace_or_insert_command_name(text: str, *, button_name: str, command_name: str) -> tuple[str, str, str | None]: _button_start, _button_end, start_tag_end, end_tag_start, button_indent = find_button_span(text, button_name) body = text[start_tag_end:end_tag_start] normalized = normalize_command_name(command_name) command_re = re.compile(r"(?P^[ \t]*)]*>(?P[\s\S]*?)[ \t]*(?:\r?\n)?", re.MULTILINE) match = command_re.search(body) if match: old = html.unescape(match.group("value").strip()) replacement = f"{match.group('indent')}{xml_text(normalized)}\n" updated_body = body[: match.start()] + replacement + body[match.end() :] return text[:start_tag_end] + updated_body + text[end_tag_start:], "replaced", old insert = render_reindented(f"{xml_text(normalized)}", button_indent + "\t") + "\n" return text[:start_tag_end] + "\n" + insert + text[start_tag_end:], "inserted", None def validate_form_xml(path: Path, before: str, *, keep_on_failure: bool) -> tuple[bool, bool, list[dict[str, Any]]]: findings: list[dict[str, Any]] = [] rolled_back = False try: ET.parse(path) return True, False, findings except ET.ParseError as exc: 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 False, rolled_back, findings def edit_form_file( path: Path, *, button_name: str, command_name: str, keep_on_failure: bool, ) -> dict[str, Any]: form_path = path.resolve() if not form_path.exists(): raise SystemExit(f"Form.xml does not exist: {form_path}") if not form_command_exists(form_path, command_name): raise SystemExit(f"Form command does not exist: {command_name}") state = button_state(form_path, button_name) if not state: raise SystemExit(f"Form button does not exist: {button_name}") before = read_text(form_path) updated, status, old = replace_or_insert_command_name(before, button_name=button_name, command_name=command_name) form_path.write_text(updated, encoding="utf-8") passed, rolled_back, findings = validate_form_xml(form_path, before, keep_on_failure=keep_on_failure) return { "schema": "onec_form_button_command_edit.v1", "path": str(form_path), "edit": { "status": status, "button": { "name": button_name, "id": state.get("id"), "old_command_name": old if old is not None else state.get("command_name"), "command_name": normalize_command_name(command_name), }, }, "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 edit_workspace( workspace: Path, relative_path: str, *, button_name: str, command_name: 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)) before = read_text(path) result = edit_form_file(path, button_name=button_name, command_name=command_name, keep_on_failure=True) 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 result.update( { "workspace": str(workspace), "relative_path": str(record.get("relative_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, } ) return result def main() -> int: parser = argparse.ArgumentParser(description="Change CommandName for an existing button in 1C managed 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.") parser.add_argument("--button-name", "--name", dest="button_name", required=True) parser.add_argument("--command-name", required=True) parser.add_argument("--keep-on-failure", action="store_true") parser.add_argument("--output", type=Path) args = parser.parse_args() if args.form_path: result = edit_form_file( args.form_path, button_name=args.button_name, command_name=args.command_name, 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, safe_relative(args.relative_path), button_name=args.button_name, command_name=args.command_name, 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"]}, ensure_ascii=False)) return 0 if result["passed"] else 2 if __name__ == "__main__": raise SystemExit(main())