mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-15 07:15:17 +03:00
3180 lines
132 KiB
Python
3180 lines
132 KiB
Python
#!/usr/bin/env python3
|
||
# meta-edit v1.19 — Edit existing 1C metadata object XML (+add-predefined предопределённые Ext/Predefined.xml)
|
||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import uuid
|
||
from lxml import etree
|
||
|
||
|
||
# ============================================================
|
||
# 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_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)
|
||
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 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
|
||
|
||
|
||
# ============================================================
|
||
# Namespaces
|
||
# ============================================================
|
||
|
||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||
CFG_NS = "http://v8.1c.ru/8.1/data/enterprise/current-config"
|
||
|
||
NSMAP_WRAPPER = {
|
||
None: MD_NS,
|
||
"xsi": XSI_NS,
|
||
"v8": V8_NS,
|
||
"xr": XR_NS,
|
||
"cfg": CFG_NS,
|
||
"xs": XS_NS,
|
||
}
|
||
|
||
# ============================================================
|
||
# Global state
|
||
# ============================================================
|
||
|
||
xml_tree = None # etree._ElementTree
|
||
xml_root = None # root <MetaDataObject>
|
||
obj_element = None # the object type element (e.g. <Catalog>)
|
||
obj_type = ""
|
||
md_ns = ""
|
||
properties_el = None
|
||
child_objects_el = None
|
||
obj_name = ""
|
||
resolved_path = ""
|
||
|
||
add_count = 0
|
||
remove_count = 0
|
||
modify_count = 0
|
||
warn_count = 0
|
||
|
||
# ============================================================
|
||
# Utilities
|
||
# ============================================================
|
||
|
||
|
||
def info(msg):
|
||
print(f"[INFO] {msg}")
|
||
|
||
|
||
def warn(msg):
|
||
global warn_count
|
||
print(f"[WARN] {msg}")
|
||
warn_count += 1
|
||
|
||
|
||
def die(msg):
|
||
print(msg, file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
def localname(el):
|
||
return etree.QName(el.tag).localname
|
||
|
||
|
||
def esc_xml(s):
|
||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||
|
||
|
||
# ============================================================
|
||
# Enum value normalization (same as meta-compile)
|
||
# ============================================================
|
||
|
||
enum_value_aliases = {
|
||
# RegisterType (AccumulationRegister)
|
||
'Balances': 'Balance', 'Остатки': 'Balance', 'Обороты': 'Turnovers',
|
||
# WriteMode (InformationRegister)
|
||
'RecordSubordinate': 'RecorderSubordinate', 'Subordinate': 'RecorderSubordinate',
|
||
'ПодчинениеРегистратору': 'RecorderSubordinate', 'Независимый': 'Independent',
|
||
# DependenceOnCalculationTypes (ChartOfCalculationTypes)
|
||
'NotDependOnCalculationTypes': 'DontUse', 'NoDependence': 'DontUse', 'NotUsed': 'DontUse',
|
||
'Depend': 'OnActionPeriod', 'ПоПериодуДействия': 'OnActionPeriod',
|
||
# InformationRegisterPeriodicity
|
||
'None': 'Nonperiodical', 'Daily': 'Day', 'Monthly': 'Month',
|
||
'Quarterly': 'Quarter', 'Yearly': 'Year',
|
||
'Непериодический': 'Nonperiodical', 'Секунда': 'Second', 'День': 'Day',
|
||
'Месяц': 'Month', 'Квартал': 'Quarter', 'Год': 'Year',
|
||
'ПозицияРегистратора': 'RecorderPosition',
|
||
# DataLockControlMode
|
||
'Автоматический': 'Automatic', 'Управляемый': 'Managed',
|
||
# FullTextSearch
|
||
'Использовать': 'Use', 'НеИспользовать': 'DontUse',
|
||
# Posting
|
||
'Разрешить': 'Allow', 'Запретить': 'Deny',
|
||
# EditType
|
||
'ВДиалоге': 'InDialog', 'ВСписке': 'InList', 'ОбаСпособа': 'BothWays',
|
||
# DefaultPresentation
|
||
'ВВидеНаименования': 'AsDescription', 'ВВидеКода': 'AsCode',
|
||
# FillChecking
|
||
'НеПроверять': 'DontCheck', 'Ошибка': 'ShowError', 'Предупреждение': 'ShowWarning',
|
||
# Indexing
|
||
'НеИндексировать': 'DontIndex', 'Индексировать': 'Index',
|
||
'ИндексироватьСДопУпорядочиванием': 'IndexWithAdditionalOrder',
|
||
}
|
||
|
||
valid_enum_values = {
|
||
'RegisterType': ['Balance', 'Turnovers'],
|
||
'WriteMode': ['Independent', 'RecorderSubordinate'],
|
||
'InformationRegisterPeriodicity': ['Nonperiodical', 'Second', 'Day', 'Month', 'Quarter', 'Year', 'RecorderPosition'],
|
||
'DependenceOnCalculationTypes': ['DontUse', 'OnActionPeriod'],
|
||
'DataLockControlMode': ['Automatic', 'Managed'],
|
||
'FullTextSearch': ['Use', 'DontUse'],
|
||
'DataHistory': ['Use', 'DontUse'],
|
||
'DefaultPresentation': ['AsDescription', 'AsCode'],
|
||
'Posting': ['Allow', 'Deny'],
|
||
'RealTimePosting': ['Allow', 'Deny'],
|
||
'EditType': ['InDialog', 'InList', 'BothWays'],
|
||
'HierarchyType': ['HierarchyFoldersAndItems', 'HierarchyOfItems'],
|
||
'CodeType': ['String', 'Number'],
|
||
'CodeAllowedLength': ['Variable', 'Fixed'],
|
||
'NumberType': ['String', 'Number'],
|
||
'NumberAllowedLength': ['Variable', 'Fixed'],
|
||
'RegisterRecordsDeletion': ['AutoDelete', 'AutoDeleteOnUnpost', 'AutoDeleteOff'],
|
||
'RegisterRecordsWritingOnPost': ['WriteModified', 'WriteSelected', 'WriteAll'],
|
||
'ReturnValuesReuse': ['DontUse', 'DuringRequest', 'DuringSession'],
|
||
'ReuseSessions': ['DontUse', 'AutoUse'],
|
||
'FillChecking': ['DontCheck', 'ShowError', 'ShowWarning'],
|
||
'Indexing': ['DontIndex', 'Index', 'IndexWithAdditionalOrder'],
|
||
}
|
||
|
||
|
||
def normalize_enum_value(prop_name, value):
|
||
# 1. Check alias dictionary — silent auto-correct
|
||
if value in enum_value_aliases:
|
||
return enum_value_aliases[value]
|
||
# 2. Case-insensitive match against valid values — silent
|
||
valid = valid_enum_values.get(prop_name)
|
||
if valid:
|
||
for v in valid:
|
||
if v.lower() == value.lower():
|
||
return v
|
||
# 3. Known property, unknown value — error with hint
|
||
print(f"Invalid value '{value}' for property '{prop_name}'. Valid values: {', '.join(valid)}", file=sys.stderr)
|
||
sys.exit(1)
|
||
# 4. Unknown property — pass-through (no validation data)
|
||
return value
|
||
|
||
|
||
def new_uuid():
|
||
return str(uuid.uuid4())
|
||
|
||
|
||
def split_camel_case(name):
|
||
if not name:
|
||
return name
|
||
# Insert space between lowercase Cyrillic and uppercase Cyrillic
|
||
result = re.sub(r"([а-яё])([А-ЯЁ])", r"\1 \2", name)
|
||
# Insert space between lowercase Latin and uppercase Latin
|
||
result = re.sub(r"([a-z])([A-Z])", r"\1 \2", result)
|
||
if len(result) > 1:
|
||
result = result[0] + result[1:].lower()
|
||
return result
|
||
|
||
|
||
# ============================================================
|
||
# Synonym tables
|
||
# ============================================================
|
||
|
||
operation_synonyms = {
|
||
"add": "add", "добавить": "add",
|
||
"remove": "remove", "удалить": "remove",
|
||
"modify": "modify", "изменить": "modify",
|
||
}
|
||
|
||
child_type_synonyms = {
|
||
"attributes": "attributes", "реквизиты": "attributes", "attrs": "attributes",
|
||
"tabularsections": "tabularSections", "табличныечасти": "tabularSections", "тч": "tabularSections", "ts": "tabularSections",
|
||
"dimensions": "dimensions", "измерения": "dimensions", "dims": "dimensions",
|
||
"resources": "resources", "ресурсы": "resources", "res": "resources",
|
||
"enumvalues": "enumValues", "значения": "enumValues", "values": "enumValues",
|
||
"columns": "columns", "графы": "columns", "колонки": "columns",
|
||
"forms": "forms", "формы": "forms",
|
||
"templates": "templates", "макеты": "templates",
|
||
"commands": "commands", "команды": "commands",
|
||
"properties": "properties", "свойства": "properties",
|
||
}
|
||
|
||
type_synonyms = {
|
||
"число": "Number",
|
||
"строка": "String",
|
||
"булево": "Boolean",
|
||
"дата": "Date",
|
||
"датавремя": "DateTime",
|
||
"хранилищезначения": "ValueStorage",
|
||
"number": "Number",
|
||
"string": "String",
|
||
"boolean": "Boolean",
|
||
"date": "Date",
|
||
"datetime": "DateTime",
|
||
"valuestorage": "ValueStorage",
|
||
"bool": "Boolean",
|
||
# Reference synonyms
|
||
"справочникссылка": "CatalogRef",
|
||
"документссылка": "DocumentRef",
|
||
"перечислениессылка": "EnumRef",
|
||
"плансчетовссылка": "ChartOfAccountsRef",
|
||
"планвидовхарактеристикссылка": "ChartOfCharacteristicTypesRef",
|
||
"планвидоврасчётассылка": "ChartOfCalculationTypesRef",
|
||
"планвидоврасчетассылка": "ChartOfCalculationTypesRef",
|
||
"планобменассылка": "ExchangePlanRef",
|
||
"бизнеспроцессссылка": "BusinessProcessRef",
|
||
"задачассылка": "TaskRef",
|
||
"определяемыйтип": "DefinedType",
|
||
"definedtype": "DefinedType",
|
||
"catalogref": "CatalogRef",
|
||
"documentref": "DocumentRef",
|
||
"enumref": "EnumRef",
|
||
}
|
||
|
||
# ============================================================
|
||
# Type system
|
||
# ============================================================
|
||
|
||
|
||
def resolve_type_str(type_str):
|
||
if not type_str:
|
||
return type_str
|
||
|
||
# Parameterized: Number(15,2), Строка(100)
|
||
m = re.match(r"^([^(]+)\((.+)\)$", type_str)
|
||
if m:
|
||
base_name = m.group(1).strip()
|
||
params = m.group(2)
|
||
resolved = type_synonyms.get(base_name.lower())
|
||
if resolved:
|
||
return f"{resolved}({params})"
|
||
return type_str
|
||
|
||
# Reference: СправочникСсылка.Организации
|
||
if "." in type_str:
|
||
dot_idx = type_str.index(".")
|
||
prefix = type_str[:dot_idx]
|
||
suffix = type_str[dot_idx:]
|
||
resolved = type_synonyms.get(prefix.lower())
|
||
if resolved:
|
||
return f"{resolved}{suffix}"
|
||
return type_str
|
||
|
||
# Simple
|
||
resolved = type_synonyms.get(type_str.lower())
|
||
if resolved:
|
||
return resolved
|
||
return type_str
|
||
|
||
|
||
def build_type_content_xml(indent, type_str):
|
||
if not type_str:
|
||
return ""
|
||
|
||
# Composite type: "Type1 + Type2 + Type3"
|
||
if " + " in type_str:
|
||
parts = [p.strip() for p in type_str.split("+")]
|
||
results = []
|
||
for part in parts:
|
||
inner = build_type_content_xml(indent, part)
|
||
if inner:
|
||
results.append(inner)
|
||
return "\r\n".join(results)
|
||
|
||
type_str = resolve_type_str(type_str)
|
||
lines = []
|
||
|
||
# Boolean
|
||
if type_str == "Boolean":
|
||
lines.append(f"{indent}<v8:Type>xs:boolean</v8:Type>")
|
||
return "\r\n".join(lines)
|
||
|
||
# ValueStorage
|
||
if type_str == "ValueStorage":
|
||
lines.append(f"{indent}<v8:Type>xs:base64Binary</v8:Type>")
|
||
return "\r\n".join(lines)
|
||
|
||
# String or String(N)
|
||
m = re.match(r"^String(\((\d+)\))?$", type_str)
|
||
if m:
|
||
length = m.group(2) if m.group(2) else "10"
|
||
lines.append(f"{indent}<v8:Type>xs:string</v8:Type>")
|
||
lines.append(f"{indent}<v8:StringQualifiers>")
|
||
lines.append(f"{indent}\t<v8:Length>{length}</v8:Length>")
|
||
lines.append(f"{indent}\t<v8:AllowedLength>Variable</v8:AllowedLength>")
|
||
lines.append(f"{indent}</v8:StringQualifiers>")
|
||
return "\r\n".join(lines)
|
||
|
||
# Number(D,F) or Number(D,F,nonneg)
|
||
m = re.match(r"^Number\((\d+),(\d+)(,nonneg)?\)$", type_str)
|
||
if m:
|
||
digits = m.group(1)
|
||
fraction = m.group(2)
|
||
sign = "Nonnegative" if m.group(3) else "Any"
|
||
lines.append(f"{indent}<v8:Type>xs:decimal</v8:Type>")
|
||
lines.append(f"{indent}<v8:NumberQualifiers>")
|
||
lines.append(f"{indent}\t<v8:Digits>{digits}</v8:Digits>")
|
||
lines.append(f"{indent}\t<v8:FractionDigits>{fraction}</v8:FractionDigits>")
|
||
lines.append(f"{indent}\t<v8:AllowedSign>{sign}</v8:AllowedSign>")
|
||
lines.append(f"{indent}</v8:NumberQualifiers>")
|
||
return "\r\n".join(lines)
|
||
|
||
# Number without params -> Number(10,0)
|
||
if type_str == "Number":
|
||
lines.append(f"{indent}<v8:Type>xs:decimal</v8:Type>")
|
||
lines.append(f"{indent}<v8:NumberQualifiers>")
|
||
lines.append(f"{indent}\t<v8:Digits>10</v8:Digits>")
|
||
lines.append(f"{indent}\t<v8:FractionDigits>0</v8:FractionDigits>")
|
||
lines.append(f"{indent}\t<v8:AllowedSign>Any</v8:AllowedSign>")
|
||
lines.append(f"{indent}</v8:NumberQualifiers>")
|
||
return "\r\n".join(lines)
|
||
|
||
# Date / DateTime
|
||
if type_str == "Date":
|
||
lines.append(f"{indent}<v8:Type>xs:dateTime</v8:Type>")
|
||
lines.append(f"{indent}<v8:DateQualifiers>")
|
||
lines.append(f"{indent}\t<v8:DateFractions>Date</v8:DateFractions>")
|
||
lines.append(f"{indent}</v8:DateQualifiers>")
|
||
return "\r\n".join(lines)
|
||
|
||
if type_str == "DateTime":
|
||
lines.append(f"{indent}<v8:Type>xs:dateTime</v8:Type>")
|
||
lines.append(f"{indent}<v8:DateQualifiers>")
|
||
lines.append(f"{indent}\t<v8:DateFractions>DateTime</v8:DateFractions>")
|
||
lines.append(f"{indent}</v8:DateQualifiers>")
|
||
return "\r\n".join(lines)
|
||
|
||
# DefinedType
|
||
m = re.match(r"^DefinedType\.(.+)$", type_str)
|
||
if m:
|
||
dt_name = m.group(1)
|
||
lines.append(f"{indent}<v8:TypeSet>cfg:DefinedType.{dt_name}</v8:TypeSet>")
|
||
return "\r\n".join(lines)
|
||
|
||
# Reference types — use local xmlns declaration for 1C compatibility
|
||
m = re.match(
|
||
r"^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|"
|
||
r"ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.(.+)$",
|
||
type_str,
|
||
)
|
||
if m:
|
||
lines.append(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
||
return "\r\n".join(lines)
|
||
|
||
# Fallback
|
||
lines.append(f"{indent}<v8:Type>{type_str}</v8:Type>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_value_type_xml(indent, type_str):
|
||
inner = build_type_content_xml(f"{indent}\t", type_str)
|
||
return f"{indent}<Type>\r\n{inner}\r\n{indent}</Type>"
|
||
|
||
|
||
def build_fill_value_xml(indent, type_str):
|
||
if not type_str:
|
||
return f'{indent}<FillValue xsi:nil="true"/>'
|
||
type_str = resolve_type_str(type_str)
|
||
if type_str == "Boolean":
|
||
return f'{indent}<FillValue xsi:type="xs:boolean">false</FillValue>'
|
||
if type_str.startswith("String"):
|
||
return f'{indent}<FillValue xsi:type="xs:string"/>'
|
||
if type_str.startswith("Number"):
|
||
return f'{indent}<FillValue xsi:type="xs:decimal">0</FillValue>'
|
||
return f'{indent}<FillValue xsi:nil="true"/>'
|
||
|
||
|
||
def build_mltext_xml(indent, tag, text):
|
||
if not text:
|
||
return f"{indent}<{tag}/>"
|
||
lines = [
|
||
f"{indent}<{tag}>",
|
||
f"{indent}\t<v8:item>",
|
||
f"{indent}\t\t<v8:lang>ru</v8:lang>",
|
||
f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>",
|
||
f"{indent}\t</v8:item>",
|
||
f"{indent}</{tag}>",
|
||
]
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
# ============================================================
|
||
# DOM helpers
|
||
# ============================================================
|
||
|
||
|
||
def import_fragment(xml_string):
|
||
"""Parse an XML fragment in the context of our namespace declarations, return list of elements."""
|
||
wrapper = (
|
||
f'<_W xmlns="{MD_NS}"'
|
||
f' xmlns:xsi="{XSI_NS}"'
|
||
f' xmlns:v8="{V8_NS}"'
|
||
f' xmlns:xr="{XR_NS}"'
|
||
f' xmlns:cfg="{CFG_NS}"'
|
||
f' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||
f' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||
f' xmlns:xs="{XS_NS}">'
|
||
f"{xml_string}</_W>"
|
||
)
|
||
parser = etree.XMLParser(remove_blank_text=False)
|
||
frag = etree.fromstring(wrapper.encode("utf-8"), parser)
|
||
nodes = []
|
||
for child in frag:
|
||
nodes.append(child)
|
||
return nodes
|
||
|
||
|
||
def get_child_indent(container):
|
||
"""Detect indentation of children inside a container element."""
|
||
# Check container.text (text before first child)
|
||
if container.text and "\n" in container.text:
|
||
after_nl = container.text.rsplit("\n", 1)[-1]
|
||
if after_nl and not after_nl.strip():
|
||
return after_nl
|
||
# Check tail of child elements
|
||
for child in container:
|
||
if child.tail and "\n" in child.tail:
|
||
after_nl = child.tail.rsplit("\n", 1)[-1]
|
||
if after_nl and not after_nl.strip():
|
||
return after_nl
|
||
# Fallback: count depth
|
||
depth = 0
|
||
current = container
|
||
while current is not None:
|
||
parent = current.getparent()
|
||
if parent is None:
|
||
break
|
||
if parent is xml_root:
|
||
break
|
||
depth += 1
|
||
current = parent
|
||
return "\t" * (depth + 1)
|
||
|
||
|
||
def insert_before_element(container, new_node, ref_node, child_indent):
|
||
"""Insert new_node into container before ref_node. If ref_node is None, append."""
|
||
if ref_node is not None:
|
||
# Insert before ref_node
|
||
idx = list(container).index(ref_node)
|
||
new_node.tail = "\r\n" + child_indent
|
||
container.insert(idx, new_node)
|
||
else:
|
||
# Append: insert before closing tag
|
||
children = list(container)
|
||
if len(children) > 0:
|
||
last = children[-1]
|
||
# The last element's tail is the whitespace before </Container>
|
||
# We set new_node.tail to what last.tail was (newline + parent indent)
|
||
new_node.tail = last.tail
|
||
last.tail = "\r\n" + child_indent
|
||
container.append(new_node)
|
||
else:
|
||
# Container is empty (possibly self-closing)
|
||
parent_indent = child_indent[:-1] if len(child_indent) > 0 else ""
|
||
container.text = "\r\n" + child_indent
|
||
new_node.tail = "\r\n" + parent_indent
|
||
container.append(new_node)
|
||
|
||
|
||
def remove_node_with_whitespace(node):
|
||
"""Remove an element from its parent, cleaning up whitespace."""
|
||
parent = node.getparent()
|
||
prev = node.getprevious()
|
||
if prev is not None:
|
||
# Transfer tail to previous sibling
|
||
if node.tail:
|
||
prev.tail = node.tail
|
||
else:
|
||
# First child: adjust parent.text
|
||
if node.tail:
|
||
parent.text = node.tail
|
||
parent.remove(node)
|
||
|
||
|
||
def find_element_by_name(container, elem_local_name, name_value):
|
||
"""Find a child element of given localname whose Properties/Name (or just Name) == name_value."""
|
||
for child in container:
|
||
if localname(child) != elem_local_name:
|
||
continue
|
||
# Look for Properties/Name or just Name child
|
||
props_el = None
|
||
for gc in child:
|
||
if localname(gc) == "Properties":
|
||
props_el = gc
|
||
break
|
||
search_in = props_el if props_el is not None else child
|
||
for gc in search_in:
|
||
if localname(gc) == "Name":
|
||
text = (gc.text or "").strip()
|
||
if text == name_value:
|
||
return child
|
||
return None
|
||
|
||
|
||
def find_last_element_of_type(container, local_name):
|
||
last = None
|
||
for child in container:
|
||
if localname(child) == local_name:
|
||
last = child
|
||
return last
|
||
|
||
|
||
def find_first_element_of_type(container, local_name):
|
||
for child in container:
|
||
if localname(child) == local_name:
|
||
return child
|
||
return None
|
||
|
||
|
||
def ensure_child_objects_open():
|
||
"""Ensure ChildObjects element exists and is open (not self-closing empty)."""
|
||
global child_objects_el
|
||
|
||
if child_objects_el is not None:
|
||
# Check if it's empty (no child elements)
|
||
has_elements = any(True for _ in child_objects_el)
|
||
if not has_elements:
|
||
# It's empty - add whitespace for proper formatting
|
||
indent = get_child_indent(obj_element)
|
||
child_objects_el.text = "\r\n" + indent
|
||
return
|
||
|
||
# No ChildObjects at all - create one after Properties
|
||
indent = get_child_indent(obj_element)
|
||
|
||
co_el = etree.Element(f"{{{md_ns}}}ChildObjects")
|
||
co_el.text = "\r\n" + indent
|
||
|
||
# Find where to insert: after Properties
|
||
ref_node = None
|
||
found_props = False
|
||
for child in obj_element:
|
||
if localname(child) == "Properties":
|
||
found_props = True
|
||
continue
|
||
if found_props:
|
||
ref_node = child
|
||
break
|
||
|
||
if ref_node is not None:
|
||
# Insert before ref_node
|
||
idx = list(obj_element).index(ref_node)
|
||
co_el.tail = "\r\n" + indent
|
||
obj_element.insert(idx, co_el)
|
||
else:
|
||
# Append
|
||
children = list(obj_element)
|
||
if len(children) > 0:
|
||
last = children[-1]
|
||
co_el.tail = last.tail
|
||
last.tail = "\r\n" + indent
|
||
obj_element.append(co_el)
|
||
else:
|
||
parent_indent = indent[:-1] if len(indent) > 0 else ""
|
||
obj_element.text = "\r\n" + indent
|
||
co_el.tail = "\r\n" + parent_indent
|
||
obj_element.append(co_el)
|
||
|
||
child_objects_el = co_el
|
||
|
||
|
||
def collapse_child_objects_if_empty():
|
||
"""Collapse ChildObjects to self-closing if empty."""
|
||
global child_objects_el
|
||
if child_objects_el is None:
|
||
return
|
||
has_elements = any(True for _ in child_objects_el)
|
||
if not has_elements:
|
||
child_objects_el.text = None
|
||
|
||
|
||
# ============================================================
|
||
# Fragment builders
|
||
# ============================================================
|
||
|
||
|
||
def parse_attribute_shorthand(val):
|
||
"""Parse attribute definition from string shorthand or dict object."""
|
||
if isinstance(val, str):
|
||
s = val
|
||
parsed = {
|
||
"name": "", "type": "", "synonym": "", "comment": "",
|
||
"flags": [], "fillChecking": "", "indexing": "",
|
||
"after": "", "before": "",
|
||
}
|
||
# Extract positional markers: >> after Name, << before Name
|
||
m = re.search(r"\s*>>\s*after\s+(\S+)\s*$", s)
|
||
if m:
|
||
parsed["after"] = m.group(1)
|
||
s = re.sub(r"\s*>>\s*after\s+\S+\s*$", "", s).strip()
|
||
else:
|
||
m = re.search(r"\s*<<\s*before\s+(\S+)\s*$", s)
|
||
if m:
|
||
parsed["before"] = m.group(1)
|
||
s = re.sub(r"\s*<<\s*before\s+\S+\s*$", "", s).strip()
|
||
|
||
# Split by | for flags
|
||
parts = s.split("|", 1)
|
||
main_part = parts[0].strip()
|
||
if len(parts) > 1:
|
||
flag_str = parts[1].strip()
|
||
parsed["flags"] = [f.strip().lower() for f in flag_str.split(",") if f.strip()]
|
||
|
||
# Split by : for name and type
|
||
colon_parts = main_part.split(":", 1)
|
||
parsed["name"] = colon_parts[0].strip()
|
||
if len(colon_parts) > 1:
|
||
parsed["type"] = colon_parts[1].strip()
|
||
|
||
parsed["synonym"] = split_camel_case(parsed["name"])
|
||
return parsed
|
||
|
||
# Object/dict form
|
||
name = str(val.get("name", ""))
|
||
result = {
|
||
"name": name,
|
||
"type": " + ".join(str(t) for t in val["type"]) if isinstance(val.get("type"), list) else str(val.get("type", "")),
|
||
"synonym": str(val.get("synonym", "")) if val.get("synonym") else split_camel_case(name),
|
||
"comment": str(val.get("comment", "")),
|
||
"flags": list(val.get("flags", [])),
|
||
"fillChecking": normalize_enum_value("FillChecking", str(val.get("fillChecking", ""))) if val.get("fillChecking") else "",
|
||
"indexing": normalize_enum_value("Indexing", str(val.get("indexing", ""))) if val.get("indexing") else "",
|
||
"after": str(val.get("after", "")),
|
||
"before": str(val.get("before", "")),
|
||
}
|
||
# Map flags to properties
|
||
if "req" in result["flags"] and not result["fillChecking"]:
|
||
result["fillChecking"] = "ShowError"
|
||
if "index" in result["flags"] and not result["indexing"]:
|
||
result["indexing"] = "Index"
|
||
if "indexadditional" in result["flags"] and not result["indexing"]:
|
||
result["indexing"] = "IndexWithAdditionalOrder"
|
||
return result
|
||
|
||
|
||
def parse_enum_value_shorthand(val):
|
||
"""Parse enum value definition from string or dict."""
|
||
if isinstance(val, str):
|
||
name = val
|
||
return {
|
||
"name": name,
|
||
"synonym": split_camel_case(name),
|
||
"comment": "",
|
||
"after": "", "before": "",
|
||
}
|
||
name = str(val.get("name", ""))
|
||
return {
|
||
"name": name,
|
||
"synonym": str(val.get("synonym", "")) if val.get("synonym") else split_camel_case(name),
|
||
"comment": str(val.get("comment", "")),
|
||
"after": str(val.get("after", "")),
|
||
"before": str(val.get("before", "")),
|
||
}
|
||
|
||
|
||
def get_attribute_context():
|
||
"""Determine attribute context from object type."""
|
||
if obj_type == "Catalog":
|
||
return "catalog"
|
||
if obj_type == "Document":
|
||
return "document"
|
||
if obj_type in ("InformationRegister", "AccumulationRegister", "AccountingRegister", "CalculationRegister"):
|
||
return "register"
|
||
if obj_type in ("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"):
|
||
return "processor"
|
||
return "object"
|
||
|
||
|
||
RESERVED_ATTR_NAMES = {
|
||
'Ref', 'DeletionMark', 'Code', 'Description', 'Date', 'Number', 'Posted',
|
||
'Parent', 'Owner', 'IsFolder', 'Predefined', 'PredefinedDataName',
|
||
'Recorder', 'Period', 'LineNumber', 'Active', 'Order', 'Type', 'OffBalance',
|
||
'Started', 'Completed', 'HeadTask', 'Executed', 'RoutePoint', 'BusinessProcess',
|
||
'ThisNode', 'SentNo', 'ReceivedNo', 'CalculationType', 'RegistrationPeriod',
|
||
'ReversingEntry', 'Account', 'ValueType', 'ActionPeriodIsBasic',
|
||
}
|
||
RESERVED_ATTR_NAMES_RU = {
|
||
'Ссылка', 'ПометкаУдаления', 'Код', 'Наименование',
|
||
'Дата', 'Номер', 'Проведен', 'Родитель', 'Владелец',
|
||
'ЭтоГруппа', 'Предопределенный', 'ИмяПредопределенныхДанных',
|
||
'Регистратор', 'Период', 'НомерСтроки', 'Активность',
|
||
'Порядок', 'Тип', 'Забалансовый',
|
||
'Стартован', 'Завершен', 'ВедущаяЗадача',
|
||
'Выполнена', 'ТочкаМаршрута', 'БизнесПроцесс',
|
||
'ЭтотУзел', 'НомерОтправленного', 'НомерПринятого',
|
||
'ВидРасчета', 'ПериодРегистрации', 'СторноЗапись',
|
||
'Счет', 'ТипЗначения', 'ПериодДействияБазовый',
|
||
}
|
||
|
||
|
||
# Стандартные реквизиты по типу объекта (EN + RU). Совпадение имени реквизита с ними платформа
|
||
# не позволит — жёсткий отказ. Контексты вне карты → предупреждение по плоскому списку.
|
||
RESERVED_BY_CONTEXT = {
|
||
'catalog': {
|
||
'ref', 'ссылка',
|
||
'deletionmark', 'пометкаудаления',
|
||
'predefined', 'предопределенный',
|
||
'predefineddataname', 'имяпредопределенныхданных',
|
||
'code', 'код',
|
||
'description', 'наименование',
|
||
'owner', 'владелец',
|
||
'parent', 'родитель',
|
||
'isfolder', 'этогруппа',
|
||
},
|
||
'document': {
|
||
'ref', 'ссылка',
|
||
'deletionmark', 'пометкаудаления',
|
||
'date', 'дата',
|
||
'number', 'номер',
|
||
'posted', 'проведен',
|
||
},
|
||
}
|
||
|
||
|
||
def build_attribute_fragment(parsed, context, indent):
|
||
"""Build XML fragment string for an Attribute element."""
|
||
if not context:
|
||
context = get_attribute_context()
|
||
|
||
# Check reserved attribute names (типозависимо: catalog/document — отказ; прочее — предупреждение)
|
||
attr_name = parsed['name']
|
||
ctx_reserved = RESERVED_BY_CONTEXT.get(context)
|
||
if ctx_reserved is not None:
|
||
if attr_name.lower() in ctx_reserved:
|
||
print(f"meta-edit: имя реквизита '{attr_name}' зарезервировано стандартным реквизитом объекта '{context}'. Выберите другое имя.", file=sys.stderr)
|
||
sys.exit(1)
|
||
elif context not in ('tabular', 'processor-tabular') and (attr_name in RESERVED_ATTR_NAMES or attr_name in RESERVED_ATTR_NAMES_RU):
|
||
print(f"WARNING: Attribute '{attr_name}' conflicts with a standard attribute name. This may cause errors when loading into 1C.", file=sys.stderr)
|
||
|
||
uid = new_uuid()
|
||
lines = []
|
||
|
||
lines.append(f'{indent}<Attribute uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(parsed['name'])}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", parsed["synonym"]))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
|
||
# Type
|
||
type_str = parsed["type"]
|
||
if type_str:
|
||
lines.append(build_value_type_xml(f"{indent}\t\t", type_str))
|
||
else:
|
||
lines.append(f"{indent}\t\t<Type>")
|
||
lines.append(f"{indent}\t\t\t<v8:Type>xs:string</v8:Type>")
|
||
lines.append(f"{indent}\t\t</Type>")
|
||
|
||
lines.append(f"{indent}\t\t<PasswordMode>false</PasswordMode>")
|
||
lines.append(f"{indent}\t\t<Format/>")
|
||
lines.append(f"{indent}\t\t<EditFormat/>")
|
||
lines.append(f"{indent}\t\t<ToolTip/>")
|
||
lines.append(f"{indent}\t\t<MarkNegatives>false</MarkNegatives>")
|
||
lines.append(f"{indent}\t\t<Mask/>")
|
||
lines.append(f"{indent}\t\t<MultiLine>false</MultiLine>")
|
||
lines.append(f"{indent}\t\t<ExtendedEdit>false</ExtendedEdit>")
|
||
lines.append(f'{indent}\t\t<MinValue xsi:nil="true"/>')
|
||
lines.append(f'{indent}\t\t<MaxValue xsi:nil="true"/>')
|
||
|
||
# FillFromFillingValue/FillValue -- not for register, tabular, or processor
|
||
if context not in ("register", "tabular", "processor"):
|
||
lines.append(f"{indent}\t\t<FillFromFillingValue>false</FillFromFillingValue>")
|
||
lines.append(build_fill_value_xml(f"{indent}\t\t", type_str))
|
||
|
||
# FillChecking
|
||
fill_checking = "DontCheck"
|
||
if "req" in parsed["flags"]:
|
||
fill_checking = "ShowError"
|
||
if parsed["fillChecking"]:
|
||
fill_checking = parsed["fillChecking"]
|
||
lines.append(f"{indent}\t\t<FillChecking>{fill_checking}</FillChecking>")
|
||
|
||
lines.append(f"{indent}\t\t<ChoiceFoldersAndItems>Items</ChoiceFoldersAndItems>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameterLinks/>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameters/>")
|
||
lines.append(f"{indent}\t\t<QuickChoice>Auto</QuickChoice>")
|
||
lines.append(f"{indent}\t\t<CreateOnInput>Auto</CreateOnInput>")
|
||
lines.append(f"{indent}\t\t<ChoiceForm/>")
|
||
lines.append(f"{indent}\t\t<LinkByType/>")
|
||
lines.append(f"{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>")
|
||
|
||
# Use -- catalog only
|
||
if context == "catalog":
|
||
lines.append(f"{indent}\t\t<Use>ForItem</Use>")
|
||
|
||
# Indexing/FullTextSearch/DataHistory -- not for non-stored objects
|
||
if context not in ("processor", "processor-tabular"):
|
||
indexing = "DontIndex"
|
||
if "index" in parsed["flags"]:
|
||
indexing = "Index"
|
||
if "indexadditional" in parsed["flags"]:
|
||
indexing = "IndexWithAdditionalOrder"
|
||
if parsed["indexing"]:
|
||
indexing = parsed["indexing"]
|
||
lines.append(f"{indent}\t\t<Indexing>{indexing}</Indexing>")
|
||
lines.append(f"{indent}\t\t<FullTextSearch>Use</FullTextSearch>")
|
||
lines.append(f"{indent}\t\t<DataHistory>Use</DataHistory>")
|
||
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</Attribute>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_tabular_section_fragment(ts_def, indent):
|
||
"""Build XML fragment string for a TabularSection element."""
|
||
if isinstance(ts_def, str):
|
||
ts_def = {"name": ts_def}
|
||
ts_name = str(ts_def.get("name", ""))
|
||
ts_synonym = str(ts_def.get("synonym", "")) if ts_def.get("synonym") else split_camel_case(ts_name)
|
||
uid = new_uuid()
|
||
|
||
type_prefix = f"{obj_type}TabularSection"
|
||
row_prefix = f"{obj_type}TabularSectionRow"
|
||
|
||
lines = []
|
||
lines.append(f'{indent}<TabularSection uuid="{uid}">')
|
||
|
||
# InternalInfo
|
||
lines.append(f"{indent}\t<InternalInfo>")
|
||
lines.append(f'{indent}\t\t<xr:GeneratedType name="{type_prefix}.{obj_name}.{ts_name}" category="TabularSection">')
|
||
lines.append(f"{indent}\t\t\t<xr:TypeId>{new_uuid()}</xr:TypeId>")
|
||
lines.append(f"{indent}\t\t\t<xr:ValueId>{new_uuid()}</xr:ValueId>")
|
||
lines.append(f"{indent}\t\t</xr:GeneratedType>")
|
||
lines.append(f'{indent}\t\t<xr:GeneratedType name="{row_prefix}.{obj_name}.{ts_name}" category="TabularSectionRow">')
|
||
lines.append(f"{indent}\t\t\t<xr:TypeId>{new_uuid()}</xr:TypeId>")
|
||
lines.append(f"{indent}\t\t\t<xr:ValueId>{new_uuid()}</xr:ValueId>")
|
||
lines.append(f"{indent}\t\t</xr:GeneratedType>")
|
||
lines.append(f"{indent}\t</InternalInfo>")
|
||
|
||
# Properties
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(ts_name)}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", ts_synonym))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
lines.append(f"{indent}\t\t<ToolTip/>")
|
||
lines.append(f"{indent}\t\t<FillChecking>DontCheck</FillChecking>")
|
||
|
||
# StandardAttributes (LineNumber)
|
||
lines.append(f"{indent}\t\t<StandardAttributes>")
|
||
lines.append(f'{indent}\t\t\t<xr:StandardAttribute name="LineNumber">')
|
||
lines.append(f"{indent}\t\t\t\t<xr:LinkByType/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:FillChecking>DontCheck</xr:FillChecking>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:MultiLine>false</xr:MultiLine>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:FillFromFillingValue>false</xr:FillFromFillingValue>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:CreateOnInput>Auto</xr:CreateOnInput>")
|
||
lines.append(f'{indent}\t\t\t\t<xr:MaxValue xsi:nil="true"/>')
|
||
lines.append(f"{indent}\t\t\t\t<xr:ToolTip/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:ExtendedEdit>false</xr:ExtendedEdit>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:Format/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:ChoiceForm/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:QuickChoice>Auto</xr:QuickChoice>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:ChoiceHistoryOnInput>Auto</xr:ChoiceHistoryOnInput>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:EditFormat/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:PasswordMode>false</xr:PasswordMode>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:DataHistory>Use</xr:DataHistory>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:MarkNegatives>false</xr:MarkNegatives>")
|
||
lines.append(f'{indent}\t\t\t\t<xr:MinValue xsi:nil="true"/>')
|
||
lines.append(f"{indent}\t\t\t\t<xr:Synonym/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:Comment/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:FullTextSearch>Use</xr:FullTextSearch>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:ChoiceParameterLinks/>")
|
||
lines.append(f'{indent}\t\t\t\t<xr:FillValue xsi:nil="true"/>')
|
||
lines.append(f"{indent}\t\t\t\t<xr:Mask/>")
|
||
lines.append(f"{indent}\t\t\t\t<xr:ChoiceParameters/>")
|
||
lines.append(f"{indent}\t\t\t</xr:StandardAttribute>")
|
||
lines.append(f"{indent}\t\t</StandardAttributes>")
|
||
|
||
# Use -- catalog only
|
||
if obj_type == "Catalog":
|
||
lines.append(f"{indent}\t\t<Use>ForItem</Use>")
|
||
|
||
lines.append(f"{indent}\t</Properties>")
|
||
|
||
# ChildObjects with attrs
|
||
columns = []
|
||
if ts_def.get("attrs"):
|
||
columns = list(ts_def["attrs"])
|
||
elif ts_def.get("attributes"):
|
||
columns = list(ts_def["attributes"])
|
||
elif ts_def.get("реквизиты"):
|
||
columns = list(ts_def["реквизиты"])
|
||
|
||
ts_attr_context = "processor-tabular" if obj_type in ("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport") else "tabular"
|
||
if columns:
|
||
lines.append(f"{indent}\t<ChildObjects>")
|
||
for col in columns:
|
||
col_parsed = parse_attribute_shorthand(col)
|
||
lines.append(build_attribute_fragment(col_parsed, ts_attr_context, f"{indent}\t\t"))
|
||
lines.append(f"{indent}\t</ChildObjects>")
|
||
else:
|
||
lines.append(f"{indent}\t<ChildObjects/>")
|
||
|
||
lines.append(f"{indent}</TabularSection>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_dimension_fragment(parsed, register_type, indent):
|
||
"""Build XML fragment string for a Dimension element."""
|
||
if not register_type:
|
||
register_type = obj_type
|
||
uid = new_uuid()
|
||
lines = []
|
||
|
||
lines.append(f'{indent}<Dimension uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(parsed['name'])}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", parsed["synonym"]))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
|
||
type_str = parsed["type"]
|
||
if type_str:
|
||
lines.append(build_value_type_xml(f"{indent}\t\t", type_str))
|
||
else:
|
||
lines.append(f"{indent}\t\t<Type>")
|
||
lines.append(f"{indent}\t\t\t<v8:Type>xs:string</v8:Type>")
|
||
lines.append(f"{indent}\t\t</Type>")
|
||
|
||
lines.append(f"{indent}\t\t<PasswordMode>false</PasswordMode>")
|
||
lines.append(f"{indent}\t\t<Format/>")
|
||
lines.append(f"{indent}\t\t<EditFormat/>")
|
||
lines.append(f"{indent}\t\t<ToolTip/>")
|
||
lines.append(f"{indent}\t\t<MarkNegatives>false</MarkNegatives>")
|
||
lines.append(f"{indent}\t\t<Mask/>")
|
||
lines.append(f"{indent}\t\t<MultiLine>false</MultiLine>")
|
||
lines.append(f"{indent}\t\t<ExtendedEdit>false</ExtendedEdit>")
|
||
lines.append(f'{indent}\t\t<MinValue xsi:nil="true"/>')
|
||
lines.append(f'{indent}\t\t<MaxValue xsi:nil="true"/>')
|
||
|
||
# InformationRegister: FillFromFillingValue, FillValue
|
||
if register_type == "InformationRegister":
|
||
fill_from = "true" if "master" in parsed["flags"] else "false"
|
||
lines.append(f"{indent}\t\t<FillFromFillingValue>{fill_from}</FillFromFillingValue>")
|
||
lines.append(f'{indent}\t\t<FillValue xsi:nil="true"/>')
|
||
|
||
fill_checking = "DontCheck"
|
||
if "req" in parsed["flags"]:
|
||
fill_checking = "ShowError"
|
||
lines.append(f"{indent}\t\t<FillChecking>{fill_checking}</FillChecking>")
|
||
|
||
lines.append(f"{indent}\t\t<ChoiceFoldersAndItems>Items</ChoiceFoldersAndItems>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameterLinks/>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameters/>")
|
||
lines.append(f"{indent}\t\t<QuickChoice>Auto</QuickChoice>")
|
||
lines.append(f"{indent}\t\t<CreateOnInput>Auto</CreateOnInput>")
|
||
lines.append(f"{indent}\t\t<ChoiceForm/>")
|
||
lines.append(f"{indent}\t\t<LinkByType/>")
|
||
lines.append(f"{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>")
|
||
|
||
# InformationRegister: Master, MainFilter, DenyIncompleteValues
|
||
if register_type == "InformationRegister":
|
||
master = "true" if "master" in parsed["flags"] else "false"
|
||
main_filter = "true" if "mainfilter" in parsed["flags"] else "false"
|
||
deny_incomplete = "true" if "denyincomplete" in parsed["flags"] else "false"
|
||
lines.append(f"{indent}\t\t<Master>{master}</Master>")
|
||
lines.append(f"{indent}\t\t<MainFilter>{main_filter}</MainFilter>")
|
||
lines.append(f"{indent}\t\t<DenyIncompleteValues>{deny_incomplete}</DenyIncompleteValues>")
|
||
|
||
# AccumulationRegister: DenyIncompleteValues
|
||
if register_type == "AccumulationRegister":
|
||
deny_incomplete = "true" if "denyincomplete" in parsed["flags"] else "false"
|
||
lines.append(f"{indent}\t\t<DenyIncompleteValues>{deny_incomplete}</DenyIncompleteValues>")
|
||
|
||
indexing = "DontIndex"
|
||
if "index" in parsed["flags"]:
|
||
indexing = "Index"
|
||
lines.append(f"{indent}\t\t<Indexing>{indexing}</Indexing>")
|
||
|
||
lines.append(f"{indent}\t\t<FullTextSearch>Use</FullTextSearch>")
|
||
|
||
# AccumulationRegister: UseInTotals
|
||
if register_type == "AccumulationRegister":
|
||
use_in_totals = "false" if "nouseintotals" in parsed["flags"] else "true"
|
||
lines.append(f"{indent}\t\t<UseInTotals>{use_in_totals}</UseInTotals>")
|
||
|
||
# InformationRegister: DataHistory
|
||
if register_type == "InformationRegister":
|
||
lines.append(f"{indent}\t\t<DataHistory>Use</DataHistory>")
|
||
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</Dimension>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_resource_fragment(parsed, register_type, indent):
|
||
"""Build XML fragment string for a Resource element."""
|
||
if not register_type:
|
||
register_type = obj_type
|
||
uid = new_uuid()
|
||
lines = []
|
||
|
||
lines.append(f'{indent}<Resource uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(parsed['name'])}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", parsed["synonym"]))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
|
||
type_str = parsed["type"]
|
||
if type_str:
|
||
lines.append(build_value_type_xml(f"{indent}\t\t", type_str))
|
||
else:
|
||
# Default: Number(15,2)
|
||
lines.append(f"{indent}\t\t<Type>")
|
||
lines.append(f"{indent}\t\t\t<v8:Type>xs:decimal</v8:Type>")
|
||
lines.append(f"{indent}\t\t\t<v8:NumberQualifiers>")
|
||
lines.append(f"{indent}\t\t\t\t<v8:Digits>15</v8:Digits>")
|
||
lines.append(f"{indent}\t\t\t\t<v8:FractionDigits>2</v8:FractionDigits>")
|
||
lines.append(f"{indent}\t\t\t\t<v8:AllowedSign>Any</v8:AllowedSign>")
|
||
lines.append(f"{indent}\t\t\t</v8:NumberQualifiers>")
|
||
lines.append(f"{indent}\t\t</Type>")
|
||
|
||
lines.append(f"{indent}\t\t<PasswordMode>false</PasswordMode>")
|
||
lines.append(f"{indent}\t\t<Format/>")
|
||
lines.append(f"{indent}\t\t<EditFormat/>")
|
||
lines.append(f"{indent}\t\t<ToolTip/>")
|
||
lines.append(f"{indent}\t\t<MarkNegatives>false</MarkNegatives>")
|
||
lines.append(f"{indent}\t\t<Mask/>")
|
||
lines.append(f"{indent}\t\t<MultiLine>false</MultiLine>")
|
||
lines.append(f"{indent}\t\t<ExtendedEdit>false</ExtendedEdit>")
|
||
lines.append(f'{indent}\t\t<MinValue xsi:nil="true"/>')
|
||
lines.append(f'{indent}\t\t<MaxValue xsi:nil="true"/>')
|
||
|
||
# InformationRegister: FillFromFillingValue, FillValue
|
||
if register_type == "InformationRegister":
|
||
lines.append(f"{indent}\t\t<FillFromFillingValue>false</FillFromFillingValue>")
|
||
lines.append(f'{indent}\t\t<FillValue xsi:nil="true"/>')
|
||
|
||
fill_checking = "DontCheck"
|
||
if "req" in parsed["flags"]:
|
||
fill_checking = "ShowError"
|
||
lines.append(f"{indent}\t\t<FillChecking>{fill_checking}</FillChecking>")
|
||
|
||
lines.append(f"{indent}\t\t<ChoiceFoldersAndItems>Items</ChoiceFoldersAndItems>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameterLinks/>")
|
||
lines.append(f"{indent}\t\t<ChoiceParameters/>")
|
||
lines.append(f"{indent}\t\t<QuickChoice>Auto</QuickChoice>")
|
||
lines.append(f"{indent}\t\t<CreateOnInput>Auto</CreateOnInput>")
|
||
lines.append(f"{indent}\t\t<ChoiceForm/>")
|
||
lines.append(f"{indent}\t\t<LinkByType/>")
|
||
lines.append(f"{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>")
|
||
|
||
# InformationRegister: Indexing, FullTextSearch, DataHistory
|
||
if register_type == "InformationRegister":
|
||
lines.append(f"{indent}\t\t<Indexing>DontIndex</Indexing>")
|
||
lines.append(f"{indent}\t\t<FullTextSearch>Use</FullTextSearch>")
|
||
lines.append(f"{indent}\t\t<DataHistory>Use</DataHistory>")
|
||
|
||
# AccumulationRegister: FullTextSearch
|
||
if register_type == "AccumulationRegister":
|
||
lines.append(f"{indent}\t\t<FullTextSearch>Use</FullTextSearch>")
|
||
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</Resource>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_enum_value_fragment(parsed, indent):
|
||
"""Build XML fragment string for an EnumValue element."""
|
||
uid = new_uuid()
|
||
lines = []
|
||
lines.append(f'{indent}<EnumValue uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(parsed['name'])}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", parsed["synonym"]))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</EnumValue>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_column_fragment(col_def, indent):
|
||
"""Build XML fragment string for a Column element."""
|
||
uid = new_uuid()
|
||
name = ""
|
||
synonym = ""
|
||
indexing = "DontIndex"
|
||
references = []
|
||
|
||
if isinstance(col_def, str):
|
||
name = col_def
|
||
synonym = split_camel_case(name)
|
||
else:
|
||
name = str(col_def.get("name", ""))
|
||
synonym = str(col_def.get("synonym", "")) if col_def.get("synonym") else split_camel_case(name)
|
||
if col_def.get("indexing"):
|
||
indexing = normalize_enum_value("Indexing", str(col_def["indexing"]))
|
||
if col_def.get("references"):
|
||
references = list(col_def["references"])
|
||
|
||
lines = []
|
||
lines.append(f'{indent}<Column uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(name)}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", synonym))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
lines.append(f"{indent}\t\t<Indexing>{indexing}</Indexing>")
|
||
if references:
|
||
lines.append(f"{indent}\t\t<References>")
|
||
for ref in references:
|
||
lines.append(f'{indent}\t\t\t<xr:Item xsi:type="xr:MDObjectRef">{ref}</xr:Item>')
|
||
lines.append(f"{indent}\t\t</References>")
|
||
else:
|
||
lines.append(f"{indent}\t\t<References/>")
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</Column>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
def build_simple_child_fragment(tag_name, name, indent):
|
||
"""Build XML fragment for Form, Template, Command -- just a name wrapper."""
|
||
uid = new_uuid()
|
||
synonym = split_camel_case(name)
|
||
lines = []
|
||
lines.append(f'{indent}<{tag_name} uuid="{uid}">')
|
||
lines.append(f"{indent}\t<Properties>")
|
||
lines.append(f"{indent}\t\t<Name>{esc_xml(name)}</Name>")
|
||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", synonym))
|
||
lines.append(f"{indent}\t\t<Comment/>")
|
||
# Forms get additional properties
|
||
if tag_name == "Form":
|
||
lines.append(f"{indent}\t\t<FormType>Ordinary</FormType>")
|
||
lines.append(f"{indent}\t\t<IncludeHelpInContents>false</IncludeHelpInContents>")
|
||
lines.append(f"{indent}\t\t<UsePurposes/>")
|
||
if tag_name == "Template":
|
||
lines.append(f"{indent}\t\t<TemplateType>SpreadsheetDocument</TemplateType>")
|
||
if tag_name == "Command":
|
||
lines.append(f"{indent}\t\t<Group>FormNavigationPanelGoTo</Group>")
|
||
lines.append(f"{indent}\t\t<Representation>Auto</Representation>")
|
||
lines.append(f"{indent}\t\t<ToolTip/>")
|
||
lines.append(f"{indent}\t\t<Picture/>")
|
||
lines.append(f"{indent}\t\t<Shortcut/>")
|
||
lines.append(f"{indent}\t</Properties>")
|
||
lines.append(f"{indent}</{tag_name}>")
|
||
return "\r\n".join(lines)
|
||
|
||
|
||
# ============================================================
|
||
# Name uniqueness check
|
||
# ============================================================
|
||
|
||
|
||
def get_all_child_names():
|
||
"""Get dict of all child element names -> element localname."""
|
||
names = {}
|
||
if child_objects_el is None:
|
||
return names
|
||
for child in child_objects_el:
|
||
props_el = None
|
||
for gc in child:
|
||
if localname(gc) == "Properties":
|
||
props_el = gc
|
||
break
|
||
if props_el is None:
|
||
continue
|
||
for gc in props_el:
|
||
if localname(gc) == "Name":
|
||
n = (gc.text or "").strip()
|
||
if n:
|
||
names[n] = localname(child)
|
||
break
|
||
return names
|
||
|
||
|
||
# ============================================================
|
||
# Context and allowed child types
|
||
# ============================================================
|
||
|
||
valid_child_types = {
|
||
"Catalog": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"Document": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"ExchangePlan": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"ChartOfAccounts": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"ChartOfCharacteristicTypes": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"ChartOfCalculationTypes": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"BusinessProcess": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"Task": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"Report": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"DataProcessor": ["attributes", "tabularSections", "forms", "templates", "commands"],
|
||
"Enum": ["enumValues", "forms", "templates", "commands"],
|
||
"InformationRegister": ["dimensions", "resources", "attributes", "forms", "templates", "commands"],
|
||
"AccumulationRegister": ["dimensions", "resources", "attributes", "forms", "templates", "commands"],
|
||
"AccountingRegister": ["dimensions", "resources", "attributes", "forms", "templates", "commands"],
|
||
"CalculationRegister": ["dimensions", "resources", "attributes", "forms", "templates", "commands"],
|
||
"DocumentJournal": ["columns", "forms", "templates", "commands"],
|
||
"Constant": ["forms"],
|
||
}
|
||
|
||
# Canonical child order in ChildObjects
|
||
child_order = [
|
||
"Resource", "Dimension", "Attribute", "TabularSection",
|
||
"AccountingFlag", "ExtDimensionAccountingFlag",
|
||
"EnumValue", "Column", "AddressingAttribute", "Recalculation",
|
||
"Form", "Template", "Command",
|
||
]
|
||
|
||
# Map from DSL child type to XML element name
|
||
child_type_to_xml_tag = {
|
||
"attributes": "Attribute",
|
||
"tabularSections": "TabularSection",
|
||
"dimensions": "Dimension",
|
||
"resources": "Resource",
|
||
"enumValues": "EnumValue",
|
||
"columns": "Column",
|
||
"forms": "Form",
|
||
"templates": "Template",
|
||
"commands": "Command",
|
||
}
|
||
|
||
# ============================================================
|
||
# DSL key normalization
|
||
# ============================================================
|
||
|
||
|
||
def resolve_operation_key(key):
|
||
k = key.lower().strip()
|
||
return operation_synonyms.get(k)
|
||
|
||
|
||
def resolve_child_type_key(key):
|
||
k = key.lower().strip()
|
||
return child_type_synonyms.get(k)
|
||
|
||
|
||
# ============================================================
|
||
# Inline mode converter
|
||
# ============================================================
|
||
|
||
|
||
def split_by_comma_outside_parens(s):
|
||
result = []
|
||
depth = 0
|
||
current = ""
|
||
for ch in s:
|
||
if ch == "(":
|
||
depth += 1
|
||
elif ch == ")":
|
||
depth -= 1
|
||
if ch == "," and depth == 0:
|
||
result.append(current)
|
||
current = ""
|
||
else:
|
||
current += ch
|
||
if current:
|
||
result.append(current)
|
||
return result
|
||
|
||
|
||
def convert_inline_to_definition(operation, value):
|
||
"""Convert inline -Operation + -Value to a definition dict."""
|
||
op_parts = operation.split("-", 1)
|
||
op = op_parts[0] # add, remove, modify, set
|
||
target = op_parts[1] # attribute, ts, owner, owners, property, etc.
|
||
|
||
# Complex property targets
|
||
complex_target_map = {
|
||
"owner": "Owners", "owners": "Owners",
|
||
"registerRecord": "RegisterRecords", "registerRecords": "RegisterRecords",
|
||
"basedOn": "BasedOn",
|
||
"inputByString": "InputByString",
|
||
"dataLockField": "DataLockFields", "dataLockFields": "DataLockFields",
|
||
"registeredDocument": "RegisteredDocuments", "registeredDocuments": "RegisteredDocuments",
|
||
}
|
||
|
||
if target in complex_target_map:
|
||
prop_name = complex_target_map[target]
|
||
values = [v.strip() for v in value.split(";;") if v.strip()]
|
||
# For InputByString, auto-prefix with MetaType.Name.
|
||
if prop_name == "InputByString":
|
||
prefix = f"{obj_type}.{obj_name}."
|
||
meta_types = (
|
||
"Catalog", "Document", "InformationRegister", "AccumulationRegister",
|
||
"AccountingRegister", "CalculationRegister", "ChartOfCharacteristicTypes",
|
||
"ChartOfCalculationTypes", "ChartOfAccounts", "ExchangePlan",
|
||
"BusinessProcess", "Task", "Enum", "Report", "DataProcessor",
|
||
)
|
||
new_values = []
|
||
for v in values:
|
||
if "." not in v:
|
||
new_values.append(f"{prefix}{v}")
|
||
elif not re.match(r"^(" + "|".join(meta_types) + r")\.", v):
|
||
new_values.append(f"{prefix}{v}")
|
||
else:
|
||
new_values.append(v)
|
||
values = new_values
|
||
complex_action = "set" if op == "set" else op
|
||
return {"_complex": [{"action": complex_action, "property": prop_name, "values": values}]}
|
||
|
||
# Предопределённые (Ext/Predefined.xml) — отдельный файл; строим { <op>: { predefined: [...] } }.
|
||
if target == "predefined":
|
||
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||
return {op: {"predefined": items}}
|
||
|
||
# TS attribute operations: dot notation "TSName.AttrDef"
|
||
if target == "ts-attribute":
|
||
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||
# Group by TS name
|
||
ts_groups = {}
|
||
ts_order = []
|
||
for item in items:
|
||
dot_idx = item.find(".")
|
||
if dot_idx <= 0:
|
||
warn(f"Invalid ts-attribute format (expected TSName.AttrDef): {item}")
|
||
continue
|
||
ts_name = item[:dot_idx].strip()
|
||
rest = item[dot_idx + 1:].strip()
|
||
if ts_name not in ts_groups:
|
||
ts_groups[ts_name] = []
|
||
ts_order.append(ts_name)
|
||
ts_groups[ts_name].append(rest)
|
||
|
||
# Build: { modify: { tabularSections: { TSName: { add/remove/modify: ... } } } }
|
||
ts_mod_obj = {}
|
||
for ts_name in ts_order:
|
||
ts_changes = {}
|
||
if op == "add":
|
||
ts_changes["add"] = ts_groups[ts_name]
|
||
elif op == "remove":
|
||
ts_changes["remove"] = ts_groups[ts_name]
|
||
elif op == "modify":
|
||
attr_mod_obj = {}
|
||
for elem_def in ts_groups[ts_name]:
|
||
colon_idx = elem_def.find(":")
|
||
if colon_idx <= 0:
|
||
warn(f"Invalid modify format (expected Name: key=val): {elem_def}")
|
||
continue
|
||
elem_name = elem_def[:colon_idx].strip()
|
||
changes_part = elem_def[colon_idx + 1:].strip()
|
||
changes_obj = {}
|
||
change_pairs = split_by_comma_outside_parens(changes_part)
|
||
for cp in change_pairs:
|
||
cp = cp.strip()
|
||
eq_idx = cp.find("=")
|
||
if eq_idx > 0:
|
||
ck = cp[:eq_idx].strip()
|
||
cv = cp[eq_idx + 1:].strip()
|
||
changes_obj[ck] = cv
|
||
attr_mod_obj[elem_name] = changes_obj
|
||
ts_changes["modify"] = attr_mod_obj
|
||
ts_mod_obj[ts_name] = ts_changes
|
||
|
||
return {"modify": {"tabularSections": ts_mod_obj}}
|
||
|
||
# Target -> JSON DSL child type
|
||
target_map = {
|
||
"attribute": "attributes",
|
||
"ts": "tabularSections",
|
||
"dimension": "dimensions",
|
||
"resource": "resources",
|
||
"enumValue": "enumValues",
|
||
"column": "columns",
|
||
"form": "forms",
|
||
"template": "templates",
|
||
"command": "commands",
|
||
"property": "properties",
|
||
}
|
||
|
||
child_type = target_map.get(target)
|
||
if not child_type:
|
||
die(f"Unknown inline target: {target}")
|
||
|
||
definition = {}
|
||
|
||
if op == "add":
|
||
items = []
|
||
if child_type == "tabularSections":
|
||
# TS format: "TSName: attr1_shorthand, attr2_shorthand, ..."
|
||
ts_values = [v.strip() for v in value.split(";;") if v.strip()]
|
||
for ts_val in ts_values:
|
||
colon_idx = ts_val.find(":")
|
||
if colon_idx > 0:
|
||
ts_name = ts_val[:colon_idx].strip()
|
||
attrs_part = ts_val[colon_idx + 1:].strip()
|
||
# Split attrs by comma (paren-aware), reassemble if part doesn't start with "Name:"
|
||
raw_parts = split_by_comma_outside_parens(attrs_part)
|
||
attr_strs = []
|
||
current = ""
|
||
for rp in raw_parts:
|
||
rp = rp.strip()
|
||
if current and re.match(r"^[А-Яа-яЁёA-Za-z_]\w*\s*:", rp):
|
||
attr_strs.append(current)
|
||
current = rp
|
||
elif current:
|
||
current += f", {rp}"
|
||
else:
|
||
current = rp
|
||
if current:
|
||
attr_strs.append(current)
|
||
items.append({"name": ts_name, "attrs": attr_strs})
|
||
else:
|
||
# Just a name, no attrs
|
||
items.append(ts_val)
|
||
else:
|
||
# Batch split by ;;
|
||
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||
definition["add"] = {child_type: items}
|
||
|
||
elif op == "remove":
|
||
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||
definition["remove"] = {child_type: items}
|
||
|
||
elif op == "modify":
|
||
if child_type == "properties":
|
||
# "CodeLength=11 ;; DescriptionLength=150"
|
||
kv_pairs = [v.strip() for v in value.split(";;") if v.strip()]
|
||
props_obj = {}
|
||
for kv in kv_pairs:
|
||
eq_idx = kv.find("=")
|
||
if eq_idx > 0:
|
||
k = kv[:eq_idx].strip()
|
||
v = kv[eq_idx + 1:].strip()
|
||
props_obj[k] = v
|
||
else:
|
||
warn(f"Invalid property format (expected Key=Value): {kv}")
|
||
definition["modify"] = {"properties": props_obj}
|
||
else:
|
||
# "ElementName: key=val, key=val ;; Element2: key=val"
|
||
elem_defs = [v.strip() for v in value.split(";;") if v.strip()]
|
||
child_mod_obj = {}
|
||
for elem_def in elem_defs:
|
||
colon_idx = elem_def.find(":")
|
||
if colon_idx <= 0:
|
||
warn(f"Invalid modify format (expected Name: key=val): {elem_def}")
|
||
continue
|
||
elem_name = elem_def[:colon_idx].strip()
|
||
changes_part = elem_def[colon_idx + 1:].strip()
|
||
changes_obj = {}
|
||
change_pairs = split_by_comma_outside_parens(changes_part)
|
||
for cp in change_pairs:
|
||
cp = cp.strip()
|
||
eq_idx = cp.find("=")
|
||
if eq_idx > 0:
|
||
ck = cp[:eq_idx].strip()
|
||
cv = cp[eq_idx + 1:].strip()
|
||
changes_obj[ck] = cv
|
||
child_mod_obj[elem_name] = changes_obj
|
||
definition["modify"] = {child_type: child_mod_obj}
|
||
|
||
return definition
|
||
|
||
|
||
# ============================================================
|
||
# ADD operations
|
||
# ============================================================
|
||
|
||
|
||
def find_insertion_point(xml_tag, parsed):
|
||
"""Find reference node for insertion. Returns element or None (meaning append)."""
|
||
if child_objects_el is None:
|
||
return None
|
||
|
||
# Positional: after/before
|
||
after_name = parsed.get("after", "")
|
||
before_name = parsed.get("before", "")
|
||
|
||
if after_name:
|
||
after_el = find_element_by_name(child_objects_el, xml_tag, after_name)
|
||
if after_el is not None:
|
||
# Insert after = insert before the next element sibling
|
||
nxt = after_el.getnext()
|
||
while nxt is not None and not isinstance(nxt.tag, str):
|
||
nxt = nxt.getnext()
|
||
if nxt is not None and localname(nxt) == xml_tag:
|
||
return nxt
|
||
return None # append
|
||
else:
|
||
warn(f"after='{after_name}': element '{after_name}' not found in {xml_tag}, appending")
|
||
|
||
if before_name:
|
||
before_el = find_element_by_name(child_objects_el, xml_tag, before_name)
|
||
if before_el is not None:
|
||
return before_el
|
||
warn(f"before='{before_name}': element '{before_name}' not found in {xml_tag}, appending")
|
||
|
||
# Default: after last element of this type, or in canonical position
|
||
last_of_type = find_last_element_of_type(child_objects_el, xml_tag)
|
||
if last_of_type is not None:
|
||
nxt = last_of_type.getnext()
|
||
while nxt is not None and not isinstance(nxt.tag, str):
|
||
nxt = nxt.getnext()
|
||
return nxt # None means append (correct: after last of type)
|
||
|
||
# No elements of this type yet -- find canonical position
|
||
if xml_tag in child_order:
|
||
tag_idx = child_order.index(xml_tag)
|
||
else:
|
||
return None
|
||
|
||
# Find first element of any type that comes AFTER in the canonical order
|
||
for i in range(tag_idx + 1, len(child_order)):
|
||
next_tag = child_order[i]
|
||
first_of_next = find_first_element_of_type(child_objects_el, next_tag)
|
||
if first_of_next is not None:
|
||
return first_of_next
|
||
|
||
return None # append at end
|
||
|
||
|
||
def process_add(add_def):
|
||
global add_count
|
||
|
||
for raw_key, items in add_def.items():
|
||
if raw_key in ('predefined', 'предопределенные', 'предопределённые'):
|
||
add_predefined_items(items)
|
||
continue
|
||
child_type = resolve_child_type_key(raw_key)
|
||
|
||
if not child_type:
|
||
warn(f"Unknown add child type: {raw_key}")
|
||
continue
|
||
|
||
# Validate allowed
|
||
allowed = valid_child_types.get(obj_type)
|
||
if allowed and child_type not in allowed:
|
||
warn(f"{child_type} not allowed for {obj_type}, skipping")
|
||
continue
|
||
|
||
xml_tag = child_type_to_xml_tag.get(child_type)
|
||
if not xml_tag:
|
||
warn(f"No XML tag mapping for {child_type}")
|
||
continue
|
||
|
||
ensure_child_objects_open()
|
||
indent = get_child_indent(child_objects_el)
|
||
existing_names = get_all_child_names()
|
||
|
||
if child_type == "attributes":
|
||
for item in items:
|
||
parsed = parse_attribute_shorthand(item)
|
||
if parsed["name"] in existing_names:
|
||
warn(f"Attribute '{parsed['name']}' already exists, skipping")
|
||
continue
|
||
context = get_attribute_context()
|
||
fragment_xml = build_attribute_fragment(parsed, context, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("Attribute", parsed)
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added attribute: {parsed['name']}")
|
||
add_count += 1
|
||
existing_names[parsed["name"]] = "Attribute"
|
||
|
||
elif child_type == "tabularSections":
|
||
for item in items:
|
||
if isinstance(item, str):
|
||
ts_name = item
|
||
ts_def = {"name": item}
|
||
else:
|
||
ts_name = str(item.get("name", ""))
|
||
ts_def = item
|
||
if ts_name in existing_names:
|
||
warn(f"TabularSection '{ts_name}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_tabular_section_fragment(ts_def, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("TabularSection", {"after": "", "before": ""})
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added tabular section: {ts_name}")
|
||
add_count += 1
|
||
existing_names[ts_name] = "TabularSection"
|
||
|
||
elif child_type == "dimensions":
|
||
for item in items:
|
||
parsed = parse_attribute_shorthand(item)
|
||
if parsed["name"] in existing_names:
|
||
warn(f"Dimension '{parsed['name']}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_dimension_fragment(parsed, obj_type, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("Dimension", parsed)
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added dimension: {parsed['name']}")
|
||
add_count += 1
|
||
existing_names[parsed["name"]] = "Dimension"
|
||
|
||
elif child_type == "resources":
|
||
for item in items:
|
||
parsed = parse_attribute_shorthand(item)
|
||
if parsed["name"] in existing_names:
|
||
warn(f"Resource '{parsed['name']}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_resource_fragment(parsed, obj_type, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("Resource", parsed)
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added resource: {parsed['name']}")
|
||
add_count += 1
|
||
existing_names[parsed["name"]] = "Resource"
|
||
|
||
elif child_type == "enumValues":
|
||
for item in items:
|
||
parsed = parse_enum_value_shorthand(item)
|
||
if parsed["name"] in existing_names:
|
||
warn(f"EnumValue '{parsed['name']}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_enum_value_fragment(parsed, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("EnumValue", parsed)
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added enum value: {parsed['name']}")
|
||
add_count += 1
|
||
existing_names[parsed["name"]] = "EnumValue"
|
||
|
||
elif child_type == "columns":
|
||
for item in items:
|
||
if isinstance(item, str):
|
||
col_name = item
|
||
else:
|
||
col_name = str(item.get("name", ""))
|
||
if col_name in existing_names:
|
||
warn(f"Column '{col_name}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_column_fragment(item, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point("Column", {"after": "", "before": ""})
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added column: {col_name}")
|
||
add_count += 1
|
||
existing_names[col_name] = "Column"
|
||
|
||
elif child_type in ("forms", "templates", "commands"):
|
||
tag_map = {"forms": "Form", "templates": "Template", "commands": "Command"}
|
||
tag = tag_map[child_type]
|
||
for item in items:
|
||
if isinstance(item, str):
|
||
item_name = item
|
||
else:
|
||
item_name = str(item.get("name", ""))
|
||
if item_name in existing_names:
|
||
warn(f"{tag} '{item_name}' already exists, skipping")
|
||
continue
|
||
fragment_xml = build_simple_child_fragment(tag, item_name, indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
ref_node = find_insertion_point(tag, {"after": "", "before": ""})
|
||
for node in nodes:
|
||
insert_before_element(child_objects_el, node, ref_node, indent)
|
||
info(f"Added {tag.lower()}: {item_name}")
|
||
add_count += 1
|
||
existing_names[item_name] = tag
|
||
|
||
|
||
# ============================================================
|
||
# REMOVE operations
|
||
# ============================================================
|
||
|
||
|
||
def process_remove(remove_def):
|
||
global remove_count
|
||
|
||
for raw_key, names in remove_def.items():
|
||
child_type = resolve_child_type_key(raw_key)
|
||
|
||
if not child_type:
|
||
warn(f"Unknown remove child type: {raw_key}")
|
||
continue
|
||
if child_type == "properties":
|
||
warn("Cannot remove properties -- use modify instead")
|
||
continue
|
||
|
||
xml_tag = child_type_to_xml_tag.get(child_type)
|
||
if not xml_tag or child_objects_el is None:
|
||
warn(f"No ChildObjects or unknown tag for {child_type}")
|
||
continue
|
||
|
||
for name in names:
|
||
name_str = str(name)
|
||
el = find_element_by_name(child_objects_el, xml_tag, name_str)
|
||
if el is None:
|
||
warn(f"{xml_tag} '{name_str}' not found, skipping remove")
|
||
continue
|
||
remove_node_with_whitespace(el)
|
||
info(f"Removed {xml_tag.lower()}: {name_str}")
|
||
remove_count += 1
|
||
|
||
# Collapse if empty
|
||
collapse_child_objects_if_empty()
|
||
|
||
|
||
# ============================================================
|
||
# MODIFY operations
|
||
# ============================================================
|
||
|
||
|
||
def modify_properties(props_def):
|
||
global modify_count
|
||
|
||
for prop_name, prop_value in props_def.items():
|
||
# Find the property element in Properties
|
||
prop_el = None
|
||
for child in properties_el:
|
||
if localname(child) == prop_name:
|
||
prop_el = child
|
||
break
|
||
|
||
if prop_el is None:
|
||
# create-if-missing: известное свойство создаём (порядок 1С терпит); неизвестное → ошибка (опечатка)
|
||
if prop_name not in known_object_props:
|
||
print(f"meta-edit: modify-property: неизвестное свойство '{prop_name}' — нет такого свойства объекта (опечатка?)", file=sys.stderr)
|
||
sys.exit(1)
|
||
new_nodes = import_fragment(f"<{prop_name}/>")
|
||
if new_nodes:
|
||
insert_property_in_order(properties_el, new_nodes[0], None, prop_name)
|
||
prop_el = new_nodes[0]
|
||
else:
|
||
warn(f"Property '{prop_name}': could not create element")
|
||
continue
|
||
|
||
# Complex property: Owners, RegisterRecords, BasedOn, InputByString
|
||
if prop_name in complex_property_map:
|
||
values_list = []
|
||
if isinstance(prop_value, list):
|
||
values_list = [str(v) for v in prop_value]
|
||
else:
|
||
values_list = [v.strip() for v in str(prop_value).split(";;") if v.strip()]
|
||
set_complex_property(prop_name, values_list)
|
||
continue
|
||
|
||
# Handle boolean values
|
||
value_str = str(prop_value)
|
||
if isinstance(prop_value, bool):
|
||
value_str = "true" if prop_value else "false"
|
||
|
||
# Set inner text — clear children first, set text
|
||
for ch in list(prop_el):
|
||
prop_el.remove(ch)
|
||
prop_el.text = value_str
|
||
info(f"Modified property: {prop_name} = {value_str}")
|
||
modify_count += 1
|
||
|
||
|
||
def modify_child_elements(modify_def, child_type):
|
||
global add_count, remove_count, modify_count, child_objects_el
|
||
|
||
xml_tag = child_type_to_xml_tag.get(child_type)
|
||
if not xml_tag or child_objects_el is None:
|
||
warn(f"No ChildObjects or unknown tag for {child_type}")
|
||
return
|
||
|
||
for elem_name, changes in modify_def.items():
|
||
el = find_element_by_name(child_objects_el, xml_tag, elem_name)
|
||
if el is None:
|
||
warn(f"{xml_tag} '{elem_name}' not found for modify")
|
||
continue
|
||
|
||
# Find Properties inside the element
|
||
props_el = None
|
||
for gc in el:
|
||
if localname(gc) == "Properties":
|
||
props_el = gc
|
||
break
|
||
if props_el is None:
|
||
warn(f"{xml_tag} '{elem_name}': no Properties element found")
|
||
continue
|
||
|
||
for change_prop, change_value in changes.items():
|
||
# TS child attribute operations (add/remove/modify attrs inside a TabularSection)
|
||
if xml_tag == "TabularSection" and change_prop in ("add", "remove", "modify"):
|
||
# Find ChildObjects inside this TS element
|
||
ts_child_obj_el = None
|
||
for gc in el:
|
||
if localname(gc) == "ChildObjects":
|
||
ts_child_obj_el = gc
|
||
break
|
||
|
||
if change_prop == "add":
|
||
if ts_child_obj_el is None:
|
||
warn(f"TS '{elem_name}' has no ChildObjects element, cannot add attributes")
|
||
continue
|
||
# Ensure ChildObjects is open (not self-closing empty)
|
||
has_ts_child_elements = any(True for _ in ts_child_obj_el)
|
||
if not has_ts_child_elements:
|
||
ts_co_indent = get_child_indent(el)
|
||
ts_child_obj_el.text = "\r\n" + ts_co_indent
|
||
attr_defs = change_value if isinstance(change_value, list) else [change_value]
|
||
for attr_def in attr_defs:
|
||
parsed = parse_attribute_shorthand(attr_def)
|
||
existing = find_element_by_name(ts_child_obj_el, "Attribute", parsed["name"])
|
||
if existing is not None:
|
||
warn(f"Attribute '{parsed['name']}' already exists in TS '{elem_name}', skipping")
|
||
continue
|
||
ts_attr_indent = get_child_indent(ts_child_obj_el)
|
||
ts_attr_context = "processor-tabular" if obj_type in ("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport") else "tabular"
|
||
fragment_xml = build_attribute_fragment(parsed, ts_attr_context, ts_attr_indent)
|
||
nodes = import_fragment(fragment_xml)
|
||
saved_co = child_objects_el
|
||
child_objects_el = ts_child_obj_el
|
||
ref_node = find_insertion_point("Attribute", parsed)
|
||
child_objects_el = saved_co
|
||
for node in nodes:
|
||
insert_before_element(ts_child_obj_el, node, ref_node, ts_attr_indent)
|
||
info(f"Added attribute to TS '{elem_name}': {parsed['name']}")
|
||
add_count += 1
|
||
|
||
elif change_prop == "remove":
|
||
if ts_child_obj_el is None:
|
||
warn(f"TS '{elem_name}' has no ChildObjects, cannot remove attributes")
|
||
continue
|
||
attr_names = change_value if isinstance(change_value, list) else [change_value]
|
||
for attr_name in attr_names:
|
||
attr_el = find_element_by_name(ts_child_obj_el, "Attribute", str(attr_name))
|
||
if attr_el is None:
|
||
warn(f"Attribute '{attr_name}' not found in TS '{elem_name}', skipping")
|
||
continue
|
||
remove_node_with_whitespace(attr_el)
|
||
info(f"Removed attribute from TS '{elem_name}': {attr_name}")
|
||
remove_count += 1
|
||
|
||
elif change_prop == "modify":
|
||
if ts_child_obj_el is None:
|
||
warn(f"TS '{elem_name}' has no ChildObjects, cannot modify attributes")
|
||
continue
|
||
# Temporarily swap childObjectsEl and recurse
|
||
saved_child_obj_el = child_objects_el
|
||
child_objects_el = ts_child_obj_el
|
||
modify_child_elements(change_value, "attributes")
|
||
child_objects_el = saved_child_obj_el
|
||
|
||
continue # Skip normal property modification
|
||
|
||
if change_prop == "name":
|
||
# Rename
|
||
name_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "Name":
|
||
name_el = gc
|
||
break
|
||
if name_el is not None:
|
||
old_name = (name_el.text or "").strip()
|
||
new_name = str(change_value)
|
||
name_el.text = new_name
|
||
|
||
# Update Synonym if it was auto-generated
|
||
old_synonym = split_camel_case(old_name)
|
||
syn_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "Synonym":
|
||
syn_el = gc
|
||
break
|
||
if syn_el is not None:
|
||
# Check if current synonym matches auto-generated from old name
|
||
current_syn = ""
|
||
for item_el in syn_el:
|
||
if localname(item_el) == "item":
|
||
for gc in item_el:
|
||
if localname(gc) == "content":
|
||
current_syn = (gc.text or "").strip()
|
||
if current_syn == old_synonym or not current_syn:
|
||
new_synonym = split_camel_case(new_name)
|
||
syn_indent = get_child_indent(props_el)
|
||
new_syn_xml = build_mltext_xml(syn_indent, "Synonym", new_synonym)
|
||
new_syn_nodes = import_fragment(new_syn_xml)
|
||
if new_syn_nodes:
|
||
# Insert new synonym after old, then remove old
|
||
syn_idx = list(props_el).index(syn_el)
|
||
new_syn_nodes[0].tail = syn_el.tail
|
||
props_el.insert(syn_idx + 1, new_syn_nodes[0])
|
||
remove_node_with_whitespace(syn_el)
|
||
|
||
info(f"Renamed {xml_tag}: {old_name} -> {new_name}")
|
||
modify_count += 1
|
||
|
||
elif change_prop == "type":
|
||
# Change type
|
||
type_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "Type":
|
||
type_el = gc
|
||
break
|
||
new_type_str = str(change_value)
|
||
type_indent = get_child_indent(props_el)
|
||
new_type_xml = build_value_type_xml(type_indent, new_type_str)
|
||
new_type_nodes = import_fragment(new_type_xml)
|
||
|
||
if type_el is not None and new_type_nodes:
|
||
type_idx = list(props_el).index(type_el)
|
||
new_type_nodes[0].tail = type_el.tail
|
||
props_el.insert(type_idx + 1, new_type_nodes[0])
|
||
remove_node_with_whitespace(type_el)
|
||
elif new_type_nodes:
|
||
# No existing Type -- insert after Comment
|
||
comment_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "Comment":
|
||
comment_el = gc
|
||
break
|
||
if comment_el is not None:
|
||
comment_idx = list(props_el).index(comment_el)
|
||
nxt = comment_el.getnext()
|
||
insert_before_element(props_el, new_type_nodes[0], nxt, type_indent)
|
||
|
||
# Also update FillValue if present
|
||
fill_val_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "FillValue":
|
||
fill_val_el = gc
|
||
break
|
||
if fill_val_el is not None:
|
||
fill_indent = get_child_indent(props_el)
|
||
new_fill_xml = build_fill_value_xml(fill_indent, new_type_str)
|
||
new_fill_nodes = import_fragment(new_fill_xml)
|
||
if new_fill_nodes:
|
||
fill_idx = list(props_el).index(fill_val_el)
|
||
new_fill_nodes[0].tail = fill_val_el.tail
|
||
props_el.insert(fill_idx + 1, new_fill_nodes[0])
|
||
remove_node_with_whitespace(fill_val_el)
|
||
|
||
info(f"Changed type of {xml_tag} '{elem_name}': {new_type_str}")
|
||
modify_count += 1
|
||
|
||
elif change_prop == "synonym":
|
||
syn_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == "Synonym":
|
||
syn_el = gc
|
||
break
|
||
syn_indent = get_child_indent(props_el)
|
||
new_syn_xml = build_mltext_xml(syn_indent, "Synonym", str(change_value))
|
||
new_syn_nodes = import_fragment(new_syn_xml)
|
||
if syn_el is not None and new_syn_nodes:
|
||
syn_idx = list(props_el).index(syn_el)
|
||
new_syn_nodes[0].tail = syn_el.tail
|
||
props_el.insert(syn_idx + 1, new_syn_nodes[0])
|
||
remove_node_with_whitespace(syn_el)
|
||
info(f"Changed synonym of {xml_tag} '{elem_name}': {change_value}")
|
||
modify_count += 1
|
||
|
||
elif change_prop == "Format":
|
||
if set_attr_property_element(props_el, "Format", build_mltext_xml(get_child_indent(props_el), "Format", str(change_value))):
|
||
info(f"Set {xml_tag} '{elem_name}'.Format")
|
||
modify_count += 1
|
||
elif change_prop == "EditFormat":
|
||
if set_attr_property_element(props_el, "EditFormat", build_mltext_xml(get_child_indent(props_el), "EditFormat", str(change_value))):
|
||
info(f"Set {xml_tag} '{elem_name}'.EditFormat")
|
||
modify_count += 1
|
||
elif change_prop == "ToolTip":
|
||
if set_attr_property_element(props_el, "ToolTip", build_mltext_xml(get_child_indent(props_el), "ToolTip", str(change_value))):
|
||
info(f"Set {xml_tag} '{elem_name}'.ToolTip")
|
||
modify_count += 1
|
||
elif change_prop == "ChoiceForm":
|
||
if set_attr_property_element(props_el, "ChoiceForm", f"<ChoiceForm>{esc_xml(str(change_value))}</ChoiceForm>"):
|
||
info(f"Set {xml_tag} '{elem_name}'.ChoiceForm")
|
||
modify_count += 1
|
||
elif change_prop == "MinValue":
|
||
if set_attr_property_element(props_el, "MinValue", build_min_max_value_xml("MinValue", change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.MinValue")
|
||
modify_count += 1
|
||
elif change_prop == "MaxValue":
|
||
if set_attr_property_element(props_el, "MaxValue", build_min_max_value_xml("MaxValue", change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.MaxValue")
|
||
modify_count += 1
|
||
elif change_prop == "LinkByType":
|
||
if set_attr_property_element(props_el, "LinkByType", build_link_by_type_xml(get_child_indent(props_el), change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.LinkByType")
|
||
modify_count += 1
|
||
elif change_prop == "ChoiceParameterLinks":
|
||
if set_attr_property_element(props_el, "ChoiceParameterLinks", build_choice_parameter_links_xml(get_child_indent(props_el), change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.ChoiceParameterLinks")
|
||
modify_count += 1
|
||
elif change_prop == "ChoiceParameters":
|
||
if set_attr_property_element(props_el, "ChoiceParameters", build_choice_parameters_xml(get_child_indent(props_el), change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.ChoiceParameters")
|
||
modify_count += 1
|
||
elif change_prop == "FillValue":
|
||
if set_attr_property_element(props_el, "FillValue", build_fill_value_explicit_xml(get_attr_type_str_from_xml(props_el), change_value)):
|
||
info(f"Set {xml_tag} '{elem_name}'.FillValue")
|
||
modify_count += 1
|
||
|
||
else:
|
||
# Scalar property change (Indexing, FillChecking, Use, etc.)
|
||
scalar_el = None
|
||
for gc in props_el:
|
||
if localname(gc) == change_prop:
|
||
scalar_el = gc
|
||
break
|
||
if scalar_el is not None:
|
||
value_str = str(change_value)
|
||
if isinstance(change_value, bool):
|
||
value_str = "true" if change_value else "false"
|
||
else:
|
||
value_str = normalize_enum_value(change_prop, value_str)
|
||
# Clear children and set text
|
||
for ch in list(scalar_el):
|
||
scalar_el.remove(ch)
|
||
scalar_el.text = value_str
|
||
info(f"Modified {xml_tag} '{elem_name}'.{change_prop} = {value_str}")
|
||
modify_count += 1
|
||
else:
|
||
# create-if-missing: известное свойство создаём в позиции; неизвестное → ошибка (опечатка)
|
||
if change_prop not in known_child_props:
|
||
print(f"meta-edit: modify: неизвестное свойство '{change_prop}' у {xml_tag} '{elem_name}' (опечатка?)", file=sys.stderr)
|
||
sys.exit(1)
|
||
value_str = str(change_value)
|
||
if isinstance(change_value, bool):
|
||
value_str = "true" if change_value else "false"
|
||
else:
|
||
value_str = normalize_enum_value(change_prop, value_str)
|
||
new_nodes = import_fragment(f"<{change_prop}>{esc_xml(value_str)}</{change_prop}>")
|
||
if new_nodes:
|
||
insert_property_in_order(props_el, new_nodes[0], attr_prop_order, change_prop)
|
||
info(f"Created {xml_tag} '{elem_name}'.{change_prop} = {value_str}")
|
||
modify_count += 1
|
||
|
||
|
||
def process_modify(modify_def):
|
||
for raw_key, value in modify_def.items():
|
||
child_type = resolve_child_type_key(raw_key)
|
||
|
||
if not child_type:
|
||
warn(f"Unknown modify child type: {raw_key}")
|
||
continue
|
||
|
||
if child_type == "properties":
|
||
modify_properties(value)
|
||
else:
|
||
modify_child_elements(value, child_type)
|
||
|
||
|
||
# ============================================================
|
||
# Complex property helpers
|
||
# ============================================================
|
||
|
||
complex_property_map = {
|
||
"Owners": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||
"RegisterRecords": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||
"BasedOn": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||
"InputByString": {"tag": "xr:Field", "attr": None},
|
||
"DataLockFields": {"tag": "xr:Field", "attr": None, "expand": True},
|
||
"RegisteredDocuments": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||
}
|
||
|
||
# Известные свойства объекта (union по корпусу acc+erp 8.3.24) — allowlist для modify-property.
|
||
# Известное отсутствующее свойство create-if-missing создаётся; неизвестное (опечатка) → ошибка.
|
||
known_object_props = {
|
||
'ActionPeriod', 'ActionPeriodUse', 'Addressing', 'AutoOrderByCode', 'Autonumbering', 'AuxiliaryChoiceForm',
|
||
'AuxiliaryFolderChoiceForm', 'AuxiliaryFolderForm', 'AuxiliaryForm', 'AuxiliaryListForm', 'AuxiliaryObjectForm',
|
||
'AuxiliaryRecordForm', 'AuxiliarySettingsForm', 'BaseCalculationTypes', 'BasePeriod', 'BasedOn',
|
||
'CharacteristicExtValues', 'Characteristics', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'CheckUnique',
|
||
'ChoiceDataGetModeOnInputByString', 'ChoiceFoldersAndItems', 'ChoiceForm', 'ChoiceHistoryOnInput', 'ChoiceMode',
|
||
'ChoiceParameterLinks', 'ChoiceParameters', 'CodeAllowedLength', 'CodeLength', 'CodeMask', 'CodeSeries', 'CodeType',
|
||
'Comment', 'Correspondence', 'CreateOnInput', 'CreateTaskInPrivilegedMode', 'CurrentPerformer', 'DataHistory',
|
||
'DataLockControlMode', 'DataLockFields', 'DefaultChoiceForm', 'DefaultFolderChoiceForm', 'DefaultFolderForm',
|
||
'DefaultForm', 'DefaultListForm', 'DefaultObjectForm', 'DefaultPresentation', 'DefaultRecordForm', 'DefaultSettingsForm',
|
||
'DefaultVariantForm', 'DependenceOnCalculationTypes', 'DescriptionLength', 'DistributedInfoBase', 'EditFormat',
|
||
'EditType', 'EnableTotalsSliceFirst', 'EnableTotalsSliceLast', 'EnableTotalsSplitting',
|
||
'ExecuteAfterWriteDataHistoryVersionProcessing', 'Explanation', 'ExtDimensionTypes', 'ExtendedEdit',
|
||
'ExtendedListPresentation', 'ExtendedObjectPresentation', 'ExtendedPresentation', 'ExtendedRecordPresentation',
|
||
'FillChecking', 'FoldersOnTop', 'Format', 'FullTextSearch', 'FullTextSearchOnInputByString', 'Hierarchical',
|
||
'HierarchyType', 'IncludeConfigurationExtensions', 'IncludeHelpInContents', 'InformationRegisterPeriodicity',
|
||
'InputByString', 'LevelCount', 'LimitLevelCount', 'LinkByType', 'ListPresentation', 'MainAddressingAttribute',
|
||
'MainDataCompositionSchema', 'MainFilterOnPeriod', 'MarkNegatives', 'Mask', 'MaxExtDimensionCount', 'MaxValue',
|
||
'MinValue', 'MultiLine', 'Name', 'NumberAllowedLength', 'NumberLength', 'NumberPeriodicity', 'NumberType', 'Numerator',
|
||
'ObjectPresentation', 'OrderLength', 'Owners', 'PasswordMode', 'PeriodAdjustmentLength', 'Periodicity',
|
||
'PostInPrivilegedMode', 'Posting', 'PredefinedDataUpdate', 'QuickChoice', 'RealTimePosting', 'RecordPresentation',
|
||
'RegisterRecords', 'RegisterRecordsDeletion', 'RegisterRecordsWritingOnPost', 'RegisterType', 'RegisteredDocuments',
|
||
'Schedule', 'ScheduleDate', 'ScheduleValue', 'SearchStringModeOnInputByString', 'SequenceFilling', 'SettingsStorage',
|
||
'StandardAttributes', 'StandardTabularSections', 'SubordinationUse', 'Synonym', 'Task', 'TaskNumberAutoPrefix',
|
||
'ToolTip', 'Type', 'UnpostInPrivilegedMode', 'UpdateDataHistoryImmediatelyAfterWrite', 'UseStandardCommands',
|
||
'VariantsStorage', 'WriteMode',
|
||
}
|
||
|
||
# Известные свойства дочерних элементов (union Attribute/Dimension/Resource) — allowlist default-ветки modify-child.
|
||
known_child_props = {
|
||
'AccountingFlag', 'Balance', 'BaseDimension', 'ChoiceFoldersAndItems', 'ChoiceForm', 'ChoiceHistoryOnInput',
|
||
'ChoiceParameterLinks', 'ChoiceParameters', 'Comment', 'CreateOnInput', 'DataHistory', 'DenyIncompleteValues',
|
||
'DocumentMap', 'EditFormat', 'ExtDimensionAccountingFlag', 'ExtendedEdit', 'FillChecking', 'FillFromFillingValue',
|
||
'FillValue', 'Format', 'FullTextSearch', 'Indexing', 'LinkByType', 'MainFilter', 'MarkNegatives', 'Mask', 'Master',
|
||
'MaxValue', 'MinValue', 'MultiLine', 'Name', 'PasswordMode', 'QuickChoice', 'RegisterRecordsMap', 'ScheduleLink',
|
||
'Synonym', 'ToolTip', 'Type', 'Use', 'UseInTotals',
|
||
}
|
||
|
||
# Канонический порядок свойств реквизита (последовательность build_attribute_fragment) — для вставки в позицию.
|
||
attr_prop_order = [
|
||
'Name', 'Synonym', 'Comment', 'Type', 'PasswordMode', 'Format', 'EditFormat', 'ToolTip', 'MarkNegatives', 'Mask',
|
||
'MultiLine', 'ExtendedEdit', 'MinValue', 'MaxValue', 'FillFromFillingValue', 'FillValue', 'FillChecking',
|
||
'ChoiceFoldersAndItems', 'ChoiceParameterLinks', 'ChoiceParameters', 'QuickChoice', 'CreateOnInput', 'ChoiceForm',
|
||
'LinkByType', 'ChoiceHistoryOnInput', 'Use', 'Indexing', 'FullTextSearch', 'DataHistory',
|
||
]
|
||
|
||
|
||
def insert_property_in_order(props_el, new_node, order_array, prop_name):
|
||
"""Вставить новое свойство в Properties в канонической позиции (по order_array); иначе append."""
|
||
child_indent = get_child_indent(props_el)
|
||
ref_node = None
|
||
idx = order_array.index(prop_name) if (order_array and prop_name in order_array) else -1
|
||
if idx >= 0:
|
||
for ch in props_el:
|
||
cl = localname(ch)
|
||
if cl in order_array and order_array.index(cl) > idx:
|
||
ref_node = ch
|
||
break
|
||
insert_before_element(props_el, new_node, ref_node, child_indent)
|
||
|
||
|
||
def set_attr_property_element(props_el, prop_name, fragment_xml):
|
||
"""Заменить существующий элемент свойства реквизита новым фрагментом, либо создать в позиции."""
|
||
new_nodes = import_fragment(fragment_xml)
|
||
if not new_nodes:
|
||
return False
|
||
existing = None
|
||
for ch in props_el:
|
||
if localname(ch) == prop_name:
|
||
existing = ch
|
||
break
|
||
if existing is not None:
|
||
idx = list(props_el).index(existing)
|
||
new_nodes[0].tail = existing.tail
|
||
props_el.insert(idx + 1, new_nodes[0])
|
||
remove_node_with_whitespace(existing)
|
||
else:
|
||
insert_property_in_order(props_el, new_nodes[0], attr_prop_order, prop_name)
|
||
return True
|
||
|
||
|
||
def build_min_max_value_xml(tag, val):
|
||
"""MinValue/MaxValue — типизированное значение (порт Emit-MinMaxValue): nil / xs:string / xs:decimal."""
|
||
if val is None or str(val) == '':
|
||
return f'<{tag} xsi:nil="true"/>'
|
||
t = 'xs:string' if isinstance(val, str) else 'xs:decimal'
|
||
return f'<{tag} xsi:type="{t}">{esc_xml(str(val))}</{tag}>'
|
||
|
||
|
||
# --- Порт из meta-compile: развёртка путей данных + связи выбора / тип по ссылке (structural modify) ---
|
||
|
||
def get_ch_el_prop(obj, names):
|
||
"""Свойство из dict по списку синонимов (первый найденный, иначе None)."""
|
||
if obj is None:
|
||
return None
|
||
if isinstance(obj, dict):
|
||
for n in names:
|
||
if n in obj:
|
||
return obj[n]
|
||
return None
|
||
|
||
|
||
# Пары EN↔RU стандартных реквизитов Catalog/Document (для resolve_std_attr_en; py-карты плоские, восстанавливаем).
|
||
_STD_ATTR_BY_CTX = {
|
||
'Catalog': [('Ref', 'Ссылка'), ('DeletionMark', 'ПометкаУдаления'), ('Predefined', 'Предопределенный'),
|
||
('PredefinedDataName', 'ИмяПредопределенныхДанных'), ('Code', 'Код'), ('Description', 'Наименование'),
|
||
('Owner', 'Владелец'), ('Parent', 'Родитель'), ('IsFolder', 'ЭтоГруппа')],
|
||
'Document': [('Ref', 'Ссылка'), ('DeletionMark', 'ПометкаУдаления'), ('Date', 'Дата'),
|
||
('Number', 'Номер'), ('Posted', 'Проведен')],
|
||
}
|
||
|
||
|
||
def resolve_std_attr_en(name):
|
||
"""Стандартный реквизит рус/англ → английский (Catalog/Document)."""
|
||
pairs = _STD_ATTR_BY_CTX.get(obj_type)
|
||
if not pairs:
|
||
return None
|
||
nl = str(name).lower()
|
||
for en, ru in pairs:
|
||
if nl == en.lower() or nl == ru.lower():
|
||
return en
|
||
return None
|
||
|
||
|
||
def expand_data_path(dp):
|
||
"""Прощающий ввод пути данных: короткое имя реквизита → полный путь объекта (порт Expand-DataPath)."""
|
||
if not dp:
|
||
return dp
|
||
s = str(dp)
|
||
if re.search(r'[:/]', s):
|
||
return s
|
||
if re.match(r'^-?\d+$', s):
|
||
return s
|
||
if re.match(r'^(StandardAttribute|Attribute)\.', s):
|
||
return f"{obj_type}.{obj_name}.{s}"
|
||
if '.' not in s:
|
||
en = resolve_std_attr_en(s)
|
||
if en:
|
||
return f"{obj_type}.{obj_name}.StandardAttribute.{en}"
|
||
return f"{obj_type}.{obj_name}.Attribute.{s}"
|
||
return s
|
||
|
||
|
||
def convert_from_ch_link_shorthand(s):
|
||
"""Shorthand "name=path" | "name=path:Clear|DontChange" → {name, dataPath, valueChange?}."""
|
||
eq = s.find('=')
|
||
if eq < 0:
|
||
return {'name': s.strip()}
|
||
o = {'name': s[:eq].strip()}
|
||
rest = s[eq + 1:].strip()
|
||
m = re.match(r'^(.*):(Clear|DontChange|очистить|неизменять)$', rest, re.IGNORECASE)
|
||
if m:
|
||
o['dataPath'] = m.group(1).strip()
|
||
o['valueChange'] = m.group(2)
|
||
else:
|
||
o['dataPath'] = rest
|
||
return o
|
||
|
||
|
||
def build_link_by_type_xml(indent, spec):
|
||
"""LinkByType — {dataPath, linkItem?} (порт Emit-LinkByType)."""
|
||
if not spec:
|
||
return f"{indent}<LinkByType/>"
|
||
if isinstance(spec, str):
|
||
dp = spec
|
||
li = 0
|
||
else:
|
||
dp = str(get_ch_el_prop(spec, ['dataPath', 'path', 'путь']) or '')
|
||
li_raw = get_ch_el_prop(spec, ['linkItem', 'элементСвязи'])
|
||
li = li_raw if li_raw is not None else 0
|
||
if not dp:
|
||
return f"{indent}<LinkByType/>"
|
||
dp = expand_data_path(dp)
|
||
return "\r\n".join([
|
||
f"{indent}<LinkByType>",
|
||
f"{indent}\t<xr:DataPath>{esc_xml(str(dp))}</xr:DataPath>",
|
||
f"{indent}\t<xr:LinkItem>{li}</xr:LinkItem>",
|
||
f"{indent}</LinkByType>",
|
||
])
|
||
|
||
|
||
def build_choice_parameter_links_xml(indent, cpl):
|
||
"""ChoiceParameterLinks — [{name, dataPath, valueChange?}] (порт Emit-ChoiceParameterLinks)."""
|
||
items = cpl if isinstance(cpl, list) else ([cpl] if cpl else [])
|
||
if not items:
|
||
return f"{indent}<ChoiceParameterLinks/>"
|
||
parts = [f"{indent}<ChoiceParameterLinks>"]
|
||
for lk in items:
|
||
if isinstance(lk, str):
|
||
lk = convert_from_ch_link_shorthand(lk)
|
||
name = get_ch_el_prop(lk, ['name', 'имя'])
|
||
dp = expand_data_path(get_ch_el_prop(lk, ['dataPath', 'path', 'путь']))
|
||
vc_raw = get_ch_el_prop(lk, ['valueChange', 'режимИзменения'])
|
||
vc = 'Clear'
|
||
if vc_raw:
|
||
low = str(vc_raw).lower()
|
||
if re.match(r'^(clear|очистить|очистка)$', low):
|
||
vc = 'Clear'
|
||
elif re.match(r'^(dontchange|неизменять|неменять|нет)$', low):
|
||
vc = 'DontChange'
|
||
else:
|
||
vc = str(vc_raw)
|
||
parts.append(f"{indent}\t<xr:Link>")
|
||
parts.append(f"{indent}\t\t<xr:Name>{esc_xml(str(name) if name is not None else '')}</xr:Name>")
|
||
parts.append(f'{indent}\t\t<xr:DataPath xsi:type="xs:string">{esc_xml(str(dp) if dp is not None else "")}</xr:DataPath>')
|
||
parts.append(f"{indent}\t\t<xr:ValueChange>{vc}</xr:ValueChange>")
|
||
parts.append(f"{indent}\t</xr:Link>")
|
||
parts.append(f"{indent}</ChoiceParameterLinks>")
|
||
return "\r\n".join(parts)
|
||
|
||
|
||
# --- Порт из meta-compile: значения параметров выбора (ChoiceParameters) ---
|
||
|
||
fill_ref_roots = {
|
||
'перечисление': 'Enum', 'справочник': 'Catalog', 'документ': 'Document',
|
||
'плансчетов': 'ChartOfAccounts', 'планвидовхарактеристик': 'ChartOfCharacteristicTypes',
|
||
'планвидоврасчета': 'ChartOfCalculationTypes', 'планвидоврасчёта': 'ChartOfCalculationTypes',
|
||
'планобмена': 'ExchangePlan', 'бизнеспроцесс': 'BusinessProcess', 'задача': 'Task',
|
||
'enum': 'Enum', 'catalog': 'Catalog', 'document': 'Document', 'chartofaccounts': 'ChartOfAccounts',
|
||
'chartofcharacteristictypes': 'ChartOfCharacteristicTypes', 'chartofcalculationtypes': 'ChartOfCalculationTypes',
|
||
'exchangeplan': 'ExchangePlan', 'businessprocess': 'BusinessProcess', 'task': 'Task',
|
||
}
|
||
fill_empty_ref_words = {'emptyref', 'пустаяссылка'}
|
||
fill_enum_val_words = {'enumvalue', 'значениеперечисления'}
|
||
account_type_values = ('Active', 'Passive', 'ActivePassive')
|
||
fill_ref_kind_root = {
|
||
'catalogref': 'Catalog', 'documentref': 'Document', 'enumref': 'Enum',
|
||
'chartofaccountsref': 'ChartOfAccounts', 'chartofcharacteristictypesref': 'ChartOfCharacteristicTypes',
|
||
'chartofcalculationtypesref': 'ChartOfCalculationTypes', 'exchangeplanref': 'ExchangePlan',
|
||
'businessprocessref': 'BusinessProcess', 'taskref': 'Task',
|
||
}
|
||
|
||
|
||
def convert_to_ch_scalar(s):
|
||
t = str(s).strip()
|
||
if re.match(r'^(true|истина)$', t, re.IGNORECASE):
|
||
return True
|
||
if re.match(r'^(false|ложь)$', t, re.IGNORECASE):
|
||
return False
|
||
if re.match(r'^-?\d+$', t):
|
||
return int(t)
|
||
if re.match(r'^-?\d+\.\d+$', t):
|
||
return float(t)
|
||
return t
|
||
|
||
|
||
def format_fill_num(n):
|
||
if isinstance(n, float):
|
||
return str(int(n)) if n == int(n) else repr(n)
|
||
return str(n)
|
||
|
||
|
||
def normalize_fill_ref(s):
|
||
if not s:
|
||
return None
|
||
if re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.[0-9a-fA-F-]+$', s):
|
||
return s
|
||
parts = s.split('.')
|
||
if len(parts) < 2:
|
||
return None
|
||
root = fill_ref_roots.get(parts[0].lower())
|
||
if not root:
|
||
return None
|
||
type_name = parts[1]
|
||
if root == 'Enum':
|
||
if len(parts) == 2:
|
||
return None
|
||
if len(parts) == 3:
|
||
if parts[2].lower() in fill_empty_ref_words:
|
||
return f"Enum.{type_name}.EmptyRef"
|
||
return f"Enum.{type_name}.EnumValue.{parts[2]}"
|
||
member = parts[2]
|
||
rest = '.'.join(parts[3:]) if member.lower() in fill_enum_val_words else '.'.join(parts[2:])
|
||
return f"Enum.{type_name}.EnumValue.{rest}"
|
||
tail = list(parts[1:])
|
||
for i in range(len(tail)):
|
||
if tail[i].lower() in fill_empty_ref_words:
|
||
tail[i] = 'EmptyRef'
|
||
return f"{root}." + '.'.join(tail)
|
||
|
||
|
||
def expand_choice_ref_value(value, type_str):
|
||
if not type_str:
|
||
return None
|
||
t = resolve_type_str(type_str)
|
||
root = None
|
||
tn = None
|
||
m = re.match(r'^(\w+Ref)\.(.+)$', t)
|
||
if m:
|
||
root = fill_ref_kind_root.get(m.group(1).lower())
|
||
tn = m.group(2)
|
||
else:
|
||
m2 = re.match(r'^([^.]+)\.(.+)$', t)
|
||
if m2:
|
||
root = fill_ref_roots.get(m2.group(1).lower())
|
||
tn = m2.group(2)
|
||
if not root:
|
||
return None
|
||
if str(value).lower() in fill_empty_ref_words:
|
||
return f"{root}.{tn}.EmptyRef"
|
||
if root == 'Enum':
|
||
return f"Enum.{tn}.EnumValue.{value}"
|
||
return f"{root}.{tn}.{value}"
|
||
|
||
|
||
def normalize_choice_value(value):
|
||
if isinstance(value, bool):
|
||
return {'XsiType': 'xs:boolean', 'Text': 'true' if value else 'false'}
|
||
if isinstance(value, (int, float)):
|
||
return {'XsiType': 'xs:decimal', 'Text': format_fill_num(value)}
|
||
s = str(value)
|
||
if s == '':
|
||
return {'XsiType': 'xs:string', 'Text': ''}
|
||
if re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$', s):
|
||
return {'XsiType': 'xs:dateTime', 'Text': s}
|
||
ref = normalize_fill_ref(s)
|
||
if ref:
|
||
return {'XsiType': 'xr:DesignTimeRef', 'Text': ref}
|
||
if s in account_type_values:
|
||
return {'XsiType': 'ent:AccountType', 'Text': s}
|
||
return {'XsiType': 'xs:string', 'Text': s}
|
||
|
||
|
||
def normalize_choice_value_t(value, type_str):
|
||
if type_str and isinstance(value, str) and '.' not in str(value):
|
||
ex = expand_choice_ref_value(value, type_str)
|
||
if ex:
|
||
return {'XsiType': 'xr:DesignTimeRef', 'Text': ex}
|
||
return normalize_choice_value(value)
|
||
|
||
|
||
def convert_from_ch_param_shorthand(s):
|
||
eq = s.find('=')
|
||
if eq < 0:
|
||
return {'name': s.strip()}
|
||
name = s[:eq].strip()
|
||
rest = s[eq + 1:]
|
||
if ',' in rest:
|
||
return {'name': name, 'value': [convert_to_ch_scalar(p) for p in rest.split(',')]}
|
||
return {'name': name, 'value': convert_to_ch_scalar(rest)}
|
||
|
||
|
||
def build_choice_parameters_xml(indent, cp):
|
||
"""ChoiceParameters — [{name, type?, value?}] (порт Emit-ChoiceParameters). Требует xmlns:app в import_fragment."""
|
||
items = cp if isinstance(cp, list) else ([cp] if cp else [])
|
||
if not items:
|
||
return f"{indent}<ChoiceParameters/>"
|
||
parts = [f"{indent}<ChoiceParameters>"]
|
||
for item in items:
|
||
if isinstance(item, str):
|
||
item = convert_from_ch_param_shorthand(item)
|
||
name = get_ch_el_prop(item, ['name', 'имя'])
|
||
ptype = get_ch_el_prop(item, ['type', 'тип'])
|
||
has_val = False
|
||
val = None
|
||
if isinstance(item, dict):
|
||
if 'value' in item:
|
||
has_val = True
|
||
val = item['value']
|
||
elif 'значение' in item:
|
||
has_val = True
|
||
val = item['значение']
|
||
val_is_array = isinstance(val, list)
|
||
parts.append(f'{indent}\t<app:item name="{esc_xml(str(name) if name is not None else "")}">')
|
||
if not has_val:
|
||
parts.append(f'{indent}\t\t<app:value xsi:nil="true"/>')
|
||
elif val_is_array:
|
||
parts.append(f'{indent}\t\t<app:value xsi:type="v8:FixedArray">')
|
||
for v in val:
|
||
norm = normalize_choice_value_t(v, ptype)
|
||
if not norm['Text']:
|
||
parts.append(f'{indent}\t\t\t<v8:Value xsi:type="{norm["XsiType"]}"/>')
|
||
else:
|
||
parts.append(f'{indent}\t\t\t<v8:Value xsi:type="{norm["XsiType"]}">{esc_xml(norm["Text"])}</v8:Value>')
|
||
parts.append(f'{indent}\t\t</app:value>')
|
||
else:
|
||
norm = normalize_choice_value_t(val, ptype)
|
||
if not norm['Text']:
|
||
parts.append(f'{indent}\t\t<app:value xsi:type="{norm["XsiType"]}"/>')
|
||
else:
|
||
parts.append(f'{indent}\t\t<app:value xsi:type="{norm["XsiType"]}">{esc_xml(norm["Text"])}</app:value>')
|
||
parts.append(f'{indent}\t</app:item>')
|
||
parts.append(f"{indent}</ChoiceParameters>")
|
||
return "\r\n".join(parts)
|
||
|
||
|
||
# --- Порт из meta-compile: явное значение заполнения (FillValue) ---
|
||
|
||
fill_bool_true = {'true', 'истина', 'да'}
|
||
fill_bool_false = {'false', 'ложь', 'нет'}
|
||
|
||
|
||
def esc_xml_text(s):
|
||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||
|
||
|
||
def get_fill_type_category(type_str):
|
||
if not type_str:
|
||
return 'String'
|
||
if '+' in type_str:
|
||
return 'Other'
|
||
t = resolve_type_str(type_str)
|
||
if re.match(r'^Boolean$', t):
|
||
return 'Boolean'
|
||
if re.match(r'^String(\(|$)', t):
|
||
return 'String'
|
||
if re.match(r'^Number(\(|$)', t):
|
||
return 'Number'
|
||
if re.match(r'^(Date|DateTime)$', t):
|
||
return 'Date'
|
||
return 'Other'
|
||
|
||
|
||
def expand_fill_short_ref(s, type_str):
|
||
if not type_str or '+' in type_str:
|
||
return None
|
||
t = resolve_type_str(type_str)
|
||
m = re.match(r'^(\w+Ref)\.(.+)$', t)
|
||
if not m:
|
||
return None
|
||
root = fill_ref_kind_root.get(m.group(1).lower())
|
||
if not root:
|
||
return None
|
||
type_name = m.group(2)
|
||
if s.lower() in fill_empty_ref_words:
|
||
return f"{root}.{type_name}.EmptyRef"
|
||
if root == 'Enum':
|
||
return f"Enum.{type_name}.EnumValue.{s}"
|
||
return f"{root}.{type_name}.{s}"
|
||
|
||
|
||
def resolve_fill_value_spec(s, type_str):
|
||
cat = get_fill_type_category(type_str)
|
||
if s == '':
|
||
return {'XsiType': 'xs:string', 'Text': ''}
|
||
if cat == 'String':
|
||
return {'XsiType': 'xs:string', 'Text': s}
|
||
if cat == 'Boolean' or s.lower() in fill_bool_true or s.lower() in fill_bool_false:
|
||
if s.lower() in fill_bool_true:
|
||
return {'XsiType': 'xs:boolean', 'Text': 'true'}
|
||
if s.lower() in fill_bool_false:
|
||
return {'XsiType': 'xs:boolean', 'Text': 'false'}
|
||
if cat == 'Number':
|
||
return {'XsiType': 'xs:decimal', 'Text': s}
|
||
if cat == 'Date' or re.match(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$', s):
|
||
if re.match(r'^\d{4}-\d{2}-\d{2}$', s):
|
||
s = s + 'T00:00:00'
|
||
return {'XsiType': 'xs:dateTime', 'Text': s}
|
||
ref = normalize_fill_ref(s)
|
||
if ref:
|
||
return {'XsiType': 'xr:DesignTimeRef', 'Text': ref}
|
||
short = expand_fill_short_ref(s, type_str)
|
||
if short:
|
||
return {'XsiType': 'xr:DesignTimeRef', 'Text': short}
|
||
return {'XsiType': 'xs:string', 'Text': s}
|
||
|
||
|
||
def get_attr_type_str_from_xml(props_el):
|
||
"""Извлечь тип реквизита из XML (<Type>/<v8:Type>) → DSL-typeStr для категоризации FillValue."""
|
||
type_el = None
|
||
for ch in props_el:
|
||
if localname(ch) == 'Type':
|
||
type_el = ch
|
||
break
|
||
if type_el is None:
|
||
return ""
|
||
mapped = []
|
||
for ch in type_el:
|
||
if localname(ch) == 'Type':
|
||
t = (ch.text or '').strip()
|
||
colon = t.find(':')
|
||
if colon >= 0:
|
||
t = t[colon + 1:]
|
||
if t == 'string':
|
||
mapped.append('String')
|
||
elif t == 'decimal':
|
||
mapped.append('Number')
|
||
elif t == 'boolean':
|
||
mapped.append('Boolean')
|
||
elif t == 'dateTime':
|
||
mapped.append('Date')
|
||
else:
|
||
mapped.append(t)
|
||
if not mapped:
|
||
return ""
|
||
if len(mapped) > 1:
|
||
return ' + '.join(mapped)
|
||
return mapped[0]
|
||
|
||
|
||
def build_fill_value_explicit_xml(type_str, spec):
|
||
"""FillValue — явное значение (порт Emit-FillValue, ветка hasSpec)."""
|
||
if spec is None:
|
||
return '<FillValue xsi:nil="true"/>'
|
||
if isinstance(spec, bool):
|
||
return f'<FillValue xsi:type="xs:boolean">{"true" if spec else "false"}</FillValue>'
|
||
if isinstance(spec, (int, float)):
|
||
return f'<FillValue xsi:type="xs:decimal">{format_fill_num(spec)}</FillValue>'
|
||
if get_ch_el_prop(spec, ['nil']) is True:
|
||
return '<FillValue xsi:nil="true"/>'
|
||
if get_ch_el_prop(spec, ['emptyRef', 'пустаяссылка']) is True:
|
||
return '<FillValue xsi:type="xr:DesignTimeRef"/>'
|
||
r = resolve_fill_value_spec(str(spec), type_str)
|
||
if r['Text'] == '' and r['XsiType'] == 'xs:string':
|
||
return '<FillValue xsi:type="xs:string"/>'
|
||
return f'<FillValue xsi:type="{r["XsiType"]}">{esc_xml_text(r["Text"])}</FillValue>'
|
||
|
||
|
||
def find_property_element(prop_name):
|
||
for child in properties_el:
|
||
if localname(child) == prop_name:
|
||
return child
|
||
return None
|
||
|
||
|
||
def get_complex_property_values(prop_el):
|
||
values = []
|
||
for child in prop_el:
|
||
values.append((child.text or "").strip())
|
||
return values
|
||
|
||
|
||
def add_complex_property_item(property_name, values):
|
||
global add_count
|
||
|
||
map_entry = complex_property_map.get(property_name)
|
||
if not map_entry:
|
||
warn(f"Unknown complex property: {property_name}")
|
||
return
|
||
if map_entry.get("expand"):
|
||
values = [expand_data_path(str(v)) for v in values]
|
||
|
||
prop_el = find_property_element(property_name)
|
||
if prop_el is None:
|
||
warn(f"Property element '{property_name}' not found in Properties")
|
||
return
|
||
|
||
# Get existing values to check duplicates
|
||
existing = get_complex_property_values(prop_el)
|
||
|
||
indent = get_child_indent(properties_el)
|
||
child_indent = f"{indent}\t"
|
||
|
||
# Check if element is empty (self-closing)
|
||
is_empty = len(list(prop_el)) == 0
|
||
|
||
# If self-closing / empty, add closing whitespace
|
||
if is_empty and not (prop_el.text and prop_el.text.strip()):
|
||
prop_el.text = "\r\n" + indent
|
||
|
||
for val in values:
|
||
if val in existing:
|
||
warn(f"{property_name} already contains '{val}', skipping")
|
||
continue
|
||
tag = map_entry["tag"]
|
||
attr_str = map_entry["attr"]
|
||
if attr_str:
|
||
frag_xml = f"<{tag} {attr_str}>{esc_xml(val)}</{tag}>"
|
||
else:
|
||
frag_xml = f"<{tag}>{esc_xml(val)}</{tag}>"
|
||
nodes = import_fragment(frag_xml)
|
||
for node in nodes:
|
||
insert_before_element(prop_el, node, None, child_indent)
|
||
info(f"Added {property_name} item: {val}")
|
||
add_count += 1
|
||
|
||
|
||
def remove_complex_property_item(property_name, values):
|
||
global remove_count
|
||
|
||
map_entry = complex_property_map.get(property_name)
|
||
if map_entry and map_entry.get("expand"):
|
||
values = [expand_data_path(str(v)) for v in values]
|
||
prop_el = find_property_element(property_name)
|
||
if prop_el is None:
|
||
warn(f"Property element '{property_name}' not found in Properties")
|
||
return
|
||
|
||
for val in values:
|
||
found = False
|
||
for child in list(prop_el):
|
||
if (child.text or "").strip() == val:
|
||
remove_node_with_whitespace(child)
|
||
info(f"Removed {property_name} item: {val}")
|
||
remove_count += 1
|
||
found = True
|
||
break
|
||
if not found:
|
||
warn(f"{property_name} item '{val}' not found, skipping")
|
||
|
||
# Collapse if empty
|
||
has_elements = any(True for _ in prop_el)
|
||
if not has_elements:
|
||
prop_el.text = None
|
||
|
||
|
||
def set_complex_property(property_name, values):
|
||
global modify_count
|
||
|
||
map_entry = complex_property_map.get(property_name)
|
||
if not map_entry:
|
||
warn(f"Unknown complex property: {property_name}")
|
||
return
|
||
if map_entry.get("expand"):
|
||
values = [expand_data_path(str(v)) for v in values]
|
||
|
||
prop_el = find_property_element(property_name)
|
||
if prop_el is None:
|
||
warn(f"Property element '{property_name}' not found in Properties")
|
||
return
|
||
|
||
indent = get_child_indent(properties_el)
|
||
child_indent = f"{indent}\t"
|
||
|
||
# Remove all existing children
|
||
for ch in list(prop_el):
|
||
prop_el.remove(ch)
|
||
prop_el.text = None
|
||
|
||
if not values:
|
||
# Leave self-closing
|
||
info(f"Cleared {property_name}")
|
||
modify_count += 1
|
||
return
|
||
|
||
# Add closing whitespace
|
||
prop_el.text = "\r\n" + indent
|
||
|
||
# Add each value
|
||
for val in values:
|
||
tag = map_entry["tag"]
|
||
attr_str = map_entry["attr"]
|
||
if attr_str:
|
||
frag_xml = f"<{tag} {attr_str}>{esc_xml(val)}</{tag}>"
|
||
else:
|
||
frag_xml = f"<{tag}>{esc_xml(val)}</{tag}>"
|
||
nodes = import_fragment(frag_xml)
|
||
for node in nodes:
|
||
insert_before_element(prop_el, node, None, child_indent)
|
||
|
||
count = len(values)
|
||
info(f"Set {property_name}: {count} items")
|
||
modify_count += 1
|
||
|
||
|
||
# ============================================================
|
||
# Save helpers
|
||
# ============================================================
|
||
|
||
|
||
def save_xml(tree, path):
|
||
"""Save XML tree with BOM and proper encoding declaration."""
|
||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||
# Fix XML declaration quotes
|
||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||
# Fix d5p1 namespace declarations stripped by lxml (it treats them as unused
|
||
# because d5p1: appears only in text content, not in element/attribute names)
|
||
xml_bytes = re.sub(
|
||
b'(<v8:Type)(?! xmlns:d5p1)(>d5p1:)',
|
||
b'\\1 xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config"\\2',
|
||
xml_bytes
|
||
)
|
||
if not xml_bytes.endswith(b"\n"):
|
||
xml_bytes += b"\n"
|
||
with open(path, "wb") as f:
|
||
f.write(b"\xef\xbb\xbf")
|
||
f.write(xml_bytes)
|
||
|
||
|
||
# ============================================================
|
||
# Main
|
||
# ============================================================
|
||
|
||
# ============================================================
|
||
# Predefined data (Ext/Predefined.xml) — add предопределённых (Catalog/ChartOfCharacteristicTypes).
|
||
# Существующие <Item id=GUID> сохраняются побайтово (текстовый append), новые получают свежий GUID.
|
||
# ============================================================
|
||
|
||
predef_xsi_type_by_obj = {
|
||
'Catalog': 'CatalogPredefinedItems',
|
||
'ChartOfCharacteristicTypes': 'PlanOfCharacteristicKindPredefinedItems',
|
||
}
|
||
|
||
|
||
def get_predefined_path():
|
||
return os.path.join(os.path.dirname(resolved_path), obj_name, "Ext", "Predefined.xml")
|
||
|
||
|
||
def get_object_code_type():
|
||
for ch in properties_el:
|
||
if localname(ch) == 'CodeType':
|
||
return (ch.text or '').strip()
|
||
return 'String'
|
||
|
||
|
||
def resolve_predef_item(val):
|
||
if isinstance(val, str):
|
||
s = val
|
||
desc_raw = None
|
||
has_desc = False
|
||
m = re.search(r'\[(.*)\]', s)
|
||
if m:
|
||
desc_raw = m.group(1)
|
||
has_desc = True
|
||
s = re.sub(r'\s*\[.*\]', '', s)
|
||
mm = re.match(r'^\s*(?:\(([^)]*)\)\s*)?(\S+)\s*$', s.strip())
|
||
name = mm.group(2) if mm else s.strip()
|
||
code = mm.group(1) if (mm and mm.group(1) is not None) else ''
|
||
desc = desc_raw if has_desc else split_camel_case(name)
|
||
return {'name': name, 'code': code, 'desc': desc, 'isFolder': False, 'children': []}
|
||
|
||
def gv(o, keys):
|
||
for k in keys:
|
||
if isinstance(o, dict) and k in o:
|
||
return o[k]
|
||
return None
|
||
name = str(gv(val, ['name', 'имя']) or '')
|
||
code_v = gv(val, ['code', 'код'])
|
||
code = str(code_v) if code_v is not None else ''
|
||
has_desc = isinstance(val, dict) and ('description' in val or 'наименование' in val)
|
||
desc_v = gv(val, ['description', 'наименование'])
|
||
desc = str(desc_v) if has_desc else split_camel_case(name)
|
||
is_folder = gv(val, ['isFolder', 'группа']) is True
|
||
subs = gv(val, ['childItems', 'подчиненные'])
|
||
return {'name': name, 'code': code, 'desc': desc if desc is not None else '',
|
||
'isFolder': is_folder, 'children': list(subs) if subs else []}
|
||
|
||
|
||
def build_predef_item_xml(indent, val, code_type):
|
||
r = resolve_predef_item(val)
|
||
parts = [f'{indent}<Item id="{new_uuid()}">']
|
||
parts.append(f'{indent}\t<Name>{esc_xml_text(r["name"])}</Name>')
|
||
if not r['code']:
|
||
parts.append(f'{indent}\t<Code/>')
|
||
elif code_type == 'Number':
|
||
parts.append(f'{indent}\t<Code xsi:type="xs:decimal">{esc_xml_text(r["code"])}</Code>')
|
||
else:
|
||
parts.append(f'{indent}\t<Code>{esc_xml_text(r["code"])}</Code>')
|
||
if r['desc'] == '':
|
||
parts.append(f'{indent}\t<Description/>')
|
||
else:
|
||
parts.append(f'{indent}\t<Description>{esc_xml_text(r["desc"])}</Description>')
|
||
parts.append(f'{indent}\t<IsFolder>{"true" if r["isFolder"] else "false"}</IsFolder>')
|
||
if r['children']:
|
||
parts.append(f'{indent}\t<ChildItems>')
|
||
for c in r['children']:
|
||
parts.append(build_predef_item_xml(indent + '\t\t', c, code_type).rstrip('\r\n'))
|
||
parts.append(f'{indent}\t</ChildItems>')
|
||
parts.append(f'{indent}</Item>')
|
||
return '\r\n'.join(parts) + '\r\n'
|
||
|
||
|
||
def add_predefined_items(items):
|
||
global add_count
|
||
xsi_type = predef_xsi_type_by_obj.get(obj_type)
|
||
if not xsi_type:
|
||
print(f"add-predefined: тип объекта '{obj_type}' не поддержан (только Catalog, ChartOfCharacteristicTypes)", file=sys.stderr)
|
||
sys.exit(1)
|
||
code_type = get_object_code_type()
|
||
version = xml_root.get("version")
|
||
path = get_predefined_path()
|
||
item_list = items if isinstance(items, list) else [items]
|
||
items_xml = ''.join(build_predef_item_xml('\t', it, code_type) for it in item_list)
|
||
if os.path.exists(path):
|
||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||
text = f.read()
|
||
text = text.replace('</PredefinedData>', items_xml + '</PredefinedData>')
|
||
else:
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
hdr = ('<?xml version="1.0" encoding="UTF-8"?>\r\n<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" '
|
||
'xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
||
f'xsi:type="{xsi_type}" version="{version}">\r\n')
|
||
text = hdr + items_xml + '</PredefinedData>\r\n'
|
||
with open(path, 'wb') as f:
|
||
f.write(b'\xef\xbb\xbf')
|
||
f.write(text.encode('utf-8'))
|
||
info(f"Added {len(item_list)} predefined item(s) -> {path}")
|
||
add_count += len(item_list)
|
||
|
||
|
||
def main():
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
global xml_tree, xml_root, obj_element, obj_type, md_ns
|
||
global properties_el, child_objects_el, obj_name, resolved_path
|
||
global add_count, remove_count, modify_count, warn_count
|
||
|
||
valid_operations = [
|
||
"add-attribute", "add-ts", "add-dimension", "add-resource",
|
||
"add-enumValue", "add-column", "add-form", "add-template", "add-command",
|
||
"add-owner", "add-registerRecord", "add-basedOn", "add-inputByString",
|
||
"add-dataLockField", "add-registeredDocument", "add-predefined",
|
||
"remove-attribute", "remove-ts", "remove-dimension", "remove-resource",
|
||
"remove-enumValue", "remove-column", "remove-form", "remove-template", "remove-command",
|
||
"remove-owner", "remove-registerRecord", "remove-basedOn", "remove-inputByString",
|
||
"remove-dataLockField", "remove-registeredDocument",
|
||
"add-ts-attribute", "remove-ts-attribute", "modify-ts-attribute", "modify-ts",
|
||
"modify-attribute", "modify-dimension", "modify-resource",
|
||
"modify-enumValue", "modify-column",
|
||
"modify-property",
|
||
"set-owners", "set-registerRecords", "set-basedOn", "set-inputByString",
|
||
"set-dataLockFields", "set-registeredDocuments",
|
||
]
|
||
|
||
parser = argparse.ArgumentParser(description="Edit existing 1C metadata object XML", allow_abbrev=False)
|
||
parser.add_argument("-DefinitionFile", default=None, help="JSON definition file")
|
||
parser.add_argument("-ObjectPath", "-Path", required=True, help="Path to object XML or directory")
|
||
parser.add_argument("-Operation", default=None, choices=valid_operations, help="Inline operation")
|
||
parser.add_argument("-Value", default=None, help="Inline value")
|
||
parser.add_argument("-NoValidate", action="store_true", help="Skip auto-validation")
|
||
args = parser.parse_args()
|
||
|
||
# --- Mode validation ---
|
||
if args.DefinitionFile and args.Operation:
|
||
die("Cannot use both -DefinitionFile and -Operation")
|
||
if not args.DefinitionFile and not args.Operation:
|
||
die("Either -DefinitionFile or -Operation is required")
|
||
|
||
# --- Load JSON definition (DefinitionFile mode) ---
|
||
definition = None
|
||
if args.DefinitionFile:
|
||
if not os.path.exists(args.DefinitionFile):
|
||
die(f"Definition file not found: {args.DefinitionFile}")
|
||
with open(args.DefinitionFile, "r", encoding="utf-8-sig") as f:
|
||
definition = json.load(f)
|
||
|
||
# --- Resolve object path ---
|
||
object_path = args.ObjectPath
|
||
if os.path.isdir(object_path):
|
||
dir_name = os.path.basename(object_path)
|
||
candidate = os.path.join(object_path, f"{dir_name}.xml")
|
||
sibling = os.path.join(os.path.dirname(object_path), f"{dir_name}.xml")
|
||
if os.path.exists(candidate):
|
||
object_path = candidate
|
||
elif os.path.exists(sibling):
|
||
object_path = sibling
|
||
else:
|
||
die(f"Directory given but no {dir_name}.xml found inside or as sibling")
|
||
|
||
# File not found -- check Dir/Name/Name.xml -> Dir/Name.xml
|
||
if not os.path.exists(object_path):
|
||
file_name = os.path.splitext(os.path.basename(object_path))[0]
|
||
parent_dir = os.path.dirname(object_path)
|
||
parent_dir_name = os.path.basename(parent_dir)
|
||
if file_name == parent_dir_name:
|
||
candidate = os.path.join(os.path.dirname(parent_dir), f"{file_name}.xml")
|
||
if os.path.exists(candidate):
|
||
object_path = candidate
|
||
|
||
if not os.path.exists(object_path):
|
||
die(f"Object file not found: {object_path}")
|
||
|
||
resolved_path = os.path.abspath(object_path)
|
||
|
||
assert_edit_allowed(resolved_path, "editable")
|
||
|
||
# --- Load XML ---
|
||
xml_parser = etree.XMLParser(remove_blank_text=False)
|
||
xml_tree = etree.parse(resolved_path, xml_parser)
|
||
xml_root = xml_tree.getroot()
|
||
|
||
# --- Detect object type ---
|
||
if localname(xml_root) != "MetaDataObject":
|
||
die(f"Root element must be MetaDataObject, got: {localname(xml_root)}")
|
||
|
||
# Find the first child element -- this is the object type element
|
||
obj_element = None
|
||
for child in xml_root:
|
||
obj_element = child
|
||
break
|
||
if obj_element is None:
|
||
die("No object element found under MetaDataObject")
|
||
|
||
obj_type = localname(obj_element)
|
||
md_ns = etree.QName(obj_element.tag).namespace or ""
|
||
|
||
# Find Properties and ChildObjects
|
||
properties_el = None
|
||
child_objects_el = None
|
||
for child in obj_element:
|
||
ln = localname(child)
|
||
if ln == "Properties":
|
||
properties_el = child
|
||
if ln == "ChildObjects":
|
||
child_objects_el = child
|
||
|
||
if properties_el is None:
|
||
die(f"No <Properties> found in {obj_type}")
|
||
|
||
# Extract object name
|
||
obj_name = ""
|
||
for child in properties_el:
|
||
if localname(child) == "Name":
|
||
obj_name = (child.text or "").strip()
|
||
break
|
||
|
||
info(f"Object: {obj_type}.{obj_name}")
|
||
|
||
# --- Inline mode conversion ---
|
||
if args.Operation:
|
||
definition = convert_inline_to_definition(args.Operation, args.Value or "")
|
||
|
||
if definition is None:
|
||
die("No definition loaded")
|
||
|
||
# --- Process complex property operations ---
|
||
if "_complex" in definition and definition["_complex"]:
|
||
for cop in definition["_complex"]:
|
||
action = cop["action"]
|
||
if action == "add":
|
||
add_complex_property_item(cop["property"], cop["values"])
|
||
elif action == "remove":
|
||
remove_complex_property_item(cop["property"], cop["values"])
|
||
elif action == "set":
|
||
set_complex_property(cop["property"], cop["values"])
|
||
|
||
# --- Process standard operations ---
|
||
for prop_name, prop_value in definition.items():
|
||
if prop_name == "_complex":
|
||
continue
|
||
op_key = resolve_operation_key(prop_name)
|
||
if not op_key:
|
||
warn(f"Unknown operation: {prop_name}")
|
||
continue
|
||
|
||
if op_key == "add":
|
||
process_add(prop_value)
|
||
elif op_key == "remove":
|
||
process_remove(prop_value)
|
||
elif op_key == "modify":
|
||
process_modify(prop_value)
|
||
|
||
# --- Save XML ---
|
||
save_xml(xml_tree, resolved_path)
|
||
info(f"Saved: {resolved_path}")
|
||
|
||
# --- Auto-validate ---
|
||
if not args.NoValidate:
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
validate_script = os.path.normpath(os.path.join(script_dir, "..", "..", "meta-validate", "scripts", "meta-validate.py"))
|
||
if os.path.exists(validate_script):
|
||
print()
|
||
print("--- Running meta-validate ---")
|
||
python_exe = sys.executable
|
||
subprocess.run([python_exe, validate_script, "-ObjectPath", resolved_path])
|
||
else:
|
||
print()
|
||
print(f"[SKIP] meta-validate not found at: {validate_script}")
|
||
|
||
# --- Summary ---
|
||
print()
|
||
print("=== meta-edit summary ===")
|
||
print(f" Object: {obj_type}.{obj_name}")
|
||
print(f" Added: {add_count}")
|
||
print(f" Removed: {remove_count}")
|
||
print(f" Modified: {modify_count}")
|
||
if warn_count > 0:
|
||
print(f" Warnings: {warn_count}")
|
||
|
||
total_changes = add_count + remove_count + modify_count
|
||
if total_changes == 0:
|
||
print(" No changes applied.")
|
||
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|