mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-08 20:43:21 +03:00
Из ~46 навыков-эмиттеров XML восемь не имели ни одной проверки `preserves`: form-edit, form-remove, meta-remove, skd-edit, template-remove, xdto-edit, support-edit, cfe-patch-method. Шесть из них правились в предыдущем коммите, и канон у них держался на том, что правка была механической, а не на проверке. Ожидания писались ПО КАНОНУ, а не по текущему поведению — и это сразу вскрыло три дефекта: 1. form-remove очищал слот формы через InnerText="" / text="" — получалась пустая пара <DefaultObjectForm></DefaultObjectForm>. Платформа пустых пар не пишет (0 на 476 942 XML). Теперь IsEmpty / text=None. 2. Опустевший <ChildObjects> оставался парой, разнесённой по строкам, — в PS-порту form-remove и template-remove. Корпус: 1394 самозакрывающихся <ChildObjects/> на acc+erp, пустых пар 0 в обеих формах. 3. template-add писал пустой макет парой <SpreadsheetDocument></...>. Во всей выгрузке acc_8.3.27 (65 040 XML) многострочных пустых пар нет ни для одного тега. Заодно усилена сама проверка noEmptyPairs: она ловила только СМЕЖНЫЕ теги, поэтому дефект №2 проходил мимо неё. Добавлен вариант с переводом строки внутри; дискриминатором служит сам перевод строки — значащий пробельный текст-узел (<xr:FillValue xsi:type="xs:string"> </xr:FillValue>) его не содержит и под проверку не попадает. support-edit покрыт частично (BOM у ParentConfigurations.bin — проверено, что платформа пишет его с BOM во всех трёх выгрузках), cfe-patch-method — BOM+EOL у .bsl: хвостовой перевод строки у модулей неканоничен (1235 с ним, 766 без), поэтому не утверждается. Регресс: 647/647 ps1, 644/647 py (3 skipped). Эталоны переснятые: три места, каждое — ровно ожидаемая пара строк. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
149 lines
6.1 KiB
Python
149 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
||
# form-remove v1.8 — Remove form from 1C object
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import shutil
|
||
import sys
|
||
|
||
from lxml import etree
|
||
|
||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||
|
||
|
||
def _detect_xml_style(path):
|
||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||
try:
|
||
raw = open(path, "rb").read()
|
||
except OSError:
|
||
return None
|
||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||
body = raw[3:] if bom else raw
|
||
crlf = b"\r\n" in body
|
||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||
final_nl = body.endswith(b"\n")
|
||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||
|
||
|
||
def _finalize_xml_bytes(xml_bytes, style):
|
||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||
enc_decl = style["enc"] if style else "UTF-8"
|
||
xml_bytes = xml_bytes.replace(
|
||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||
want_final_nl = style["final_nl"] if style else False
|
||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||
if want_final_nl:
|
||
xml_bytes += b"\n"
|
||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||
if (style["crlf"] if style else True):
|
||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||
return xml_bytes
|
||
|
||
|
||
def save_xml_with_bom(tree, path):
|
||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||
style = _detect_xml_style(path)
|
||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||
with open(path, "wb") as f:
|
||
if style is None or style["bom"]:
|
||
f.write(b"\xef\xbb\xbf")
|
||
f.write(xml_bytes)
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
parser = argparse.ArgumentParser(description="Remove form from 1C object", allow_abbrev=False)
|
||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||
parser.add_argument("-FormName", required=True)
|
||
parser.add_argument("-SrcDir", default="src")
|
||
args = parser.parse_args()
|
||
|
||
object_name = args.ObjectName
|
||
form_name = args.FormName
|
||
src_dir = args.SrcDir
|
||
|
||
# --- Checks ---
|
||
|
||
root_xml_path = os.path.join(src_dir, f"{object_name}.xml")
|
||
if not os.path.exists(root_xml_path):
|
||
print(f"Корневой файл обработки не найден: {root_xml_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
processor_dir = os.path.join(src_dir, object_name)
|
||
forms_dir = os.path.join(processor_dir, "Forms")
|
||
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
|
||
form_dir = os.path.join(forms_dir, form_name)
|
||
|
||
if not os.path.exists(form_meta_path):
|
||
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
# --- Delete files ---
|
||
|
||
if os.path.isdir(form_dir):
|
||
shutil.rmtree(form_dir)
|
||
print(f"[OK] Удалён каталог: {form_dir}")
|
||
|
||
os.remove(form_meta_path)
|
||
print(f"[OK] Удалён файл: {form_meta_path}")
|
||
|
||
# --- Modify root XML ---
|
||
|
||
root_xml_full = os.path.abspath(root_xml_path)
|
||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||
tree = etree.parse(root_xml_full, parser_xml)
|
||
root = tree.getroot()
|
||
|
||
# Remove <Form>FormName</Form> from ChildObjects
|
||
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
|
||
if node.text and node.text.strip() == form_name:
|
||
parent = node.getparent()
|
||
prev = node.getprevious()
|
||
if prev is not None:
|
||
# Whitespace is in prev.tail
|
||
if prev.tail and prev.tail.strip() == "":
|
||
prev.tail = ""
|
||
else:
|
||
# First child — whitespace is in parent.text
|
||
if parent.text and parent.text.strip() == "":
|
||
parent.text = ""
|
||
parent.remove(node)
|
||
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||
if len(parent) == 0 and not (parent.text or "").strip():
|
||
parent.text = None
|
||
break
|
||
|
||
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
||
# (form-add writes the purpose-specific property: DefaultObjectForm / DefaultListForm /
|
||
# DefaultChoiceForm / DefaultRecordForm / DefaultForm — not just generic DefaultForm).
|
||
ref_re = re.compile(rf"Form\.{re.escape(form_name)}$")
|
||
for el in root.iter():
|
||
if not isinstance(el.tag, str):
|
||
continue
|
||
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
|
||
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
|
||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||
el.text = None
|
||
|
||
# Save with BOM
|
||
save_xml_with_bom(tree, root_xml_full)
|
||
|
||
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|