Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
#!/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<close>/)?(?P<tag>[A-Za-z_:][\w:.-]*)(?P<attrs>[^<>]*?)(?P<self>/)?>", 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<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_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}<Button name="{xml_attr(name)}" id="{xml_attr(button_id)}">']
|
||||
lines.append(f"{indent}\t<Type>{xml_text(button_type)}</Type>")
|
||||
lines.append(f"{indent}\t<CommandName>{xml_text(normalized_command)}</CommandName>")
|
||||
lines.extend(render_localized("Title", title, indent=indent + "\t"))
|
||||
lines.append(f'{indent}\t<ExtendedTooltip name="{xml_attr(name)}РасширеннаяПодсказка" id="{xml_attr(str(int(button_id) + 1))}"/>')
|
||||
lines.append(f"{indent}</Button>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def button_block_pattern(name: str) -> re.Pattern[str]:
|
||||
escaped = re.escape(name)
|
||||
return re.compile(rf"(?P<indent>^[ \t]*)<Button\b(?=[^>]*\bname=\"{escaped}\")[\s\S]*?</Button>[ \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}<ChildItems>\n"
|
||||
f"{render_reindented(block, button_indent)}\n"
|
||||
f"{child_indent}</ChildItems>\n"
|
||||
f"{parent_indent}</{tag}>"
|
||||
)
|
||||
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}<ChildItems>\n{render_reindented(block, button_indent)}\n{child_indent}</ChildItems>\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())
|
||||
Reference in New Issue
Block a user