313 lines
12 KiB
Python
313 lines
12 KiB
Python
#!/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<v8:item>",
|
|
f"{indent}\t\t<v8:lang>ru</v8:lang>",
|
|
f"{indent}\t\t<v8:content>{xml_text(value)}</v8:content>",
|
|
f"{indent}\t</v8:item>",
|
|
f"{indent}</{tag}>",
|
|
]
|
|
|
|
|
|
def render_command(*, name: str, command_id: str, title: str, tooltip: str, action: str, call_type: str, indent: str = "\t") -> str:
|
|
lines = [f'{indent}<Command name="{xml_attr(name)}" id="{xml_attr(command_id)}">']
|
|
lines.extend(render_localized("Title", title, indent=indent + "\t"))
|
|
lines.extend(render_localized("ToolTip", tooltip, indent=indent + "\t"))
|
|
lines.append(f'{indent}\t<Action callType="{xml_attr(call_type)}">{xml_text(action)}</Action>')
|
|
lines.append(f"{indent}</Command>")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def command_block_pattern(name: str) -> re.Pattern[str]:
|
|
escaped = re.escape(name)
|
|
return re.compile(rf"(?P<indent>^[ \t]*)<Command\b(?=[^>]*\bname=\"{escaped}\")[\s\S]*?</Command>[ \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]*</Commands>[ \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]*<Commands\s*/>[ \t]*$", text, re.MULTILINE)
|
|
if self_closing:
|
|
indent = re.match(r"^[ \t]*", self_closing.group(0)).group(0)
|
|
commands_block = f"{indent}<Commands>\n{block.rstrip()}\n{indent}</Commands>"
|
|
return text[: self_closing.start()] + commands_block + text[self_closing.end() :], "appended"
|
|
|
|
raise SystemExit("Could not locate <Commands> 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())
|