Files
llm/scripts/edit_1c_form_button_command.py
T

216 lines
8.5 KiB
Python

#!/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 </Button> 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<indent>^[ \t]*)<CommandName\b[^>]*>(?P<value>[\s\S]*?)</CommandName>[ \t]*(?:\r?\n)?", re.MULTILINE)
match = command_re.search(body)
if match:
old = html.unescape(match.group("value").strip())
replacement = f"{match.group('indent')}<CommandName>{xml_text(normalized)}</CommandName>\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"<CommandName>{xml_text(normalized)}</CommandName>", 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())