Files
cc-1c-skills/.agents/skills/meta-remove/scripts/meta-remove.py
T

783 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# meta-remove v1.11 — Remove metadata object from 1C configuration dump
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
import os
import re
import sys
import shutil
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
# ============================================================
def _sg_root_uuid(xml_path):
if not os.path.isfile(xml_path):
return None
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str) and child.get("uuid"):
return child.get("uuid")
except Exception:
return None
return None
def _sg_is_external_root(xml_path):
if not os.path.isfile(xml_path):
return False
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str):
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
except Exception:
return False
return False
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def _sg_get_edit_mode(cfg_dir):
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
if not pj:
return "deny"
proj = json.loads(open(pj, encoding="utf-8-sig").read())
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
for db in proj.get("databases", []):
src = db.get("configSrc")
if src:
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
if db.get("editingAllowedCheck"):
return db["editingAllowedCheck"]
if proj.get("editingAllowedCheck"):
return proj["editingAllowedCheck"]
return "deny"
except Exception:
return "deny"
def assert_edit_allowed(target_path, require):
try:
rp = os.path.abspath(target_path)
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
if _sg_is_external_root(rp):
return
elem_uuid = _sg_root_uuid(rp)
cfg_dir = None
bin_path = None
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
for _ in range(12):
if not d:
break
if _sg_is_external_root(d + ".xml"):
return
if not elem_uuid:
elem_uuid = _sg_root_uuid(d + ".xml")
if not cfg_dir:
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
cfg_dir = d
bin_path = cand
if elem_uuid and cfg_dir:
break
parent = os.path.dirname(d)
if parent == d:
break
d = parent
if not elem_uuid and cfg_dir:
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
if not bin_path or not os.path.exists(bin_path):
return
data = open(bin_path, "rb").read()
if len(data) <= 32:
return
if data[:3] == b"\xef\xbb\xbf":
data = data[3:]
text = data.decode("utf-8", "replace")
h = re.match(r"\{6,(\d+),(\d+),", text)
if not h:
return
g = int(h.group(1))
k = int(h.group(2))
if k == 0:
return
best = None
if elem_uuid:
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
f1 = int(m.group(1))
if best is None or f1 < best:
best = f1
blocked = False
code = ""
reason = ""
if g == 1:
blocked = True
code = "capability-off"
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
elif require == "removed":
if best is not None and best != 2:
blocked = True
code = "not-removed"
reason = "объект не снят с поддержки — удаление сломает обновления"
else:
if best is not None and best == 0:
blocked = True
code = "locked"
reason = "объект на замке — редактирование сломает обновления"
if not blocked:
return
mode = _sg_get_edit_mode(cfg_dir)
if mode == "off":
return
if mode == "warn":
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
return
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
if code == "capability-off":
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
fix = (
"Либо снять защиту явно (навык support-edit, два шага):\n"
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
)
elif code == "not-removed":
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
fix = (
"Либо сначала снять объект с поддержки, затем удалять:\n"
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
)
else:
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
fix = (
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
)
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
sys.exit(1)
except SystemExit:
raise
except Exception:
return
# --- Type -> plural directory mapping ---
TYPE_PLURAL_MAP = {
"Catalog": "Catalogs",
"Document": "Documents",
"Enum": "Enums",
"Constant": "Constants",
"InformationRegister": "InformationRegisters",
"AccumulationRegister": "AccumulationRegisters",
"AccountingRegister": "AccountingRegisters",
"CalculationRegister": "CalculationRegisters",
"ChartOfAccounts": "ChartsOfAccounts",
"ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
"ChartOfCalculationTypes": "ChartsOfCalculationTypes",
"BusinessProcess": "BusinessProcesses",
"Task": "Tasks",
"ExchangePlan": "ExchangePlans",
"DocumentJournal": "DocumentJournals",
"Report": "Reports",
"DataProcessor": "DataProcessors",
"CommonModule": "CommonModules",
"ScheduledJob": "ScheduledJobs",
"EventSubscription": "EventSubscriptions",
"HTTPService": "HTTPServices",
"WebService": "WebServices",
"DefinedType": "DefinedTypes",
"Role": "Roles",
"Subsystem": "Subsystems",
"CommonForm": "CommonForms",
"CommonTemplate": "CommonTemplates",
"CommonPicture": "CommonPictures",
"CommonAttribute": "CommonAttributes",
"SessionParameter": "SessionParameters",
"FunctionalOption": "FunctionalOptions",
"FunctionalOptionsParameter": "FunctionalOptionsParameters",
"Sequence": "Sequences",
"FilterCriterion": "FilterCriteria",
"SettingsStorage": "SettingsStorages",
"XDTOPackage": "XDTOPackages",
"WSReference": "WSReferences",
"StyleItem": "StyleItems",
"Language": "Languages",
}
# Type -> reference type names (used in XML <v8:Type> elements)
TYPE_REF_NAMES = {
"Catalog": ["CatalogRef", "CatalogObject"],
"Document": ["DocumentRef", "DocumentObject"],
"Enum": ["EnumRef"],
"ExchangePlan": ["ExchangePlanRef", "ExchangePlanObject"],
"ChartOfAccounts": ["ChartOfAccountsRef", "ChartOfAccountsObject"],
"ChartOfCharacteristicTypes": ["ChartOfCharacteristicTypesRef", "ChartOfCharacteristicTypesObject"],
"ChartOfCalculationTypes": ["ChartOfCalculationTypesRef", "ChartOfCalculationTypesObject"],
"BusinessProcess": ["BusinessProcessRef", "BusinessProcessObject"],
"Task": ["TaskRef", "TaskObject"],
}
# Type -> Russian manager name (used in BSL code)
TYPE_RU_MANAGER = {
"Catalog": "\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438",
"Document": "\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b",
"Enum": "\u041f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u044f",
"Constant": "\u041a\u043e\u043d\u0441\u0442\u0430\u043d\u0442\u044b",
"InformationRegister": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0421\u0432\u0435\u0434\u0435\u043d\u0438\u0439",
"AccumulationRegister": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u041d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u0438\u044f",
"AccountingRegister": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0411\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0438",
"CalculationRegister": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u044b\u0420\u0430\u0441\u0447\u0435\u0442\u0430",
"ChartOfAccounts": "\u041f\u043b\u0430\u043d\u044b\u0421\u0447\u0435\u0442\u043e\u0432",
"ChartOfCharacteristicTypes": "\u041f\u043b\u0430\u043d\u044b\u0412\u0438\u0434\u043e\u0432\u0425\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a",
"ChartOfCalculationTypes": "\u041f\u043b\u0430\u043d\u044b\u0412\u0438\u0434\u043e\u0432\u0420\u0430\u0441\u0447\u0435\u0442\u0430",
"BusinessProcess": "\u0411\u0438\u0437\u043d\u0435\u0441\u041f\u0440\u043e\u0446\u0435\u0441\u0441\u044b",
"Task": "\u0417\u0430\u0434\u0430\u0447\u0438",
"ExchangePlan": "\u041f\u043b\u0430\u043d\u044b\u041e\u0431\u043c\u0435\u043d\u0430",
"Report": "\u041e\u0442\u0447\u0435\u0442\u044b",
"DataProcessor": "\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438",
"DocumentJournal": "\u0416\u0443\u0440\u043d\u0430\u043b\u044b\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432",
"CommonModule": None,
}
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
V8_NS = "http://v8.1c.ru/8.1/data/core"
NSMAP = {"md": MD_NS, "v8": V8_NS}
def remove_node_with_indent(node):
"""Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать
самозакрывающимся. Копия из form-remove: одна задача — одна реализация."""
parent = node.getparent()
if parent is None:
return
# В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а
# whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с
# узлом, поэтому его надо передать предшественнику.
prev = node.getprevious()
if prev is not None:
prev.tail = node.tail
else:
parent.text = node.tail
parent.remove(node)
if len(parent) == 0 and not (parent.text or "").strip():
parent.text = None
def localname(el):
return etree.QName(el.tag).localname
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 (убирает &#13; от \r в tail'ах)
xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", 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_bom(tree, path):
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 metadata object from 1C configuration dump", allow_abbrev=False)
parser.add_argument("-ConfigDir", required=True)
parser.add_argument("-Object", required=True)
parser.add_argument("-DryRun", action="store_true")
parser.add_argument("-KeepFiles", action="store_true")
parser.add_argument("-Force", action="store_true")
args = ci_parse_args(parser)
config_dir = args.ConfigDir
if not os.path.isabs(config_dir):
config_dir = os.path.join(os.getcwd(), config_dir)
if not os.path.isdir(config_dir):
print(f"[ERROR] Config directory not found: {config_dir}")
sys.exit(1)
config_xml = os.path.join(config_dir, "Configuration.xml")
if not os.path.isfile(config_xml):
print(f"[ERROR] Configuration.xml not found in: {config_dir}")
sys.exit(1)
# --- Parse object spec ---
parts = args.Object.split(".", 1)
if len(parts) != 2 or not parts[0] or not parts[1]:
print(f"[ERROR] Invalid object format '{args.Object}'. Expected: Type.Name (e.g. Catalog.\u0422\u043e\u0432\u0430\u0440\u044b)")
sys.exit(1)
obj_type = parts[0]
obj_name = parts[1]
if obj_type not in TYPE_PLURAL_MAP:
print(f"[ERROR] Unknown type '{obj_type}'. Supported: {', '.join(TYPE_PLURAL_MAP.keys())}")
sys.exit(1)
type_plural = TYPE_PLURAL_MAP[obj_type]
print(f"=== meta-remove: {obj_type}.{obj_name} ===")
print()
if args.DryRun:
print("[DRY-RUN] No changes will be made")
print()
actions = 0
errors = 0
# --- 1. Find object files ---
type_dir = os.path.join(config_dir, type_plural)
obj_xml = os.path.join(type_dir, f"{obj_name}.xml")
obj_dir = os.path.join(type_dir, obj_name)
# Support guard — removal requires the object be снят-с-поддержки (f1=2).
assert_edit_allowed(obj_xml, "removed")
has_xml = os.path.isfile(obj_xml)
has_dir = os.path.isdir(obj_dir)
if not has_xml and not has_dir:
# Check if registered in Configuration.xml before proceeding
cfg_check_tree = etree.parse(config_xml, etree.XMLParser(remove_blank_text=False))
cfg_check_root = cfg_check_tree.getroot()
child_objects = cfg_check_root.find(f"{{{MD_NS}}}Configuration/{{{MD_NS}}}ChildObjects")
registered_in_cfg = False
if child_objects is not None:
for child in child_objects:
if isinstance(child.tag, str) and etree.QName(child.tag).localname == obj_type and (child.text or "").strip() == obj_name:
registered_in_cfg = True
break
if not registered_in_cfg:
print(f"[ERROR] Object not found: {type_plural}/{obj_name}.xml and not registered in Configuration.xml")
sys.exit(1)
print(f"[WARN] Object files not found: {type_plural}/{obj_name}.xml")
print(" Proceeding with deregistration only...")
else:
if has_xml:
print(f"[FOUND] {type_plural}/{obj_name}.xml")
if has_dir:
file_count = sum(len(files) for _, _, files in os.walk(obj_dir))
print(f"[FOUND] {type_plural}/{obj_name}/ ({file_count} files)")
# --- 2. Reference check ---
print()
print("--- Reference check ---")
search_patterns = []
# 1) XML type references
if obj_type in TYPE_REF_NAMES:
for ref_name in TYPE_REF_NAMES[obj_type]:
search_patterns.append(f"{ref_name}.{obj_name}")
# 2) BSL code references
ru_mgr = TYPE_RU_MANAGER.get(obj_type)
if ru_mgr:
search_patterns.append(f"{ru_mgr}.{obj_name}")
search_patterns.append(f"{type_plural}.{obj_name}")
# 3) CommonModule: method calls
if obj_type == "CommonModule":
search_patterns.append(f"{obj_name}.")
# 4) ScheduledJob/EventSubscription handler references
if obj_type == "CommonModule":
search_patterns.append(f"<Handler>{obj_name}.")
search_patterns.append(f"<MethodName>{obj_name}.")
# Exclude object's own files
exclude_dirs = []
if has_dir:
exclude_dirs.append(obj_dir)
exclude_file = obj_xml if has_xml else ""
# Ссылки на формы удаляемого объекта: слоты вида <DefaultListForm>, <ChoiceForm>,
# <SettingsStorage>, элемент начальной страницы. Их, в отличие от типов и вызовов в .bsl,
# можно починить однозначно — пустой слот легален, — поэтому -Force их чистит.
form_slot_re = re.compile(
r"<([A-Za-z0-9_.]+)>(" + re.escape(f"{obj_type}.{obj_name}") + r"\.Form\.[^<]+|"
+ re.escape(f"CommonForm.{obj_name}") + r")</")
form_slot_files = {}
# Search all XML and BSL files
references = []
search_extensions = (".xml", ".bsl")
# Один проход вместо двух: раньше конфигурация обходилась дважды и каждый файл читался
# по два раза. Зеркало EnumerateFiles-прохода в PS.
type_name_ref = f"{obj_type}.{obj_name}"
for root_path, dirs, files in os.walk(config_dir):
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext not in search_extensions:
continue
full_path = os.path.join(root_path, fname)
# Skip own files
if exclude_file and os.path.normcase(full_path) == os.path.normcase(exclude_file):
continue
skip = False
for ed in exclude_dirs:
if os.path.normcase(full_path).startswith(os.path.normcase(ed + os.sep)) or os.path.normcase(full_path) == os.path.normcase(ed):
skip = True
break
if skip:
continue
# Get relative path
rel_path = os.path.relpath(full_path, config_dir)
rel_path_fwd = rel_path.replace("\\", "/")
# Auto-cleaned: ChildObjects в Configuration.xml и состав подсистем. Сам
# Configuration.xml при этом НЕ слепая зона — его form-слоты (DefaultReportForm
# и соседи) не чистятся автоматически и раньше терялись молча.
is_auto_cleaned = (rel_path_fwd in ("Configuration.xml", "ConfigDumpInfo.xml")
or rel_path_fwd.startswith("Subsystems"))
try:
with open(full_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
except Exception:
continue
if ext == ".xml":
slot_matches = list(form_slot_re.finditer(content))
if slot_matches:
form_slot_files[full_path] = rel_path
for m in slot_matches:
references.append({"File": rel_path,
"Pattern": f"<{m.group(1)}>{m.group(2)}"})
if is_auto_cleaned:
continue
# Общие паттерны ищем в тексте БЕЗ form-слотов: «Catalog.Товары» есть внутри
# «Catalog.Товары.Form.X», и файл со слотом попадал бы в список дважды. Вырезаем
# слоты, а не пропускаем файл целиком — иначе настоящая ссылка рядом со слотом
# осталась бы незамеченной, а её, в отличие от слота, автоматически не починить.
content_no_slots = form_slot_re.sub("", content) if full_path in form_slot_files else content
matched = False
for pat in search_patterns:
if pat in content_no_slots:
references.append({"File": rel_path, "Pattern": pat})
matched = True
break
if ext == ".xml" and not matched and type_name_ref in content_no_slots:
references.append({"File": rel_path, "Pattern": type_name_ref})
if references:
print(f"[WARN] Found {len(references)} reference(s) to {obj_type}.{obj_name}:")
print()
shown = 0
for ref in references:
print(f" {ref['File']}")
print(f" pattern: {ref['Pattern']}")
shown += 1
if shown >= 20:
remaining = len(references) - shown
if remaining > 0:
print(f" ... and {remaining} more")
break
print()
if not args.Force:
print(f"[ERROR] Cannot remove: object has {len(references)} reference(s).")
print(" The user decides: fix the references, keep the object, or")
print(" re-run with -Force — form references are cleared.")
sys.exit(1)
else:
print("[WARN] -Force specified, proceeding despite references")
else:
print("[OK] No references found")
# --- 3. Remove from Configuration.xml ChildObjects ---
print()
print("--- Configuration.xml ---")
xml_parser = etree.XMLParser(remove_blank_text=False)
tree = etree.parse(config_xml, xml_parser)
xml_root = tree.getroot()
cfg_node = xml_root.find(f"{{{MD_NS}}}Configuration")
if cfg_node is None:
print("[ERROR] Configuration element not found in Configuration.xml")
errors += 1
else:
child_objects = cfg_node.find(f"{{{MD_NS}}}ChildObjects")
if child_objects is not None:
found = False
for child in list(child_objects):
if not isinstance(child.tag, str):
continue
if localname(child) == obj_type and (child.text or "").strip() == obj_name:
found = True
if not args.DryRun:
# Общий помощник — зеркало DOM-поведения PS. Прежняя ветка теряла
# отступ следующего элемента, если удалялся ПЕРВЫЙ ребёнок.
remove_node_with_indent(child)
print(f"[OK] Removed <{obj_type}>{obj_name}</{obj_type}> from ChildObjects")
actions += 1
break
if not found:
print(f"[WARN] <{obj_type}>{obj_name}</{obj_type}> not found in ChildObjects")
# Save Configuration.xml
if actions > 0 and not args.DryRun:
save_xml_bom(tree, config_xml)
print("[OK] Configuration.xml saved")
# --- 4. Remove from subsystem Content ---
print()
print("--- Subsystems ---")
subsystems_dir = os.path.join(config_dir, "Subsystems")
subsystems_found = 0
subsystems_cleaned = 0
def remove_from_subsystems(dir_path):
nonlocal subsystems_found, subsystems_cleaned
if not os.path.isdir(dir_path):
return
for fname in os.listdir(dir_path):
if not fname.lower().endswith(".xml"):
continue
xml_file = os.path.join(dir_path, fname)
if not os.path.isfile(xml_file):
continue
ss_parser = etree.XMLParser(remove_blank_text=False)
try:
ss_tree = etree.parse(xml_file, ss_parser)
except Exception:
continue
ss_root = ss_tree.getroot()
ss_node = None
for child in ss_root:
if isinstance(child.tag, str) and localname(child) == "Subsystem":
ss_node = child
break
if ss_node is None:
continue
props_node = ss_node.find(f"{{{MD_NS}}}Properties")
if props_node is None:
continue
content_node = props_node.find(f"{{{MD_NS}}}Content")
if content_node is None:
continue
ss_name_node = props_node.find(f"{{{MD_NS}}}Name")
ss_name = ss_name_node.text if ss_name_node is not None and ss_name_node.text else os.path.splitext(fname)[0]
target_ref = f"{obj_type}.{obj_name}"
modified = False
for item in list(content_node):
if not isinstance(item.tag, str):
continue
val = (item.text or "").strip()
if val == target_ref:
subsystems_found += 1
if not args.DryRun:
content_node.remove(item)
modified = True
print(f"[OK] Removed from subsystem '{ss_name}'")
subsystems_cleaned += 1
if modified and not args.DryRun:
save_xml_bom(ss_tree, xml_file)
# Recurse into child subsystems
base_name = os.path.splitext(fname)[0]
child_dir = os.path.join(dir_path, base_name, "Subsystems")
if os.path.isdir(child_dir):
remove_from_subsystems(child_dir)
if os.path.isdir(subsystems_dir):
remove_from_subsystems(subsystems_dir)
if subsystems_cleaned == 0:
print("[OK] Not referenced in any subsystem")
else:
print("[OK] No Subsystems directory")
# --- 4b. Clear form slots pointing at this object's forms ---
# Только слоты форм: пустой слот легален (164 508 пустых на корпус), поэтому замена
# однозначна. Ссылки на типы и вызовы в .bsl не трогаем — чем их заменить, неизвестно.
if form_slot_files:
print()
print("--- Form slots ---")
slot_prefix = f"{obj_type}.{obj_name}.Form."
common_form_ref = f"CommonForm.{obj_name}"
for slot_path in sorted(form_slot_files):
if args.DryRun:
print(f"[DRY-RUN] Would clear form slot(s) in {form_slot_files[slot_path]}")
continue
slot_parser = etree.XMLParser(remove_blank_text=False)
slot_tree = etree.parse(slot_path, slot_parser)
slot_root = slot_tree.getroot()
is_form_file = localname(slot_root) == "Form"
touched = []
for el in list(slot_root.iter()):
if not isinstance(el.tag, str) or len(el) > 0:
continue
val = (el.text or "").strip()
if not val:
continue
# Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает).
if val.lower() != common_form_ref.lower() and not val.lower().startswith(slot_prefix.lower()):
continue
parent = el.getparent()
ln = localname(el)
if ln == "Form" and parent is not None and localname(parent) == "Item":
touched.append(f"{localname(parent)}/{ln}")
remove_node_with_indent(parent)
elif is_form_file:
# Внутри Ext/Form.xml пустых <ChoiceForm/> и <SettingsStorage/> нет ни
# одного — каноничное «не задано» там это отсутствие тега.
touched.append(ln)
remove_node_with_indent(el)
else:
# text=None, а не "": Конфигуратор пустых пар не пишет.
touched.append(ln)
el.text = None
if not touched:
continue
save_xml_bom(slot_tree, slot_path)
print(f"[OK] Cleared in {form_slot_files[slot_path]}: {', '.join(sorted(set(touched)))}")
# --- 5. Delete object files ---
print()
print("--- Files ---")
if not args.KeepFiles:
if has_dir and not args.DryRun:
shutil.rmtree(obj_dir)
print(f"[OK] Deleted directory: {type_plural}/{obj_name}/")
actions += 1
elif has_dir:
print(f"[DRY] Would delete directory: {type_plural}/{obj_name}/")
actions += 1
if has_xml and not args.DryRun:
os.remove(obj_xml)
print(f"[OK] Deleted file: {type_plural}/{obj_name}.xml")
actions += 1
elif has_xml:
print(f"[DRY] Would delete file: {type_plural}/{obj_name}.xml")
actions += 1
if not has_xml and not has_dir:
print("[OK] No files to delete")
else:
print("[SKIP] File deletion skipped (-KeepFiles)")
# --- Summary ---
print()
total_actions = actions + subsystems_cleaned
if args.DryRun:
print(f"=== Dry run complete: {total_actions} actions would be performed ===")
else:
print(f"=== Done: {total_actions} actions performed ({subsystems_cleaned} subsystem references removed) ===")
if errors > 0:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()