# skd-edit v1.27 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import json import os import re import sys import uuid from lxml import etree sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") # Dirty flag — set to True by every successful mutation. If still False at save time, # the file is left untouched (NO-OP operations like [WARN] not found don't rewrite). dirty = False # ── arg parsing ────────────────────────────────────────────── VALID_OPS = [ "add-field", "add-total", "add-calculated-field", "add-parameter", "add-filter", "add-dataParameter", "add-order", "add-selection", "add-dataSetLink", "add-dataSet", "add-variant", "add-conditionalAppearance", "add-drilldown", "set-query", "patch-query", "set-outputParameter", "set-structure", "modify-field", "modify-filter", "modify-dataParameter", "modify-parameter", "modify-structure", "set-field-role", "rename-parameter", "reorder-parameters", "clear-selection", "clear-order", "clear-filter", "clear-conditionalAppearance", "remove-field", "remove-total", "remove-calculated-field", "remove-parameter", "remove-filter", ] parser = argparse.ArgumentParser(allow_abbrev=False) parser.add_argument("-TemplatePath", "-Path", required=True) parser.add_argument("-Operation", required=True, choices=VALID_OPS) parser.add_argument("-Value", required=True) parser.add_argument("-DataSet", default="") parser.add_argument("-Variant", default="") parser.add_argument("-NoSelection", action="store_true") args = parser.parse_args() template_path = args.TemplatePath operation = args.Operation value_arg = args.Value data_set_arg = args.DataSet variant_arg = args.Variant no_selection = args.NoSelection # ── namespaces ─────────────────────────────────────────────── SCH_NS = "http://v8.1c.ru/8.1/data-composition-system/schema" SET_NS = "http://v8.1c.ru/8.1/data-composition-system/settings" COR_NS = "http://v8.1c.ru/8.1/data-composition-system/core" XSI_NS = "http://www.w3.org/2001/XMLSchema-instance" V8_NS = "http://v8.1c.ru/8.1/data/core" NS_MAP = { "sch": SCH_NS, "dcsset": SET_NS, "dcscor": COR_NS, "xsi": XSI_NS, "v8": V8_NS, } WRAPPER_NS = ( f'xmlns="{SCH_NS}"' f' xmlns:xsi="{XSI_NS}"' f' xmlns:v8="{V8_NS}"' ' xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"' f' xmlns:dcscor="{COR_NS}"' f' xmlns:dcsset="{SET_NS}"' ' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"' ) XSI_TYPE = f"{{{XSI_NS}}}type" def local_name(node): return etree.QName(node.tag).localname # ── helpers ────────────────────────────────────────────────── def esc_xml(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') def resolve_query_value(val, base_dir): if not val.startswith("@"): return val file_path = val[1:] if os.path.isabs(file_path): candidates = [file_path] else: candidates = [ os.path.join(base_dir, file_path), os.path.join(os.getcwd(), file_path), ] for c in candidates: if os.path.exists(c): with open(c, 'r', encoding='utf-8-sig') as f: return f.read().rstrip() print(f"Query file not found: {file_path} (searched: {', '.join(candidates)})", file=sys.stderr) sys.exit(1) def new_uuid(): return str(uuid.uuid4()) # ── 1. Resolve path ───────────────────────────────────────── if not template_path.endswith(".xml"): candidate = os.path.join(template_path, "Ext", "Template.xml") if os.path.exists(candidate): template_path = candidate if not os.path.exists(template_path): print(f"File not found: {template_path}", file=sys.stderr) sys.exit(1) resolved_path = os.path.abspath(template_path) # ============================================================ # 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 assert_edit_allowed(resolved_path, "editable") query_base_dir = os.path.dirname(resolved_path) # ── 2. Type system ────────────────────────────────────────── type_synonyms = { "\u0447\u0438\u0441\u043b\u043e": "decimal", "\u0441\u0442\u0440\u043e\u043a\u0430": "string", "\u0431\u0443\u043b\u0435\u0432\u043e": "boolean", "\u0434\u0430\u0442\u0430": "date", "\u0434\u0430\u0442\u0430\u0432\u0440\u0435\u043c\u044f": "dateTime", "\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434": "StandardPeriod", "bool": "boolean", "str": "string", "int": "decimal", "integer": "decimal", "number": "decimal", "num": "decimal", "\u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0441\u0441\u044b\u043b\u043a\u0430": "CatalogRef", "\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0441\u0441\u044b\u043b\u043a\u0430": "DocumentRef", "\u043f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435\u0441\u0441\u044b\u043b\u043a\u0430": "EnumRef", "\u043f\u043b\u0430\u043d\u0441\u0447\u0435\u0442\u043e\u0432\u0441\u0441\u044b\u043b\u043a\u0430": "ChartOfAccountsRef", "\u043f\u043b\u0430\u043d\u0432\u0438\u0434\u043e\u0432\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a\u0441\u0441\u044b\u043b\u043a\u0430": "ChartOfCharacteristicTypesRef", } output_param_types = { "\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a": "mltext", "\u0412\u044b\u0432\u043e\u0434\u0438\u0442\u044c\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a": "dcsset:DataCompositionTextOutputType", "\u0412\u044b\u0432\u043e\u0434\u0438\u0442\u044c\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u0414\u0430\u043d\u043d\u044b\u0445": "dcsset:DataCompositionTextOutputType", "\u0412\u044b\u0432\u043e\u0434\u0438\u0442\u044c\u041e\u0442\u0431\u043e\u0440": "dcsset:DataCompositionTextOutputType", "\u041c\u0430\u043a\u0435\u0442\u041e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u044f": "xs:string", "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u041f\u043e\u043b\u0435\u0439\u0413\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438": "dcsset:DataCompositionGroupFieldsPlacement", "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u043e\u0432": "dcsset:DataCompositionAttributesPlacement", "\u0413\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u041e\u0431\u0449\u0438\u0445\u0418\u0442\u043e\u0433\u043e\u0432": "dcscor:DataCompositionTotalPlacement", "\u0412\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u041e\u0431\u0449\u0438\u0445\u0418\u0442\u043e\u0433\u043e\u0432": "dcscor:DataCompositionTotalPlacement", } def resolve_type_str(type_str): if not type_str: return type_str 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 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 resolved = type_synonyms.get(type_str.lower()) if resolved: return resolved return type_str # ── 3. Parsers ────────────────────────────────────────────── def parse_field_shorthand(s): result = {"dataPath": "", "field": "", "title": "", "type": "", "roles": [], "restrict": []} m = re.search(r'\[([^\]]+)\]', s) if m: result["title"] = m.group(1) s = re.sub(r'\s*\[[^\]]+\]', '', s) role_matches = re.findall(r'@(\w+)', s) result["roles"] = role_matches s = re.sub(r'\s*@\w+', '', s) restrict_matches = re.findall(r'#(\w+)', s) result["restrict"] = restrict_matches s = re.sub(r'\s*#\w+', '', s) s = s.strip() if ":" in s: parts = s.split(":", 1) result["dataPath"] = parts[0].strip() result["type"] = resolve_type_str(parts[1].strip()) else: result["dataPath"] = s result["field"] = result["dataPath"] return result def read_field_properties(field_el): props = {"dataPath": "", "field": "", "title": "", "type": "", "roles": [], "restrict": [], "_rawTypeText": "", "_rawTitle": None, "_unknownChildren": []} for ch in field_el: if not isinstance(ch.tag, str): continue ln = local_name(ch) if ln == "dataPath": props["dataPath"] = (ch.text or "").strip() elif ln == "field": props["field"] = (ch.text or "").strip() elif ln == "title": # Preserve full multi-lang title OuterXml; also extract ru content for compat. raw = etree.tostring(ch, encoding="unicode", with_tail=False) raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) props["_rawTitle"] = raw for item in ch: if isinstance(item.tag, str) and local_name(item) == "item": lang = None content = None for gc in item: if isinstance(gc.tag, str) and local_name(gc) == "lang": lang = (gc.text or "").strip() if isinstance(gc.tag, str) and local_name(gc) == "content": content = (gc.text or "").strip() if lang == "ru" and content is not None: props["title"] = content elif ln == "valueType": # Preserve full serialization so rebuild can re-emit qualifiers # (StringQualifiers, NumberQualifiers, DateQualifiers, …) that aren't # expressible via shorthand. Strip xmlns declarations that lxml re-emits when # serializing a sub-element (parent context already provides them). raw = etree.tostring(ch, encoding="unicode", with_tail=False) raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) props["_rawValueType"] = raw for gc in ch: if isinstance(gc.tag, str) and local_name(gc) == "Type": props["_rawTypeText"] = (gc.text or "").strip() break elif ln == "role": for gc in ch: if isinstance(gc.tag, str): gcn = local_name(gc) if gcn == "periodNumber": props["roles"].append("period") elif (gc.text or "").strip() == "true": props["roles"].append(gcn) elif ln == "useRestriction": rev_map = {"field": "noField", "condition": "noFilter", "group": "noGroup", "order": "noOrder"} for gc in ch: if isinstance(gc.tag, str) and (gc.text or "").strip() == "true": mapped = rev_map.get(local_name(gc)) if mapped: props["restrict"].append(mapped) else: # Defense in depth: preserve OuterXml of unknown children so rebuild # doesn't silently drop them (custom , , etc.). raw = etree.tostring(ch, encoding="unicode", with_tail=False) raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) props["_unknownChildren"].append(raw) return props def parse_total_shorthand(s): # "DataPath: Func" or "DataPath: Func(expr)" or "DataPath: ИмяРесурса" (identity) parts = s.split(":", 1) data_path = parts[0].strip() func_part = parts[1].strip() agg_funcs = {'Сумма', 'Количество', 'Минимум', 'Максимум', 'Среднее', 'Sum', 'Count', 'Min', 'Max', 'Avg', 'Minimum', 'Maximum', 'Average'} if re.match(r'^\w+\(', func_part): return {"dataPath": data_path, "expression": func_part} elif func_part in agg_funcs: return {"dataPath": data_path, "expression": f"{func_part}({data_path})"} else: return {"dataPath": data_path, "expression": func_part} def parse_calc_shorthand(s): # Pattern: "Name [Title]: type = Expression #noField #noFilter ...". # - `[Title]` is extracted only from the LHS of '=' so that `[...]` inside # an expression (e.g. index access) isn't interpreted as a title. # - `#restrict` flags use a known-names pattern and are extracted globally — # the docs put them after `=`, and the closed flag set avoids matching # `#word` that happens to appear inside a string literal. restrict_pattern = r'#(noField|noFilter|noCondition|noGroup|noOrder)\b' restrict_matches = re.findall(restrict_pattern, s) s = re.sub(r'\s*' + restrict_pattern, '', s) eq_idx = s.find("=") if eq_idx > 0: lhs = s[:eq_idx] rhs = s[eq_idx + 1:].strip() has_rhs = True else: lhs = s rhs = "" has_rhs = False title = "" m = re.search(r'\[([^\]]+)\]', lhs) if m: title = m.group(1) lhs = re.sub(r'\s*\[[^\]]+\]', '', lhs) lhs = lhs.strip() if has_rhs: if ":" in lhs: colon_idx = lhs.index(":") data_path = lhs[:colon_idx].strip() type_str = resolve_type_str(lhs[colon_idx + 1:].strip()) return {"dataPath": data_path, "expression": rhs, "type": type_str, "title": title, "restrict": restrict_matches} return {"dataPath": lhs, "expression": rhs, "type": "", "title": title, "restrict": restrict_matches} return {"dataPath": lhs, "expression": "", "type": "", "title": title, "restrict": restrict_matches} def parse_param_shorthand(s): result = {"name": "", "type": "", "value": None, "autoDates": False, "title": None, "hidden": False, "always": False, "availableValues": [], "valueListAllowed": False} # Extract availableValue=... (must be before main parse — captures to end of string) m_av = re.search(r'\s*availableValue=(.+)$', s) if m_av: result["availableValues"] = parse_available_value_list(m_av.group(1).strip()) s = re.sub(r'\s*availableValue=.+$', '', s).strip() if re.search(r'@autoDates', s): result["autoDates"] = True s = re.sub(r'\s*@autoDates', '', s) if re.search(r'@valueList\b', s): result["valueListAllowed"] = True s = re.sub(r'\s*@valueList\b', '', s) if re.search(r'@hidden\b', s): result["hidden"] = True s = re.sub(r'\s*@hidden\b', '', s) if re.search(r'@always\b', s): result["always"] = True s = re.sub(r'\s*@always\b', '', s) # Extract optional [Title] (mirrors parse_field_shorthand) m = re.search(r'\[([^\]]*)\]', s) if m: result["title"] = m.group(1).strip() s = re.sub(r'\s*\[[^\]]*\]\s*', ' ', s).strip() # Allow empty RHS (`= ` / `=`) as empty-value sentinel m = re.match(r'^([^:]+):\s*(\S+)(\s*=\s*(.*))?$', s) if m: result["name"] = m.group(1).strip() result["type"] = resolve_type_str(m.group(2).strip()) if m.group(3) is not None: rhs = m.group(4) if rhs and rhs.strip(): items = parse_value_list(rhs.strip()) if len(items) >= 2: # Multi-value default → list; valueListAllowed implied result["value"] = items result["valueListAllowed"] = True else: # Scalar (single item, quotes stripped) or empty sentinel result["value"] = items[0] if len(items) == 1 else "" else: result["value"] = "" else: result["name"] = s.strip() return result def parse_filter_shorthand(s): # use is tristate: None = not specified (modify-* won't touch), # False = @off (explicit), True = @on (explicit). add-* writes false only when False. result = {"field": "", "op": "Equal", "value": None, "use": None, "userSettingID": None, "viewMode": None} if re.search(r'@user', s): result["userSettingID"] = "auto" s = re.sub(r'\s*@user', '', s) if re.search(r'@off', s): result["use"] = False s = re.sub(r'\s*@off', '', s) if re.search(r'@on\b', s): result["use"] = True s = re.sub(r'\s*@on\b', '', s) if re.search(r'@quickAccess', s): result["viewMode"] = "QuickAccess" s = re.sub(r'\s*@quickAccess', '', s) if re.search(r'@normal', s): result["viewMode"] = "Normal" s = re.sub(r'\s*@normal', '', s) if re.search(r'@inaccessible', s): result["viewMode"] = "Inaccessible" s = re.sub(r'\s*@inaccessible', '', s) s = s.strip() op_patterns = [r'<>', r'>=', r'<=', r'=', r'>', r'<', r'notIn\b', r'in\b', r'inHierarchy\b', r'inListByHierarchy\b', r'notContains\b', r'contains\b', r'notBeginsWith\b', r'beginsWith\b', r'notFilled\b', r'filled\b'] op_joined = "|".join(op_patterns) m = re.match(rf'^(.+?)\s+({op_joined})\s*(.*)?$', s) if m: result["field"] = m.group(1).strip() op_raw = m.group(2).strip() val_part = (m.group(3) or "").strip() op_map = { "=": "Equal", "<>": "NotEqual", ">": "Greater", ">=": "GreaterOrEqual", "<": "Less", "<=": "LessOrEqual", "in": "InList", "notIn": "NotInList", "inHierarchy": "InHierarchy", "inListByHierarchy": "InListByHierarchy", "contains": "Contains", "notContains": "NotContains", "beginsWith": "BeginsWith", "notBeginsWith": "NotBeginsWith", "filled": "Filled", "notFilled": "NotFilled", } result["op"] = op_map.get(op_raw, op_raw) if val_part and val_part != "_": if val_part in ("true", "false"): result["value"] = val_part result["valueType"] = "xs:boolean" elif re.match(r'^\d{4}-\d{2}-\d{2}T', val_part): result["value"] = val_part result["valueType"] = "xs:dateTime" elif re.match(r'^\d+(\.\d+)?$', val_part): result["value"] = val_part result["valueType"] = "xs:decimal" elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', val_part): result["value"] = val_part result["valueType"] = "dcscor:DesignTimeValue" else: result["value"] = val_part result["valueType"] = "xs:string" else: result["field"] = s return result def parse_data_param_shorthand(s): # use is tristate: None = not specified (modify-* won't touch), # False = @off (explicit), True = @on (explicit). add-* writes false only when False. result = {"parameter": "", "value": None, "use": None, "userSettingID": None, "viewMode": None} if re.search(r'@user', s): result["userSettingID"] = "auto" s = re.sub(r'\s*@user', '', s) if re.search(r'@off', s): result["use"] = False s = re.sub(r'\s*@off', '', s) if re.search(r'@on\b', s): result["use"] = True s = re.sub(r'\s*@on\b', '', s) if re.search(r'@quickAccess', s): result["viewMode"] = "QuickAccess" s = re.sub(r'\s*@quickAccess', '', s) if re.search(r'@normal', s): result["viewMode"] = "Normal" s = re.sub(r'\s*@normal', '', s) s = s.strip() m = re.match(r'^([^=]+)=\s*(.*)$', s) if m: result["parameter"] = m.group(1).strip() val_str = m.group(2).strip() period_variants = [ "Custom", "Today", "ThisWeek", "ThisTenDays", "ThisMonth", "ThisQuarter", "ThisHalfYear", "ThisYear", "FromBeginningOfThisWeek", "FromBeginningOfThisTenDays", "FromBeginningOfThisMonth", "FromBeginningOfThisQuarter", "FromBeginningOfThisHalfYear", "FromBeginningOfThisYear", "LastWeek", "LastTenDays", "LastMonth", "LastQuarter", "LastHalfYear", "LastYear", "NextDay", "NextWeek", "NextTenDays", "NextMonth", "NextQuarter", "NextHalfYear", "NextYear", "TillEndOfThisWeek", "TillEndOfThisTenDays", "TillEndOfThisMonth", "TillEndOfThisQuarter", "TillEndOfThisHalfYear", "TillEndOfThisYear", ] # Empty / sentinel — record as "" so caller emits xsi:nil if val_str == "" or val_str == "_" or val_str.lower() == "null": result["value"] = "" elif val_str in period_variants: result["value"] = {"variant": val_str} else: result["value"] = val_str else: result["parameter"] = s return result def parse_order_shorthand(s): s = s.strip() if s == "Auto": return {"field": "Auto", "direction": ""} parts = s.split(None, 1) field = parts[0] direction = "Asc" if len(parts) > 1 and re.match(r'^desc$', parts[1], re.IGNORECASE): direction = "Desc" return {"field": field, "direction": direction} def parse_data_set_link_shorthand(s): result = {"source": "", "dest": "", "sourceExpr": "", "destExpr": "", "parameter": ""} m = re.search(r'\[param\s+([^\]]+)\]', s) if m: result["parameter"] = m.group(1).strip() s = re.sub(r'\s*\[param\s+[^\]]+\]', '', s) m = re.match(r'^(.+?)\s*>\s*(.+?)\s+on\s+(.+?)\s*=\s*(.+)$', s) if m: result["source"] = m.group(1).strip() result["dest"] = m.group(2).strip() result["sourceExpr"] = m.group(3).strip() result["destExpr"] = m.group(4).strip() else: print(f"Invalid dataSetLink shorthand: {s}. Expected: 'Source > Dest on FieldA = FieldB [param Name]'", file=sys.stderr) sys.exit(1) return result def parse_data_set_shorthand(s): s = s.strip() m = re.match(r'^(\S+):\s(.+)$', s) if m: return {"name": m.group(1), "query": m.group(2)} return {"name": "", "query": s} def parse_variant_shorthand(s): presentation = "" m = re.search(r'\[([^\]]+)\]', s) if m: presentation = m.group(1) s = re.sub(r'\s*\[[^\]]+\]', '', s) name = s.strip() if not presentation: presentation = name return {"name": name, "presentation": presentation} def parse_conditional_appearance_shorthand(s): result = {"param": "", "value": "", "filter": None, "fields": []} when_idx = s.find(" when ") for_idx = s.find(" for ") main_end = len(s) if when_idx >= 0 and for_idx >= 0: main_end = min(when_idx, for_idx) elif when_idx >= 0: main_end = when_idx elif for_idx >= 0: main_end = for_idx if for_idx >= 0: for_end = len(s) if when_idx > for_idx: for_end = when_idx for_part = s[for_idx + 5:for_end].strip() result["fields"] = [f.strip() for f in for_part.split(",") if f.strip()] if when_idx >= 0: when_end = len(s) if for_idx > when_idx: when_end = for_idx when_part = s[when_idx + 6:when_end].strip() or_parts = re.split(r'\s+or\s+', when_part) if len(or_parts) > 1: result["filter"] = [parse_filter_shorthand(p.strip()) for p in or_parts] else: result["filter"] = parse_filter_shorthand(when_part) main_part = s[:main_end].strip() eq_idx = main_part.find("=") if eq_idx > 0: result["param"] = main_part[:eq_idx].strip() result["value"] = main_part[eq_idx + 1:].strip() else: result["param"] = main_part return result def parse_structure_shorthand(s): segments = [seg.strip() for seg in s.split(">")] result = [] innermost = None for i in range(len(segments) - 1, -1, -1): seg = segments[i].strip() group = {"type": "group"} name_m = re.search(r'@name=(?:"([^"]+)"|\'([^\']+)\'|(\S+))', seg) if name_m: raw_name = name_m.group(1) or name_m.group(2) or name_m.group(3) group["name"] = raw_name.strip() seg = re.sub(r'\s*@name=(?:"[^"]+"|\'[^\']+\'|\S+)', '', seg).strip() if re.match(r'^(details|\u0434\u0435\u0442\u0430\u043b\u0438)$', seg, re.IGNORECASE): group["groupBy"] = [] else: fields = [f.strip() for f in re.split(r'\s*,\s*', seg) if f.strip()] group["groupBy"] = fields if innermost is not None: group["children"] = [innermost] innermost = group if innermost: result.append(innermost) return result def parse_output_param_shorthand(s): idx = s.find("=") if idx > 0: return {"key": s[:idx].strip(), "value": s[idx + 1:].strip()} return {"key": s.strip(), "value": ""} def split_quoted_csv(s): """Split on top-level commas, respecting single/double quoted spans. Returns raw (un-stripped) item spans. Shared by availableValue and value-list parsing.""" items = [] if s is None: return items buf = [] in_quote = None for ch in s: if in_quote: buf.append(ch) if ch == in_quote: in_quote = None elif ch in ("'", '"'): in_quote = ch buf.append(ch) elif ch == ',': items.append("".join(buf)) buf = [] else: buf.append(ch) if buf: items.append("".join(buf)) return items def strip_quotes(t): """Strip a single surrounding pair of matching quotes; trims first.""" t = t.strip() if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')): return t[1:-1] return t def parse_value_list(s): """Return list of value strings (quotes stripped) split by top-level commas. No ':' handling — values may contain colons (e.g. dateTime 2024-01-01T12:30:00).""" if s is None: return [] result = [] for raw in split_quoted_csv(s): v = strip_quotes(raw) if v != "": result.append(v) return result def parse_available_value_list(s): """Returns list of {value, presentation} from comma-separated list. Items can use single/double quotes (stripped). Quoted spans preserve commas/colons.""" if not s: return [] items = split_quoted_csv(s) result = [] for raw in items: item = raw.strip() if not item: continue # Find first ':' outside quotes colon_idx = -1 q = None for j, c in enumerate(item): if q: if c == q: q = None elif c in ("'", '"'): q = c elif c == ':': colon_idx = j break if colon_idx >= 0: val_part = item[:colon_idx] pres_part = item[colon_idx + 1:] result.append({"value": strip_quotes(val_part), "presentation": strip_quotes(pres_part)}) else: result.append({"value": strip_quotes(item), "presentation": ""}) return result def build_available_value_fragment(item, declared_type, indent): """Return XML lines for a single block.""" lines = [f"{indent}"] if is_empty_value(item.get("value")): empty_xml = build_empty_value_xml(declared_type, f"{indent}\t", "", "value", False) if empty_xml: lines.append(empty_xml) else: for vl in build_param_value_xml(declared_type, item["value"], f"{indent}\t"): lines.append(vl) if item.get("presentation"): lines.append(f'{indent}\t') lines.append(f"{indent}\t\t") lines.append(f"{indent}\t\t\tru") lines.append(f"{indent}\t\t\t{esc_xml(item['presentation'])}") lines.append(f"{indent}\t\t") lines.append(f"{indent}\t") lines.append(f"{indent}") return lines # ── 4. Build-* functions (XML fragment generators) ────────── def build_value_type_xml(type_str, indent): if not type_str: return "" # Composite: list/tuple → concatenate per-type fragments if isinstance(type_str, (list, tuple)): parts = [] for t in type_str: p = build_value_type_xml(str(t), indent) if p: parts.append(p) return "\n".join(parts) type_str = resolve_type_str(str(type_str)) lines = [] if type_str == "boolean": lines.append(f"{indent}xs:boolean") return "\n".join(lines) # string, string(N), string(N,fix) — fix → AllowedLength=Fixed m = re.match(r'^string(\((\d+)(,(fix|fixed))?\))?$', type_str) if m: length = m.group(2) if m.group(2) else "0" al = "Fixed" if m.group(4) else "Variable" lines.append(f"{indent}xs:string") lines.append(f"{indent}") lines.append(f"{indent}\t{length}") lines.append(f"{indent}\t{al}") lines.append(f"{indent}") return "\n".join(lines) # decimal — bare = 10,2; decimal(N) = N,0 m = re.match(r'^decimal(\((\d+)(,(\d+))?(,nonneg)?\))?$', type_str) if m: if not m.group(1): digits, fraction, sign = "10", "2", "Any" else: digits = m.group(2) fraction = m.group(4) if m.group(4) else "0" sign = "Nonnegative" if m.group(5) else "Any" lines.append(f"{indent}xs:decimal") lines.append(f"{indent}") lines.append(f"{indent}\t{digits}") lines.append(f"{indent}\t{fraction}") lines.append(f"{indent}\t{sign}") lines.append(f"{indent}") return "\n".join(lines) # date / dateTime / time — all xs:dateTime m = re.match(r'^(date|dateTime|time)$', type_str) if m: fractions = {"date": "Date", "dateTime": "DateTime", "time": "Time"}[type_str] lines.append(f"{indent}xs:dateTime") lines.append(f"{indent}") lines.append(f"{indent}\t{fractions}") lines.append(f"{indent}") return "\n".join(lines) if type_str == "StandardPeriod": lines.append(f"{indent}v8:StandardPeriod") return "\n".join(lines) if re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef)\.', type_str): lines.append(f'{indent}d5p1:{esc_xml(type_str)}') return "\n".join(lines) if "." in type_str: lines.append(f'{indent}d5p1:{esc_xml(type_str)}') return "\n".join(lines) lines.append(f"{indent}{esc_xml(type_str)}") return "\n".join(lines) def is_empty_value(v): """Empty sentinel — None / '' / '_' / 'null' (case-insensitive).""" if v is None: return True s = str(v).strip() if s == "": return True if s == "_": return True if s.lower() == "null": return True return False def build_empty_value_xml(type_str, indent, tag_prefix="", tag_name="value", value_list_allowed=False): """Type-aware empty fragment. Returns None when valueListAllowed (omit).""" if value_list_allowed: return None t = "" if type_str is None else str(type_str) # Strip well-known XML schema prefixes so callers can pass raw text t = re.sub(r'^xs:', '', t) t = re.sub(r'^v8:', '', t) t = re.sub(r'^d\d+p\d+:', '', t) pf, tn = tag_prefix, tag_name lines = [] if t == "": lines.append(f'{indent}<{pf}{tn} xsi:nil="true"/>') elif t == "StandardPeriod": lines.append(f'{indent}<{pf}{tn} xsi:type="v8:StandardPeriod">') lines.append(f'{indent}\tCustom') lines.append(f'{indent}\t0001-01-01T00:00:00') lines.append(f'{indent}\t0001-01-01T00:00:00') lines.append(f'{indent}') elif re.match(r'^string', t): lines.append(f'{indent}<{pf}{tn} xsi:type="xs:string"/>') elif re.match(r'^(date|time)', t): lines.append(f'{indent}<{pf}{tn} xsi:type="xs:dateTime">0001-01-01T00:00:00') elif re.match(r'^decimal', t): lines.append(f'{indent}<{pf}{tn} xsi:type="xs:decimal">0') elif t == "boolean": lines.append(f'{indent}<{pf}{tn} xsi:type="xs:boolean">false') else: lines.append(f'{indent}<{pf}{tn} xsi:nil="true"/>') return "\n".join(lines) def build_mltext_xml(tag, text, indent): lines = [ f'{indent}<{tag} xsi:type="v8:LocalStringType">', f"{indent}\t", f"{indent}\t\tru", f"{indent}\t\t{esc_xml(text)}", f"{indent}\t", f"{indent}", ] return "\n".join(lines) def patch_mltext_ru(raw_outer_xml, new_ru_text, indent): """Patch the ru within an existing multi-lang title OuterXml, preserving en/uk/etc. siblings. Mirrors PS Patch-MLTextRu.""" escaped = esc_xml(new_ru_text) ru_item_pat = r"(\s*ru\s*)[^<]*(\s*)" if re.search(ru_item_pat, raw_outer_xml): return re.sub(ru_item_pat, lambda m: m.group(1) + escaped + m.group(2), raw_outer_xml) prep = f"{indent}\t\n{indent}\t\tru\n{indent}\t\t{escaped}\n{indent}\t" if "" in raw_outer_xml: return re.sub(r"(\s*)", lambda m: "\n" + prep + m.group(1) + "", raw_outer_xml, count=1) return re.sub(r"(<(?:\w+:)?title[^>]*>)", lambda m: m.group(1) + "\n" + prep + "\n" + indent, raw_outer_xml, count=1) def build_role_xml(roles, indent): if not roles: return "" lines = [f"{indent}"] for role in roles: if role == "period": lines.append(f"{indent}\t1") lines.append(f"{indent}\tMain") else: lines.append(f"{indent}\ttrue") lines.append(f"{indent}") return "\n".join(lines) def build_restriction_xml(restrict, indent): if not restrict: return "" restrict_map = {"noField": "field", "noFilter": "condition", "noCondition": "condition", "noGroup": "group", "noOrder": "order"} lines = [f"{indent}"] for r in restrict: xml_name = restrict_map.get(r) if xml_name: lines.append(f"{indent}\t<{xml_name}>true") lines.append(f"{indent}") return "\n".join(lines) def build_field_fragment(parsed, indent): i = indent lines = [f'{i}'] lines.append(f"{i}\t{esc_xml(parsed['dataPath'])}") lines.append(f"{i}\t{esc_xml(parsed['field'])}") # Title: prefer raw multi-lang OuterXml (preserves en/uk/etc.). When shorthand # provides a new ru text different from existing, patch the ru content. Otherwise # emit raw as-is or build ru-only from shorthand if there was no prior title. if parsed.get("_rawTitle"): if parsed.get("title") and parsed["title"] != parsed.get("_existingTitleRu"): lines.append(f"{i}\t" + patch_mltext_ru(parsed["_rawTitle"], parsed["title"], f"{i}\t")) else: lines.append(f"{i}\t" + parsed["_rawTitle"]) elif parsed.get("title"): lines.append(build_mltext_xml("title", parsed["title"], f"{i}\t")) if parsed.get("restrict"): lines.append(build_restriction_xml(parsed["restrict"], f"{i}\t")) role_xml = build_role_xml(parsed.get("roles"), f"{i}\t") if role_xml: lines.append(role_xml) if parsed.get("rawValueType"): # Preserve original verbatim — keeps qualifiers (StringQualifiers, # NumberQualifiers, DateQualifiers, …) that aren't expressible via shorthand. lines.append(f"{i}\t" + parsed["rawValueType"]) elif parsed.get("type"): lines.append(f"{i}\t") lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t") # Defense in depth: re-emit OuterXml of unknown children captured by Read. for raw in (parsed.get("_unknownChildren") or []): lines.append(f"{i}\t" + raw) lines.append(f"{i}") return "\n".join(lines) def build_total_fragment(parsed, indent): i = indent lines = [ f"{i}", f"{i}\t{esc_xml(parsed['dataPath'])}", f"{i}\t{esc_xml(parsed['expression'])}", f"{i}", ] return "\n".join(lines) def build_calc_field_fragment(parsed, indent): i = indent lines = [ f"{i}", f"{i}\t{esc_xml(parsed['dataPath'])}", f"{i}\t{esc_xml(parsed['expression'])}", ] if parsed.get("title"): lines.append(build_mltext_xml("title", parsed["title"], f"{i}\t")) if parsed.get("restrict"): lines.append(build_restriction_xml(parsed["restrict"], f"{i}\t")) if parsed.get("type"): lines.append(f"{i}\t") lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t") lines.append(f"{i}") return "\n".join(lines) def build_param_value_xml(type_str, value, indent, tag_name="value", tag_ns=""): """Return list of XML lines for ....""" val_str = "" if value is None else str(value) open_tag = f"{tag_ns}:{tag_name}" if tag_ns else tag_name lines = [] if type_str == "StandardPeriod": lines.append(f'{indent}<{open_tag} xsi:type="v8:StandardPeriod">') lines.append(f'{indent}\t{esc_xml(val_str)}') lines.append(f"{indent}\t0001-01-01T00:00:00") lines.append(f"{indent}\t0001-01-01T00:00:00") lines.append(f"{indent}") return lines t = type_str or "" xsi = None if t.startswith("date"): xsi = "xs:dateTime" elif t == "boolean": xsi = "xs:boolean" elif t.startswith("decimal"): xsi = "xs:decimal" elif t.startswith("string"): xsi = "xs:string" elif re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', t): xsi = "dcscor:DesignTimeValue" else: if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str): xsi = "xs:dateTime" elif val_str in ("true", "false"): xsi = "xs:boolean" elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', val_str) or \ re.match(r'^(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', val_str): xsi = "dcscor:DesignTimeValue" else: xsi = "xs:string" lines.append(f'{indent}<{open_tag} xsi:type="{xsi}">{esc_xml(val_str)}') return lines def build_param_fragment(parsed, indent): i = indent fragments = [] lines = [f"{i}", f"{i}\t{esc_xml(parsed['name'])}"] if parsed.get("title"): lines.append(build_mltext_xml("title", parsed["title"], f"{i}\t")) if parsed.get("type"): lines.append(f"{i}\t") lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t") vla = bool(parsed.get("valueListAllowed")) if isinstance(parsed["value"], list): # Multi-value default (value-list): one per item for v in parsed["value"]: for vl in build_param_value_xml(parsed.get("type", ""), v, f"{i}\t"): lines.append(vl) elif parsed["value"] is not None: if is_empty_value(parsed["value"]): empty_xml = build_empty_value_xml(parsed.get("type", ""), f"{i}\t", "", "value", vla) if empty_xml: lines.append(empty_xml) else: for vl in build_param_value_xml(parsed.get("type", ""), parsed["value"], f"{i}\t"): lines.append(vl) if parsed.get("hidden"): lines.append(f"{i}\ttrue") lines.append(f"{i}\tfalse") if vla: lines.append(f"{i}\ttrue") for av in parsed.get("availableValues", []) or []: for l in build_available_value_fragment(av, parsed.get("type", ""), f"{i}\t"): lines.append(l) if parsed.get("always"): lines.append(f"{i}\tAlways") lines.append(f"{i}") fragments.append("\n".join(lines)) if parsed.get("autoDates"): param_name = parsed["name"] # Canonical БСП pattern: title + valueType + value + useRestriction + expression b_lines = [ f"{i}", f"{i}\t\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430", build_mltext_xml("title", "\u041d\u0430\u0447\u0430\u043b\u043e \u043f\u0435\u0440\u0438\u043e\u0434\u0430", f"{i}\t"), f"{i}\t", build_value_type_xml("date", f"{i}\t\t"), f"{i}\t", f'{i}\t0001-01-01T00:00:00', f"{i}\ttrue", f"{i}\t{esc_xml('&' + param_name + '.\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430')}", f"{i}", ] fragments.append("\n".join(b_lines)) e_lines = [ f"{i}", f"{i}\t\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f", build_mltext_xml("title", "\u041a\u043e\u043d\u0435\u0446 \u043f\u0435\u0440\u0438\u043e\u0434\u0430", f"{i}\t"), f"{i}\t", build_value_type_xml("date", f"{i}\t\t"), f"{i}\t", f'{i}\t0001-01-01T00:00:00', f"{i}\ttrue", f"{i}\t{esc_xml('&' + param_name + '.\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f')}", f"{i}", ] fragments.append("\n".join(e_lines)) return fragments def build_filter_item_fragment(parsed, indent): i = indent lines = [f'{i}'] if parsed.get("use") is False: lines.append(f"{i}\tfalse") lines.append(f'{i}\t{esc_xml(parsed["field"])}') lines.append(f"{i}\t{esc_xml(parsed['op'])}") if parsed.get("value") is not None: vt = parsed.get("valueType", "xs:string") lines.append(f'{i}\t{esc_xml(str(parsed["value"]))}') if parsed.get("viewMode"): lines.append(f"{i}\t{esc_xml(parsed['viewMode'])}") if parsed.get("userSettingID"): uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] lines.append(f"{i}\t{esc_xml(uid)}") lines.append(f"{i}") return "\n".join(lines) def build_selection_item_fragment(field_name, indent): i = indent if field_name == "Auto": return f'{i}' m = re.match(r'^Folder\((.+)\)$', field_name) if m: inner = m.group(1) colon_idx = inner.find(':') if colon_idx > 0: title = inner[:colon_idx].strip() items = [x.strip() for x in inner[colon_idx + 1:].split(',') if x.strip()] else: title = "" items = [x.strip() for x in inner.split(',') if x.strip()] lines = [f'{i}'] if title: lines.append(f"{i}\t") lines.append(f"{i}\t\t") lines.append(f"{i}\t\t\tru") lines.append(f"{i}\t\t\t{esc_xml(title)}") lines.append(f"{i}\t\t") lines.append(f"{i}\t") for item in items: lines.append(f'{i}\t') lines.append(f"{i}\t\t{esc_xml(item)}") lines.append(f"{i}\t") lines.append(f"{i}\tAuto") lines.append(f"{i}") return "\n".join(lines) lines = [ f'{i}', f"{i}\t{esc_xml(field_name)}", f"{i}", ] return "\n".join(lines) def build_data_param_fragment(parsed, indent): i = indent lines = [f'{i}'] if parsed.get("use") is False: lines.append(f"{i}\tfalse") lines.append(f"{i}\t{esc_xml(parsed['parameter'])}") if parsed.get("value") is not None: val = parsed["value"] if isinstance(val, dict) and val.get("variant"): lines.append(f'{i}\t') lines.append(f'{i}\t\t{esc_xml(val["variant"])}') lines.append(f"{i}\t\t0001-01-01T00:00:00") lines.append(f"{i}\t\t0001-01-01T00:00:00") lines.append(f"{i}\t") elif is_empty_value(val): lines.append(f'{i}\t') elif re.match(r'^\d{4}-\d{2}-\d{2}T', str(val)): lines.append(f'{i}\t{esc_xml(str(val))}') elif str(val) in ("true", "false"): lines.append(f'{i}\t{esc_xml(str(val))}') else: lines.append(f'{i}\t{esc_xml(str(val))}') if parsed.get("viewMode"): lines.append(f"{i}\t{esc_xml(parsed['viewMode'])}") if parsed.get("userSettingID"): uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] lines.append(f"{i}\t{esc_xml(uid)}") lines.append(f"{i}") return "\n".join(lines) def build_order_item_fragment(parsed, indent): i = indent if parsed["field"] == "Auto": return f'{i}' lines = [ f'{i}', f"{i}\t{esc_xml(parsed['field'])}", f"{i}\t{parsed['direction']}", f"{i}", ] return "\n".join(lines) def build_data_set_link_fragment(parsed, indent): i = indent lines = [ f"{i}", f"{i}\t{esc_xml(parsed['source'])}", f"{i}\t{esc_xml(parsed['dest'])}", f"{i}\t{esc_xml(parsed['sourceExpr'])}", f"{i}\t{esc_xml(parsed['destExpr'])}", ] if parsed.get("parameter"): lines.append(f"{i}\t{esc_xml(parsed['parameter'])}") lines.append(f"{i}") return "\n".join(lines) def build_data_set_query_fragment(parsed, indent): i = indent lines = [ f'{i}', f"{i}\t{esc_xml(parsed['name'])}", f"{i}\t{esc_xml(parsed['dataSource'])}", f"{i}\t{esc_xml(parsed['query'])}", f"{i}", ] return "\n".join(lines) def build_variant_fragment(parsed, indent): i = indent lines = [ f"{i}", f"{i}\t{esc_xml(parsed['name'])}", build_mltext_xml("dcsset:presentation", parsed["presentation"], f"{i}\t"), f'{i}\t', f"{i}\t\t", f'{i}\t\t\t', f"{i}\t\t", f'{i}\t\t', f"{i}\t\t\t", f"{i}\t\t\t", f'{i}\t\t\t\t', f"{i}\t\t\t", f"{i}\t\t\t", f'{i}\t\t\t\t', f"{i}\t\t\t", f"{i}\t\t", f"{i}\t", f"{i}", ] return "\n".join(lines) def _emit_filter_comparison(lines, f, indent): lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(f["field"])}') lines.append(f"{indent}\t{esc_xml(f['op'])}") if f.get("value") is not None: vt = f.get("valueType", "xs:string") lines.append(f'{indent}\t{esc_xml(str(f["value"]))}') lines.append(f"{indent}") def build_conditional_appearance_item_fragment(parsed, indent): i = indent lines = [f"{i}"] if parsed.get("fields"): lines.append(f"{i}\t") for fld in parsed["fields"]: lines.append(f"{i}\t\t") lines.append(f"{i}\t\t\t{esc_xml(fld)}") lines.append(f"{i}\t\t") lines.append(f"{i}\t") else: lines.append(f"{i}\t") if parsed.get("filter"): flt = parsed["filter"] lines.append(f"{i}\t") if isinstance(flt, list): # OrGroup lines.append(f'{i}\t\t') lines.append(f"{i}\t\t\tOrGroup") for f in flt: _emit_filter_comparison(lines, f, f"{i}\t\t\t") lines.append(f"{i}\t\t") else: _emit_filter_comparison(lines, flt, f"{i}\t\t") lines.append(f"{i}\t") else: lines.append(f"{i}\t") # appearance lines.append(f"{i}\t") val = parsed["value"] lines.append(f'{i}\t\t') lines.append(f"{i}\t\t\t{esc_xml(parsed['param'])}") if re.match(r'^(web|style|win):', val): lines.append(f'{i}\t\t\t{esc_xml(val)}') elif val in ("true", "false"): lines.append(f'{i}\t\t\t{esc_xml(val)}') elif parsed["param"] in ("Формат", "Текст", "Заголовок"): lines.append(f'{i}\t\t\t') lines.append(f"{i}\t\t\t\t") lines.append(f"{i}\t\t\t\t\tru") lines.append(f"{i}\t\t\t\t\t{esc_xml(val)}") lines.append(f"{i}\t\t\t\t") lines.append(f"{i}\t\t\t") else: lines.append(f'{i}\t\t\t{esc_xml(val)}') lines.append(f"{i}\t\t") lines.append(f"{i}\t") lines.append(f"{i}") return "\n".join(lines) def build_structure_item_fragment(item, indent): i = indent lines = [f'{i}'] if item.get("name"): lines.append(f"{i}\t{esc_xml(item['name'])}") group_by = item.get("groupBy", []) if not group_by: lines.append(f"{i}\t") else: lines.append(f"{i}\t") for field in group_by: lines.append(f'{i}\t\t') lines.append(f"{i}\t\t\t{esc_xml(field)}") lines.append(f"{i}\t\t\tItems") lines.append(f"{i}\t\t\tNone") lines.append(f'{i}\t\t\t0001-01-01T00:00:00') lines.append(f'{i}\t\t\t0001-01-01T00:00:00') lines.append(f"{i}\t\t") lines.append(f"{i}\t") lines.append(f"{i}\t") lines.append(f'{i}\t\t') lines.append(f"{i}\t") lines.append(f"{i}\t") lines.append(f'{i}\t\t') lines.append(f"{i}\t") if item.get("children"): for child in item["children"]: lines.append(build_structure_item_fragment(child, f"{i}\t")) lines.append(f"{i}") return "\n".join(lines) def build_output_param_fragment(parsed, indent): i = indent key = parsed["key"] val = parsed["value"] ptype = output_param_types.get(key, "xs:string") lines = [f'{i}'] lines.append(f"{i}\t{esc_xml(key)}") if ptype == "mltext": lines.append(f'{i}\t') lines.append(f"{i}\t\t") lines.append(f"{i}\t\t\tru") lines.append(f"{i}\t\t\t{esc_xml(val)}") lines.append(f"{i}\t\t") lines.append(f"{i}\t") else: lines.append(f'{i}\t{esc_xml(val)}') lines.append(f"{i}") return "\n".join(lines) # ── 5. XML helpers ────────────────────────────────────────── def import_fragment(doc_root, xml_string): wrapper = f"<_W {WRAPPER_NS}>{xml_string}" frag_parser = etree.XMLParser(remove_blank_text=False) frag = etree.fromstring(wrapper.encode("utf-8"), frag_parser) nodes = [] for child in frag: if isinstance(child.tag, str): nodes.append(child) return nodes def get_child_indent(container): for i, child in enumerate(container): txt = container.text if i == 0 else container[i - 1].tail if txt: m = re.search(r'\n(\t+)$', txt) if m: return m.group(1) # Fallback: count depth depth = 0 current = container while current is not None: parent = current.getparent() if parent is None: break depth += 1 current = parent return "\t" * (depth + 1) def insert_before_element(container, new_node, ref_node, child_indent): if ref_node is not None: idx = list(container).index(ref_node) if idx == 0: prev_text = container.text or "" container.text = prev_text.rstrip("\n\t") + "\n" + child_indent container.insert(idx, new_node) new_node.tail = "\n" + child_indent else: prev = container[idx - 1] prev.tail = (prev.tail or "").rstrip("\n\t") + "\n" + child_indent container.insert(idx, new_node) new_node.tail = "\n" + child_indent else: # Append at end children = list(container) if children: last = children[-1] last.tail = (last.tail or "").rstrip("\n\t") + "\n" + child_indent container.append(new_node) parent_indent = child_indent[:-1] if len(child_indent) > 1 else "" new_node.tail = "\n" + parent_indent else: container.text = "\n" + child_indent container.append(new_node) parent_indent = child_indent[:-1] if len(child_indent) > 1 else "" new_node.tail = "\n" + parent_indent def clear_container_children(container): to_remove = [ch for ch in container if isinstance(ch.tag, str)] for el in to_remove: remove_node_with_whitespace(el) def remove_node_with_whitespace(node): parent = node.getparent() idx = list(parent).index(node) # Remove the node and adjust whitespace if idx > 0: prev = parent[idx - 1] # Preserve the tail of the removed node's predecessor prev.tail = node.tail elif idx == 0: parent.text = node.tail parent.remove(node) def find_first_element(container, local_names, ns_uri=None): for child in container: if not isinstance(child.tag, str): continue ln = local_name(child) if ln in local_names: if not ns_uri or etree.QName(child.tag).namespace == ns_uri: return child return None def find_last_element(container, ln_name, ns_uri=None): last = None for child in container: if not isinstance(child.tag, str): continue if local_name(child) == ln_name: if not ns_uri or etree.QName(child.tag).namespace == ns_uri: last = child return last def find_element_by_child_value(container, elem_name, child_name, child_value, ns_uri=None): for child in container: if not isinstance(child.tag, str): continue if local_name(child) != elem_name: continue if ns_uri and etree.QName(child.tag).namespace != ns_uri: continue for gc in child: if isinstance(gc.tag, str) and local_name(gc) == child_name and (gc.text or "").strip() == child_value: return child return None def set_or_create_child_element(parent, ln, ns_uri, value, indent): existing = None for ch in parent: if isinstance(ch.tag, str) and local_name(ch) == ln and etree.QName(ch.tag).namespace == ns_uri: existing = ch break if existing is not None: existing.text = value else: prefix = None for p, uri in parent.nsmap.items(): if uri == ns_uri: prefix = p break qual_name = f"{prefix}:{ln}" if prefix else ln frag_xml = f"{indent}<{qual_name}>{esc_xml(value)}" nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(parent, node, None, indent) def set_or_create_child_element_with_attr(parent, ln, ns_uri, value, xsi_type, indent): existing = None for ch in parent: if isinstance(ch.tag, str) and local_name(ch) == ln and etree.QName(ch.tag).namespace == ns_uri: existing = ch break if existing is not None: existing.text = value if xsi_type: existing.set(XSI_TYPE, xsi_type) else: prefix = None for p, uri in parent.nsmap.items(): if uri == ns_uri: prefix = p break qual_name = f"{prefix}:{ln}" if prefix else ln type_attr = f' xsi:type="{xsi_type}"' if xsi_type else "" frag_xml = f"{indent}<{qual_name}{type_attr}>{esc_xml(value)}" nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(parent, node, None, indent) def get_all_data_sets(): return [c for c in xml_doc if isinstance(c.tag, str) and local_name(c) == "dataSet" and etree.QName(c.tag).namespace == SCH_NS] def normalize_line_endings(s): if s is None: return s return s.replace("\r\n", "\n").replace("\r", "\n") def escape_whitespace(s): out = [] for ch in s: code = ord(ch) if ch == "\n": out.append("\\n") elif ch == "\r": out.append("\\r") elif ch == "\t": out.append("\\t") elif code < 32 or code == 0xA0 or (0x2000 <= code <= 0x200F) or code == 0xFEFF: out.append(f"\\u{code:04X}") else: out.append(ch) return "".join(out) def collapse_whitespace(s): return re.sub(r"[\s ]+", " ", s).strip() def find_longest_prefix_match(haystack, needle): """Binary search: largest L such that needle[:L] is a substring of haystack.""" if not needle or not haystack: return (0, -1) first_idx = haystack.find(needle[0]) if first_idx < 0: return (0, -1) lo, hi = 1, len(needle) best_len, best_off = 1, first_idx while lo <= hi: mid = (lo + hi) // 2 idx = haystack.find(needle[:mid]) if idx >= 0: best_len, best_off = mid, idx lo = mid + 1 else: hi = mid - 1 return (best_len, best_off) def format_patch_query_not_found(old_str, query_text, current_ds_node, ds_name): lines = [f"Substring not found in query of dataset '{ds_name}'."] # Step 1 — cross-dataset probe for ds in get_all_data_sets(): if ds is current_ds_node: continue q = find_first_element(ds, ["query"], SCH_NS) if q is None: continue qt = normalize_line_endings(q.text or "") if old_str in qt: other = get_data_set_name(ds) lines.append(f"Found in dataset '{other}' instead — wrong -DataSet?") return "\n".join(lines) # Step 2 — tolerant probe norm_needle = collapse_whitespace(old_str) norm_hay = collapse_whitespace(query_text) tolerant = bool(norm_needle) and (norm_needle in norm_hay) # Step 3 — divergence matched, off = find_longest_prefix_match(query_text, old_str) divergence = None if 0 < matched < len(old_str): query_pos = off + matched before_len = min(20, matched) divergence = { "matched": matched, "total": len(old_str), "before": old_str[matched - before_len:matched], "search_char": old_str[matched], "query_char": query_text[query_pos] if query_pos < len(query_text) else None, } if tolerant: lines.append("Not found exactly, but would match with whitespace normalized (tabs/spaces/NBSP).") if divergence: lines.append(f"Diverged at offset {divergence['matched']} of {divergence['total']}:") lines.append(f" before: '{escape_whitespace(divergence['before'])}'") sc = divergence['search_char'] lines.append(f" in search: '{escape_whitespace(sc)}' (U+{ord(sc):04X})") qc = divergence['query_char'] if qc is not None: lines.append(f" in query: '{escape_whitespace(qc)}' (U+{ord(qc):04X})") return "\n".join(lines) if matched == 0: lines.append(f"No common prefix with query. Check -DataSet (current: '{ds_name}').") return "\n".join(lines) lines.append(f"Matched first {divergence['matched']} of {divergence['total']} chars, then diverged:") lines.append(f" before: '{escape_whitespace(divergence['before'])}'") sc = divergence['search_char'] lines.append(f" in search: '{escape_whitespace(sc)}' (U+{ord(sc):04X})") qc = divergence['query_char'] if qc is not None: lines.append(f" in query: '{escape_whitespace(qc)}' (U+{ord(qc):04X})") else: lines.append(" in query: (end of query)") return "\n".join(lines) def resolve_data_set(): root_el = xml_doc if data_set_arg: for child in root_el: if isinstance(child.tag, str) and local_name(child) == "dataSet" and etree.QName(child.tag).namespace == SCH_NS: for gc in child: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SCH_NS: if gc.text == data_set_arg: return child print(f"DataSet '{data_set_arg}' not found", file=sys.stderr) sys.exit(1) for child in root_el: if isinstance(child.tag, str) and local_name(child) == "dataSet" and etree.QName(child.tag).namespace == SCH_NS: return child print("No dataSet found in DCS", file=sys.stderr) sys.exit(1) def resolve_variant_settings(): root_el = xml_doc sv = None if variant_arg: for child in root_el: if isinstance(child.tag, str) and local_name(child) == "settingsVariant" and etree.QName(child.tag).namespace == SCH_NS: for gc in child: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SET_NS: if gc.text == variant_arg: sv = child break if sv: break if sv is None: print(f"Variant '{variant_arg}' not found", file=sys.stderr) sys.exit(1) else: for child in root_el: if isinstance(child.tag, str) and local_name(child) == "settingsVariant" and etree.QName(child.tag).namespace == SCH_NS: sv = child break if sv is None: print("No settingsVariant found in DCS", file=sys.stderr) sys.exit(1) for gc in sv: if isinstance(gc.tag, str) and local_name(gc) == "settings" and etree.QName(gc.tag).namespace == SET_NS: return gc print("No found in variant", file=sys.stderr) sys.exit(1) def ensure_settings_child(settings, child_name, after_siblings): el = find_first_element(settings, [child_name], SET_NS) if el is not None: return el indent = get_child_indent(settings) frag_xml = f"{indent}" nodes = import_fragment(xml_doc, frag_xml) ref_node = None for sib_name in after_siblings: sib = find_first_element(settings, [sib_name], SET_NS) if sib is not None: # Get next element sibling found = False for ch in settings: if found and isinstance(ch.tag, str): ref_node = ch break if ch is sib: found = True break for node in nodes: insert_before_element(settings, node, ref_node, indent) return find_first_element(settings, [child_name], SET_NS) def get_variant_name(): if variant_arg: return variant_arg root_el = xml_doc for child in root_el: if isinstance(child.tag, str) and local_name(child) == "settingsVariant" and etree.QName(child.tag).namespace == SCH_NS: for gc in child: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SET_NS: return gc.text or "(unknown)" return "(unknown)" def get_data_set_name(ds_node): for gc in ds_node: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SCH_NS: return gc.text or "(unknown)" return "(unknown)" def get_container_child_indent(container): has_elements = any(isinstance(ch.tag, str) for ch in container) if has_elements: return get_child_indent(container) else: parent_indent = get_child_indent(container.getparent()) return parent_indent + "\t" # ── 6. Load XML ───────────────────────────────────────────── # Capture raw original BEFORE parse — needed at save time to restore exact root # opening tag (lxml's tostring() collapses multi-line # xmlns into one line) and to detect NO-OP via byte-equality as a safety net. with open(resolved_path, "rb") as _f: raw_original_bytes = _f.read() raw_original_text = raw_original_bytes.lstrip(b"\xef\xbb\xbf").decode("utf-8") _root_open_m = re.search(r"]*>", raw_original_text, re.DOTALL) raw_root_opening = _root_open_m.group(0) if _root_open_m else None # Detect line ending convention so save can normalize back to whatever the source used. line_ending = "\r\n" if "\r\n" in raw_original_text else "\n" xml_parser = etree.XMLParser(remove_blank_text=False) tree = etree.parse(resolved_path, xml_parser) xml_doc = tree.getroot() # ── 7. Batch value splitting ──────────────────────────────── if operation in ("set-query", "set-structure", "modify-structure", "add-dataSet"): values = [value_arg] elif operation == "patch-query": values = [v.strip() for v in value_arg.split(";;") if v.strip()] elif operation == "add-drilldown": if ";;" in value_arg: values = [v.strip() for v in value_arg.split(";;") if v.strip()] else: values = [v.strip() for v in value_arg.split(",") if v.strip()] else: values = [v.strip() for v in value_arg.split(";;") if v.strip()] # ── 8. Main logic ─────────────────────────────────────────── if operation == "add-field": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) for val in values: parsed = parse_field_shorthand(val) child_indent = get_child_indent(ds_node) existing = find_element_by_child_value(ds_node, "field", "dataPath", parsed["dataPath"], SCH_NS) if existing is not None: print(f'[WARN] Field "{parsed["dataPath"]}" already exists in dataset "{ds_name}" -- skipped') continue frag_xml = build_field_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) ref_node = find_first_element(ds_node, ["dataSource"], SCH_NS) for node in nodes: insert_before_element(ds_node, node, ref_node, child_indent) dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to dataset "{ds_name}"') if not no_selection: settings = resolve_variant_settings() var_name = get_variant_name() selection = ensure_settings_child(settings, "selection", []) existing_sel = find_element_by_child_value(selection, "item", "field", parsed["dataPath"], SET_NS) if existing_sel is not None: print(f'[INFO] Field "{parsed["dataPath"]}" already in selection -- skipped') else: sel_indent = get_container_child_indent(selection) sel_xml = build_selection_item_fragment(parsed["dataPath"], sel_indent) sel_nodes = import_fragment(xml_doc, sel_xml) for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') elif operation == "add-total": for val in values: parsed = parse_total_shorthand(val) child_indent = get_child_indent(xml_doc) existing = find_element_by_child_value(xml_doc, "totalField", "dataPath", parsed["dataPath"], SCH_NS) if existing is not None: print(f'[WARN] TotalField "{parsed["dataPath"]}" already exists -- skipped') continue frag_xml = build_total_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) last_total = find_last_element(xml_doc, "totalField", SCH_NS) if last_total is not None: # Insert after last totalField - find next element ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_total: found = True else: ref_node = find_first_element(xml_doc, ["parameter", "template", "groupTemplate", "settingsVariant"], SCH_NS) for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) dirty = True; print(f'[OK] TotalField "{parsed["dataPath"]}" = {parsed["expression"]} added') elif operation == "add-calculated-field": for val in values: parsed = parse_calc_shorthand(val) child_indent = get_child_indent(xml_doc) existing = find_element_by_child_value(xml_doc, "calculatedField", "dataPath", parsed["dataPath"], SCH_NS) if existing is not None: print(f'[WARN] CalculatedField "{parsed["dataPath"]}" already exists -- skipped') continue frag_xml = build_calc_field_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) last_calc = find_last_element(xml_doc, "calculatedField", SCH_NS) if last_calc is not None: ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_calc: found = True else: ref_node = find_first_element(xml_doc, ["totalField", "parameter", "template", "groupTemplate", "settingsVariant"], SCH_NS) for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) dirty = True; print(f'[OK] CalculatedField "{parsed["dataPath"]}" = {parsed["expression"]} added') if not no_selection: settings = resolve_variant_settings() var_name = get_variant_name() selection = ensure_settings_child(settings, "selection", []) existing_sel = find_element_by_child_value(selection, "item", "field", parsed["dataPath"], SET_NS) if existing_sel is not None: print(f'[INFO] Field "{parsed["dataPath"]}" already in selection -- skipped') else: sel_indent = get_container_child_indent(selection) sel_xml = build_selection_item_fragment(parsed["dataPath"], sel_indent) sel_nodes = import_fragment(xml_doc, sel_xml) for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) dirty = True; print(f'[OK] Field "{parsed["dataPath"]}" added to selection of variant "{var_name}"') elif operation == "add-parameter": for val in values: parsed = parse_param_shorthand(val) child_indent = get_child_indent(xml_doc) existing = find_element_by_child_value(xml_doc, "parameter", "name", parsed["name"], SCH_NS) if existing is not None: print(f'[WARN] Parameter "{parsed["name"]}" already exists -- skipped') continue fragments = build_param_fragment(parsed, child_indent) last_param = find_last_element(xml_doc, "parameter", SCH_NS) if last_param is not None: ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_param: found = True else: ref_node = find_first_element(xml_doc, ["template", "groupTemplate", "settingsVariant"], SCH_NS) for frag_xml in fragments: nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) dirty = True; print(f'[OK] Parameter "{parsed["name"]}" added') if parsed.get("autoDates"): dirty = True; print('[OK] Auto-parameters "\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430", "\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f" added') elif operation == "modify-parameter": for val in values: # Extract optional [Title] first (mirrors parse_field_shorthand) title_val = None m_title = re.search(r'\[([^\]]*)\]', val) if m_title: title_val = m_title.group(1).strip() val = re.sub(r'\s*\[[^\]]*\]\s*', ' ', val).strip() parts = val.split(None, 1) param_name = parts[0].strip() rest = parts[1].strip() if len(parts) > 1 else "" flag_hidden = False flag_always = False if re.search(r'@hidden\b', rest): flag_hidden = True rest = re.sub(r'\s*@hidden\b', '', rest).strip() if re.search(r'@always\b', rest): flag_always = True rest = re.sub(r'\s*@always\b', '', rest).strip() param_el = find_element_by_child_value(xml_doc, "parameter", "name", param_name, SCH_NS) if param_el is None: print(f'[WARN] Parameter "{param_name}" not found -- skipped') continue child_indent = get_child_indent(param_el) # Set/replace title (must come right after , before ) if title_val is not None: existing_title = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "title"), None) # If existing title is multi-lang (has >1 ), patch ru content # while preserving other languages. Otherwise rebuild as ru-only. title_frag = None if existing_title is not None: raw_title = etree.tostring(existing_title, encoding="unicode", with_tail=False) raw_title = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw_title) if raw_title.count("") > 1: title_frag = child_indent + patch_mltext_ru(raw_title, title_val, child_indent) remove_node_with_whitespace(existing_title) if title_frag is None: title_frag = build_mltext_xml("title", title_val, child_indent) # Insert before the first child after title_ref = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) != "name"), None) for node in import_fragment(xml_doc, title_frag): insert_before_element(param_el, node, title_ref, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": title set to "{title_val}"') # Separate availableValue=... from simple kv pairs simple_rest = rest av_part = None av_idx = rest.find("availableValue=") if av_idx >= 0: simple_rest = rest[:av_idx].strip() av_part = rest[av_idx:] # Separate a multi-value value=... (list) — kv-regex below grabs only a single # \S+ token, so a comma-separated list (with spaces) wouldn't be captured. value_list_items = None vl_idx = simple_rest.find("value=") if vl_idx >= 0: vl_rhs = simple_rest[vl_idx + len("value="):] cand = parse_value_list(vl_rhs) if len(cand) >= 2: value_list_items = cand simple_rest = simple_rest[:vl_idx].strip() # Process simple key=value pairs (use, denyIncompleteValues, etc.) if simple_rest: for m in re.finditer(r'(\w+)=(\S+)', simple_rest): key, value = m.group(1), m.group(2) existing = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == key and etree.QName(ch.tag).namespace == SCH_NS), None) if key == "value": # Rebuild with correct xsi:type from declared_type = "" vt_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS), None) if vt_el is not None: for tnode in vt_el: if isinstance(tnode.tag, str) and local_name(tnode) == "Type": declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip()) break # Detect valueListAllowed — empty value should be omitted when set vla_set = False vla_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueListAllowed" and etree.QName(ch.tag).namespace == SCH_NS), None) if vla_el is not None and (vla_el.text or "").strip() == "true": vla_set = True if is_empty_value(value): frag_xml = build_empty_value_xml(declared_type, child_indent, "", "value", vla_set) else: value_lines = build_param_value_xml(declared_type, value, child_indent) frag_xml = "\n".join(value_lines) # Collect ALL existing (a param may carry a value-list) — scalar # value= collapses them to one, so remove every , not just the first. all_value_els = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "value" and etree.QName(ch.tag).namespace == SCH_NS] was_existing = len(all_value_els) > 0 if was_existing: last_idx = list(param_el).index(all_value_els[-1]) ref_node = param_el[last_idx + 1] if last_idx + 1 < len(param_el) else None for ve in all_value_els: remove_node_with_whitespace(ve) else: ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None) if frag_xml: nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) verb = "updated" if was_existing else "added" dirty = True; print(f'[OK] Parameter "{param_name}": value {verb} to {value}') elif existing is not None: existing.text = value dirty = True; print(f'[OK] Parameter "{param_name}": {key} updated to {value}') else: # Schema order: ...value, useRestriction, availableValue*, denyIncompleteValues, use ref_node = None if key == "denyIncompleteValues": ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "use"), None) frag_xml = f"{child_indent}<{key}>{esc_xml(value)}" nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": {key}={value} added') # Process multi-value list (value=v1, v2, ...) — replace ALL , ensure valueListAllowed=true if value_list_items: declared_type = "" vt_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS), None) if vt_el is not None: for tnode in vt_el: if isinstance(tnode.tag, str) and local_name(tnode) == "Type": declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip()) break # Remove ALL existing ; capture insertion ref after the last one value_els = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "value" and etree.QName(ch.tag).namespace == SCH_NS] if value_els: last_idx = list(param_el).index(value_els[-1]) ref_node = param_el[last_idx + 1] if last_idx + 1 < len(param_el) else None for ve in value_els: remove_node_with_whitespace(ve) else: ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None) for v in value_list_items: frag_xml = "\n".join(build_param_value_xml(declared_type, v, child_indent)) for node in import_fragment(xml_doc, frag_xml): insert_before_element(param_el, node, ref_node, child_indent) # Ensure true (schema order: after useRestriction, before availableValue/use) vla_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueListAllowed" and etree.QName(ch.tag).namespace == SCH_NS), None) if vla_el is not None: if (vla_el.text or "").strip() != "true": vla_el.text = "true" else: ref_vla = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("availableValue", "denyIncompleteValues", "use")), None) for node in import_fragment(xml_doc, f"{child_indent}true"): insert_before_element(param_el, node, ref_vla, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": value set to list of {len(value_list_items)} item(s)') # Process availableValue if av_part: av_rest = av_part[len("availableValue="):].strip() av_items = parse_available_value_list(av_rest) # Prefer declared of the parameter; fall back to value pattern declared_type = "" vt_el = None for ch in param_el: if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS: vt_el = ch break if vt_el is not None: for tnode in vt_el: if isinstance(tnode.tag, str) and local_name(tnode) == "Type": declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip()) break # Remove all existing to_remove = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableValue" and etree.QName(ch.tag).namespace == SCH_NS] for el in to_remove: remove_node_with_whitespace(el) # Insert each new before (denyIncompleteValues, use) ref_node = None for child in param_el: if isinstance(child.tag, str) and local_name(child) in ("denyIncompleteValues", "use"): ref_node = child break for av in av_items: av_lines = build_available_value_fragment(av, declared_type, child_indent) frag_xml = "\n".join(av_lines) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(param_el, node, ref_node, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": availableValue set to {len(av_items)} item(s)') if flag_hidden: ur_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "useRestriction" and etree.QName(ch.tag).namespace == SCH_NS), None) if ur_el is not None: if (ur_el.text or "").strip() != "true": ur_el.text = "true" else: ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("expression", "availableAsField", "availableValue", "denyIncompleteValues", "use")), None) for node in import_fragment(xml_doc, f"{child_indent}true"): insert_before_element(param_el, node, ref_node, child_indent) af_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableAsField" and etree.QName(ch.tag).namespace == SCH_NS), None) if af_el is not None: if (af_el.text or "").strip() != "false": af_el.text = "false" else: ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("availableValue", "denyIncompleteValues", "use")), None) for node in import_fragment(xml_doc, f"{child_indent}false"): insert_before_element(param_el, node, ref_node, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": @hidden applied') if flag_always: use_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == SCH_NS), None) if use_el is not None: if (use_el.text or "").strip() != "Always": use_el.text = "Always" else: for node in import_fragment(xml_doc, f"{child_indent}Always"): insert_before_element(param_el, node, None, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": @always applied') elif operation == "rename-parameter": root = xml_doc for val in values: m_rn = re.match(r'^\s*(.+?)\s*=>\s*(.+?)\s*$', val) if not m_rn: print(f'[WARN] rename-parameter expects "OldName => NewName", got: {val}') continue old_name = m_rn.group(1).strip() new_name = m_rn.group(2).strip() if old_name == new_name: print('[WARN] rename-parameter: old and new names are equal -- skipped') continue # 1. Rename OldName param_el = find_element_by_child_value(root, "parameter", "name", old_name, SCH_NS) if param_el is None: print(f'[WARN] Parameter "{old_name}" not found -- skipped') continue for ch in param_el: if isinstance(ch.tag, str) and local_name(ch) == "name" and etree.QName(ch.tag).namespace == SCH_NS: ch.text = new_name break # 2. Update in other elements. # Regex matches "&OldName" only when followed by a non-identifier char (or end), # so "&Период" matches "&Период.ДатаНачала" but NOT "&ПериодОтчета". esc_old = re.escape(old_name) expr_regex = re.compile(rf'&{esc_old}(?=[^\w\u0400-\u04FF]|$)') expr_updated = 0 for ch in root: if not (isinstance(ch.tag, str) and local_name(ch) == "parameter" and etree.QName(ch.tag).namespace == SCH_NS): continue for gc in ch: if isinstance(gc.tag, str) and local_name(gc) == "expression" and etree.QName(gc.tag).namespace == SCH_NS: old_expr = gc.text or "" new_expr = expr_regex.sub(f'&{new_name}', old_expr) if new_expr != old_expr: gc.text = new_expr expr_updated += 1 # 3. Update OldName in dataParameters of all variants. dp_updated = 0 for variant_node in root: if not (isinstance(variant_node.tag, str) and local_name(variant_node) == "settingsVariant" and etree.QName(variant_node.tag).namespace == SCH_NS): continue settings_node = find_first_element(variant_node, ["settings"], SET_NS) if settings_node is None: continue dp_el = find_first_element(settings_node, ["dataParameters"], SET_NS) if dp_el is None: continue for item in dp_el: if not (isinstance(item.tag, str) and local_name(item) == "item"): continue for gc in item: if isinstance(gc.tag, str) and local_name(gc) == "parameter" and etree.QName(gc.tag).namespace == COR_NS: if (gc.text or "").strip() == old_name: gc.text = new_name dp_updated += 1 dirty = True; print(f'[OK] Parameter renamed: "{old_name}" => "{new_name}" (expressions updated: {expr_updated}, dataParameters updated: {dp_updated})') elif operation == "reorder-parameters": root = xml_doc for val in values: order = [s.strip() for s in val.split(",") if s.strip()] if not order: print('[WARN] reorder-parameters: empty list -- skipped') continue all_params = [] for ch in root: if isinstance(ch.tag, str) and local_name(ch) == "parameter" and etree.QName(ch.tag).namespace == SCH_NS: all_params.append(ch) if not all_params: print('[WARN] reorder-parameters: no parameters in schema') continue child_indent = get_child_indent(root) by_name = {} for pe in all_params: for gc in pe: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SCH_NS: by_name[(gc.text or "").strip()] = pe break new_order = [] used = set() for name in order: if name in by_name: new_order.append(by_name[name]) used.add(name) else: print(f'[WARN] reorder-parameters: parameter "{name}" not found -- skipped') for pe in all_params: pe_name = None for gc in pe: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SCH_NS: pe_name = (gc.text or "").strip() break if pe_name and pe_name not in used: new_order.append(pe) # Anchor: element right after the last parameter in original order last_param = all_params[-1] anchor = last_param.getnext() # Remove all parameters with surrounding whitespace for pe in all_params: remove_node_with_whitespace(pe) # Re-insert in new order before anchor for pe in new_order: insert_before_element(root, pe, anchor, child_indent) dirty = True; print(f'[OK] Parameters reordered ({len(all_params)} total, {len(order)} explicit)') elif operation == "add-filter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_filter_shorthand(val) filter_el = ensure_settings_child(settings, "filter", ["selection"]) filter_indent = get_container_child_indent(filter_el) frag_xml = build_filter_item_fragment(parsed, filter_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(filter_el, node, None, filter_indent) dirty = True; print(f'[OK] Filter "{parsed["field"]} {parsed["op"]}" added to variant "{var_name}"') elif operation == "add-dataParameter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_data_param_shorthand(val) dp_el = ensure_settings_child(settings, "dataParameters", ["outputParameters", "conditionalAppearance", "order", "filter", "selection"]) dp_indent = get_container_child_indent(dp_el) frag_xml = build_data_param_fragment(parsed, dp_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(dp_el, node, None, dp_indent) dirty = True; print(f'[OK] DataParameter "{parsed["parameter"]}" added to variant "{var_name}"') elif operation == "add-order": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_order_shorthand(val) order_el = ensure_settings_child(settings, "order", ["filter", "selection"]) order_indent = get_container_child_indent(order_el) if parsed["field"] == "Auto": is_dup = False for ch in order_el: if isinstance(ch.tag, str) and local_name(ch) == "item": type_attr = ch.get(XSI_TYPE, "") if "OrderItemAuto" in type_attr: is_dup = True break if is_dup: print(f'[WARN] OrderItemAuto already exists in variant "{var_name}" -- skipped') continue else: existing_ord = find_element_by_child_value(order_el, "item", "field", parsed["field"], SET_NS) if existing_ord is not None: print(f'[WARN] Order "{parsed["field"]}" already exists in variant "{var_name}" -- skipped') continue frag_xml = build_order_item_fragment(parsed, order_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(order_el, node, None, order_indent) desc = "Auto" if parsed["field"] == "Auto" else f"{parsed['field']} {parsed['direction']}" dirty = True; print(f'[OK] Order "{desc}" added to variant "{var_name}"') elif operation == "add-selection": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: field_name = val.strip() group_name = None # Extract @group=Name gm = re.search(r'\s*@group=(\S+)', field_name) if gm: group_name = gm.group(1) field_name = re.sub(r'\s*@group=\S+', '', field_name).strip() if group_name: # Find named StructureItemGroup target_el = None for item in settings.iter(f"{{{SET_NS}}}item"): xsi_type = item.get(f"{{{XSI_NS}}}type", "") if "StructureItemGroup" in xsi_type: name_el = item.find(f"{{{SET_NS}}}name") if name_el is not None and name_el.text == group_name: target_el = item break if target_el is None: print(f'[WARN] StructureItemGroup "{group_name}" not found -- adding to variant level') target_el = settings else: target_el = settings selection = ensure_settings_child(target_el, "selection", []) # Dedup: skip if SelectedItemAuto already exists if field_name == "Auto": is_dup = False for ch in selection: if isinstance(ch.tag, str) and local_name(ch) == "item": type_attr = ch.get(XSI_TYPE, "") if "SelectedItemAuto" in type_attr: is_dup = True break if is_dup: target = f'group "{group_name}"' if group_name else f'variant "{var_name}"' print(f'[WARN] SelectedItemAuto already exists in {target} -- skipped') continue sel_indent = get_container_child_indent(selection) sel_xml = build_selection_item_fragment(field_name, sel_indent) sel_nodes = import_fragment(xml_doc, sel_xml) for node in sel_nodes: insert_before_element(selection, node, None, sel_indent) target = f'group "{group_name}"' if group_name else f'variant "{var_name}"' dirty = True; print(f'[OK] Selection "{field_name}" added to {target}') elif operation == "set-query": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) query_el = find_first_element(ds_node, ["query"], SCH_NS) if query_el is None: print(f"No element found in dataset '{ds_name}'", file=sys.stderr) sys.exit(1) query_el.text = resolve_query_value(value_arg, query_base_dir) dirty = True; print(f'[OK] Query replaced in dataset "{ds_name}"') elif operation == "patch-query": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) query_el = find_first_element(ds_node, ["query"], SCH_NS) if query_el is None: print(f"No element found in dataset '{ds_name}'", file=sys.stderr) sys.exit(1) for val in values: once = False if re.search(r'@once\b', val): once = True val = re.sub(r'\s*@once\b', '', val).strip() sep_idx = val.find(" => ") if sep_idx < 0: print("patch-query value must contain ' => ' separator: old => new", file=sys.stderr) sys.exit(1) old_str = normalize_line_endings(val[:sep_idx]) new_str = normalize_line_endings(val[sep_idx + 4:]) query_text = normalize_line_endings(query_el.text or "") count = query_text.count(old_str) if count == 0: print(format_patch_query_not_found(old_str, query_text, ds_node, ds_name), file=sys.stderr) sys.exit(1) if once and count != 1: print(f"@once: expected 1 occurrence of '{old_str}' in dataset '{ds_name}', found {count}", file=sys.stderr) sys.exit(1) query_el.text = query_text.replace(old_str, new_str) suffix = " (1 occurrence)" if once else f" ({count} occurrence(s))" dirty = True; print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'{suffix}') elif operation == "set-outputParameter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_output_param_shorthand(val) output_el = ensure_settings_child(settings, "outputParameters", ["conditionalAppearance", "order", "filter", "selection"]) output_indent = get_container_child_indent(output_el) existing_param = find_element_by_child_value(output_el, "item", "parameter", parsed["key"], COR_NS) if existing_param is not None: remove_node_with_whitespace(existing_param) dirty = True; print(f'[OK] Replaced outputParameter "{parsed["key"]}" in variant "{var_name}"') else: dirty = True; print(f'[OK] OutputParameter "{parsed["key"]}" added to variant "{var_name}"') frag_xml = build_output_param_fragment(parsed, output_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(output_el, node, None, output_indent) elif operation == "set-structure": settings = resolve_variant_settings() var_name = get_variant_name() to_remove = [ch for ch in settings if isinstance(ch.tag, str) and local_name(ch) == "item" and etree.QName(ch.tag).namespace == SET_NS] for el in to_remove: remove_node_with_whitespace(el) struct_items = parse_structure_shorthand(value_arg) settings_indent = get_child_indent(settings) ref_node = find_first_element(settings, ["outputParameters", "dataParameters", "conditionalAppearance", "order", "filter", "selection", "item"], SET_NS) for struct_item in struct_items: frag_xml = build_structure_item_fragment(struct_item, settings_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(settings, node, ref_node, settings_indent) dirty = True; print(f'[OK] Structure set in variant "{var_name}": {value_arg}') elif operation == "modify-structure": settings = resolve_variant_settings() var_name = get_variant_name() struct_items = parse_structure_shorthand(value_arg) # Flatten parsed tree into (name, groupBy) targets targets = [] stack = list(struct_items) while stack: it = stack.pop() if it.get("name"): targets.append({"name": it["name"], "groupBy": it.get("groupBy", [])}) for ch in it.get("children", []) or []: stack.append(ch) if not targets: print(f"modify-structure requires @name= for at least one group: {value_arg}", file=sys.stderr) sys.exit(1) ns = {"dcsset": SET_NS, "xsi": XSI_NS} for t in targets: xpath = f".//dcsset:item[@xsi:type='dcsset:StructureItemGroup'][dcsset:name='{t['name']}']" group_el = settings.find(xpath, ns) if group_el is None: print(f'[WARN] Group with @name="{t["name"]}" not found — skipped') continue gi_el = None for ch in group_el: if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS: gi_el = ch break group_indent = get_child_indent(group_el) if gi_el is None: # Insert after , before first non-name sibling ref_after_name = None saw_name = False for ch in group_el: if not isinstance(ch.tag, str): continue if local_name(ch) == "name" and etree.QName(ch.tag).namespace == SET_NS: saw_name = True elif saw_name: ref_after_name = ch break gi_frag = f"{group_indent}" for node in import_fragment(xml_doc, gi_frag): insert_before_element(group_el, node, ref_after_name, group_indent) for ch in group_el: if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS: gi_el = ch break to_remove = [ch for ch in gi_el if isinstance(ch.tag, str)] for el in to_remove: remove_node_with_whitespace(el) item_indent = group_indent + "\t" for field in t["groupBy"]: lines = [ f'{item_indent}', f'{item_indent}\t{esc_xml(field)}', f'{item_indent}\tItems', f'{item_indent}\tNone', f'{item_indent}\t0001-01-01T00:00:00', f'{item_indent}\t0001-01-01T00:00:00', f'{item_indent}', ] frag_xml = "\n".join(lines) for node in import_fragment(xml_doc, frag_xml): insert_before_element(gi_el, node, None, item_indent) desc = "details" if not t["groupBy"] else ", ".join(t["groupBy"]) dirty = True; print(f'[OK] Group "{t["name"]}" groupItems updated: {desc}') elif operation == "add-dataSetLink": for val in values: parsed = parse_data_set_link_shorthand(val) child_indent = get_child_indent(xml_doc) frag_xml = build_data_set_link_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) last_link = find_last_element(xml_doc, "dataSetLink", SCH_NS) if last_link is not None: ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_link: found = True else: ref_node = find_first_element(xml_doc, ["calculatedField", "totalField", "parameter", "template", "groupTemplate", "settingsVariant"], SCH_NS) for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) desc = f"{parsed['source']} > {parsed['dest']} on {parsed['sourceExpr']} = {parsed['destExpr']}" if parsed.get("parameter"): desc += f" [param {parsed['parameter']}]" dirty = True; print(f'[OK] DataSetLink "{desc}" added') elif operation == "add-dataSet": child_indent = get_child_indent(xml_doc) parsed = parse_data_set_shorthand(value_arg) parsed["query"] = resolve_query_value(parsed["query"], query_base_dir) if not parsed["name"]: count = sum(1 for ch in xml_doc if isinstance(ch.tag, str) and local_name(ch) == "dataSet" and etree.QName(ch.tag).namespace == SCH_NS) parsed["name"] = f"\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445{count + 1}" existing = find_element_by_child_value(xml_doc, "dataSet", "name", parsed["name"], SCH_NS) if existing is not None: print(f'[WARN] DataSet "{parsed["name"]}" already exists -- skipped') else: ds_source_el = find_first_element(xml_doc, ["dataSource"], SCH_NS) ds_source_name = "\u0418\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0414\u0430\u043d\u043d\u044b\u04451" if ds_source_el is not None: name_el = find_first_element(ds_source_el, ["name"], SCH_NS) if name_el is not None: ds_source_name = (name_el.text or "").strip() parsed["dataSource"] = ds_source_name frag_xml = build_data_set_query_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) last_ds = find_last_element(xml_doc, "dataSet", SCH_NS) if last_ds is not None: ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_ds: found = True else: ref_node = find_first_element(xml_doc, ["dataSetLink", "calculatedField", "totalField", "parameter", "template", "groupTemplate", "settingsVariant"], SCH_NS) for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) dirty = True; print(f'[OK] DataSet "{parsed["name"]}" added (dataSource={ds_source_name})') elif operation == "add-variant": child_indent = get_child_indent(xml_doc) for val in values: parsed = parse_variant_shorthand(val) is_dup = False for ch in xml_doc: if isinstance(ch.tag, str) and local_name(ch) == "settingsVariant" and etree.QName(ch.tag).namespace == SCH_NS: for gc in ch: if isinstance(gc.tag, str) and local_name(gc) == "name" and etree.QName(gc.tag).namespace == SET_NS and gc.text == parsed["name"]: is_dup = True break if is_dup: break if is_dup: print(f'[WARN] Variant "{parsed["name"]}" already exists -- skipped') continue frag_xml = build_variant_fragment(parsed, child_indent) nodes = import_fragment(xml_doc, frag_xml) last_sv = find_last_element(xml_doc, "settingsVariant", SCH_NS) if last_sv is not None: ref_node = None found = False for ch in xml_doc: if found and isinstance(ch.tag, str): ref_node = ch break if ch is last_sv: found = True else: ref_node = None for node in nodes: insert_before_element(xml_doc, node, ref_node, child_indent) dirty = True; print(f'[OK] Variant "{parsed["name"]}" ["{parsed["presentation"]}"] added') elif operation == "add-conditionalAppearance": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_conditional_appearance_shorthand(val) ca_el = ensure_settings_child(settings, "conditionalAppearance", ["outputParameters", "order", "filter", "selection"]) ca_indent = get_container_child_indent(ca_el) frag_xml = build_conditional_appearance_item_fragment(parsed, ca_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(ca_el, node, None, ca_indent) desc = f"{parsed['param']} = {parsed['value']}" if parsed.get("filter"): flt = parsed["filter"] if isinstance(flt, list): desc += f" when OrGroup({len(flt)} conditions)" else: desc += f" when {flt['field']} {flt['op']}" if parsed.get("fields"): desc += f" for {', '.join(parsed['fields'])}" dirty = True; print(f'[OK] ConditionalAppearance "{desc}" added to variant "{var_name}"') elif operation == "clear-selection": settings = resolve_variant_settings() var_name = get_variant_name() selection = find_first_element(settings, ["selection"], SET_NS) if selection is not None: clear_container_children(selection) dirty = True; print(f'[OK] Selection cleared in variant "{var_name}"') else: print(f'[INFO] No selection section in variant "{var_name}"') elif operation == "clear-order": settings = resolve_variant_settings() var_name = get_variant_name() order_el = find_first_element(settings, ["order"], SET_NS) if order_el is not None: clear_container_children(order_el) dirty = True; print(f'[OK] Order cleared in variant "{var_name}"') else: print(f'[INFO] No order section in variant "{var_name}"') elif operation == "clear-filter": settings = resolve_variant_settings() var_name = get_variant_name() filter_el = find_first_element(settings, ["filter"], SET_NS) if filter_el is not None: clear_container_children(filter_el) dirty = True; print(f'[OK] Filter cleared in variant "{var_name}"') else: print(f'[INFO] No filter section in variant "{var_name}"') elif operation == "clear-conditionalAppearance": settings = resolve_variant_settings() var_name = get_variant_name() ca_el = find_first_element(settings, ["conditionalAppearance"], SET_NS) if ca_el is not None: clear_container_children(ca_el) dirty = True; print(f'[OK] ConditionalAppearance cleared in variant "{var_name}"') else: print(f'[INFO] No conditionalAppearance section in variant "{var_name}"') elif operation == "modify-filter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_filter_shorthand(val) filter_el = find_first_element(settings, ["filter"], SET_NS) if filter_el is None: print(f'[WARN] No filter section in variant "{var_name}"') continue filter_item = find_element_by_child_value(filter_el, "item", "left", parsed["field"], SET_NS) if filter_item is None: print(f'[WARN] Filter for "{parsed["field"]}" not found in variant "{var_name}"') continue item_indent = get_child_indent(filter_item) set_or_create_child_element(filter_item, "comparisonType", SET_NS, parsed["op"], item_indent) if parsed.get("value") is not None: vt = parsed.get("valueType", "xs:string") set_or_create_child_element_with_attr(filter_item, "right", SET_NS, str(parsed["value"]), vt, item_indent) # Update use (only when explicitly set via @off / @on) if parsed.get("use") is False: set_or_create_child_element(filter_item, "use", SET_NS, "false", item_indent) elif parsed.get("use") is True: # @on: remove existing use=false if any for ch in filter_item: if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == SET_NS: if (ch.text or "").strip() == "false": remove_node_with_whitespace(ch) break if parsed.get("viewMode"): set_or_create_child_element(filter_item, "viewMode", SET_NS, parsed["viewMode"], item_indent) if parsed.get("userSettingID"): uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] set_or_create_child_element(filter_item, "userSettingID", SET_NS, uid, item_indent) dirty = True; print(f'[OK] Filter "{parsed["field"]}" modified in variant "{var_name}"') elif operation == "modify-dataParameter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: parsed = parse_data_param_shorthand(val) dp_el = find_first_element(settings, ["dataParameters"], SET_NS) if dp_el is None: print(f'[WARN] No dataParameters section in variant "{var_name}"') continue dp_item = find_element_by_child_value(dp_el, "item", "parameter", parsed["parameter"], COR_NS) if dp_item is None: print(f'[WARN] DataParameter "{parsed["parameter"]}" not found in variant "{var_name}"') continue item_indent = get_child_indent(dp_item) if parsed.get("value") is not None: existing_val = None for ch in dp_item: if isinstance(ch.tag, str) and local_name(ch) == "value" and etree.QName(ch.tag).namespace == COR_NS: existing_val = ch break if existing_val is not None: remove_node_with_whitespace(existing_val) val_lines = [] pv = parsed["value"] if isinstance(pv, dict) and pv.get("variant"): val_lines.append(f'{item_indent}') val_lines.append(f'{item_indent}\t{esc_xml(pv["variant"])}') val_lines.append(f"{item_indent}\t0001-01-01T00:00:00") val_lines.append(f"{item_indent}\t0001-01-01T00:00:00") val_lines.append(f"{item_indent}") elif is_empty_value(pv): val_lines.append(f'{item_indent}') elif re.match(r'^\d{4}-\d{2}-\d{2}T', str(pv)): val_lines.append(f'{item_indent}{esc_xml(str(pv))}') elif str(pv) in ("true", "false"): val_lines.append(f'{item_indent}{esc_xml(str(pv))}') else: val_lines.append(f'{item_indent}{esc_xml(str(pv))}') val_xml = "\n".join(val_lines) val_nodes = import_fragment(xml_doc, val_xml) for node in val_nodes: insert_before_element(dp_item, node, None, item_indent) # Update use (only when explicitly set via @off / @on) if parsed.get("use") is False: set_or_create_child_element(dp_item, "use", COR_NS, "false", item_indent) elif parsed.get("use") is True: # @on: remove existing use=false if any for ch in dp_item: if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == COR_NS: if (ch.text or "").strip() == "false": remove_node_with_whitespace(ch) break if parsed.get("viewMode"): set_or_create_child_element(dp_item, "viewMode", SET_NS, parsed["viewMode"], item_indent) if parsed.get("userSettingID"): uid = new_uuid() if parsed["userSettingID"] == "auto" else parsed["userSettingID"] set_or_create_child_element(dp_item, "userSettingID", SET_NS, uid, item_indent) dirty = True; print(f'[OK] DataParameter "{parsed["parameter"]}" modified in variant "{var_name}"') elif operation == "modify-field": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) for val in values: parsed = parse_field_shorthand(val) field_name = parsed["dataPath"] field_el = find_element_by_child_value(ds_node, "field", "dataPath", field_name, SCH_NS) if field_el is None: print(f'[WARN] Field "{field_name}" not found in dataset "{ds_name}"') continue existing = read_field_properties(field_el) merged = { "dataPath": existing["dataPath"], "field": existing["field"], "title": parsed["title"] if parsed.get("title") else existing["title"], "type": parsed["type"] if parsed.get("type") else existing["type"], "roles": parsed["roles"] if parsed.get("roles") else existing["roles"], "restrict": parsed["restrict"] if parsed.get("restrict") else existing["restrict"], # Preserve raw only when user did NOT override type via shorthand. "rawValueType": None if parsed.get("type") else existing.get("_rawValueType"), # Preserve raw multi-lang title; pass existing ru content for change detection. "_rawTitle": existing.get("_rawTitle"), "_existingTitleRu": existing.get("title"), # Pass-through unknown children (e.g. , , custom extensions). "_unknownChildren": existing.get("_unknownChildren"), } # Find next element sibling for position next_sib = None found = False for ch in ds_node: if found and isinstance(ch.tag, str): next_sib = ch break if ch is field_el: found = True child_indent = get_child_indent(ds_node) remove_node_with_whitespace(field_el) frag_xml = build_field_fragment(merged, child_indent) nodes = import_fragment(xml_doc, frag_xml) for node in nodes: insert_before_element(ds_node, node, next_sib, child_indent) dirty = True; print(f'[OK] Field "{field_name}" modified in dataset "{ds_name}"') elif operation == "set-field-role": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) for val in values: s = val.strip() flags = [] for m in re.finditer(r'@(\w+)', s): flags.append(m.group(1)) s = re.sub(r'\s*@\w+', '', s).strip() kv = [] for m in re.finditer(r'(\w+)=(\S+)', s): kv.append((m.group(1), m.group(2))) s = re.sub(r'\s*\w+=\S+', '', s).strip() data_path = s if not data_path: print(f'[WARN] set-field-role: empty dataPath in "{val}"') continue field_el = find_element_by_child_value(ds_node, "field", "dataPath", data_path, SCH_NS) if field_el is None: print(f'[WARN] Field "{data_path}" not found in dataset "{ds_name}"') continue field_indent = get_child_indent(field_el) # Remove existing — but first capture OuterXml of sub-children that the # rebuild won't re-emit (custom , , etc.). old_role = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) == "role" and etree.QName(ch.tag).namespace == SCH_NS), None) known_role_children = {"periodNumber", "periodType", "dimension", "ignoreNullsInGroups", "balance", "account", "accountTypeExpression", "additionType", "addition"} kv_keys = {k for k, _ in kv} preserved_role_children = [] if old_role is not None: for gc in old_role: if not isinstance(gc.tag, str): continue ln = local_name(gc) if ln in known_role_children: continue if ln in kv_keys: continue raw = etree.tostring(gc, encoding="unicode", with_tail=False) raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) preserved_role_children.append(raw) remove_node_with_whitespace(old_role) # Empty spec — remove only if not flags and not kv: dirty = True; print(f'[OK] Field "{data_path}" role cleared') continue # Build new lines = [f"{field_indent}"] for flag in flags: if flag == "period": lines.append(f"{field_indent}\t1") lines.append(f"{field_indent}\tMain") else: lines.append(f"{field_indent}\ttrue") for k, v in kv: lines.append(f"{field_indent}\t{esc_xml(v)}") for raw in preserved_role_children: lines.append(f"{field_indent}\t" + raw) lines.append(f"{field_indent}") frag_xml = "\n".join(lines) ref_node = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) in ("valueType", "inputParameters") and etree.QName(ch.tag).namespace == SCH_NS), None) for node in import_fragment(xml_doc, frag_xml): insert_before_element(field_el, node, ref_node, field_indent) parts = [] if flags: parts.append(" ".join(f"@{f}" for f in flags)) if kv: parts.append(" ".join(f"{k}={v}" for k, v in kv)) dirty = True; print(f'[OK] Field "{data_path}" role set: {" ".join(parts)}') elif operation == "remove-field": ds_node = resolve_data_set() ds_name = get_data_set_name(ds_node) for val in values: field_name = val.strip() field_el = find_element_by_child_value(ds_node, "field", "dataPath", field_name, SCH_NS) if field_el is None: print(f'[WARN] Field "{field_name}" not found in dataset "{ds_name}"') continue remove_node_with_whitespace(field_el) dirty = True; print(f'[OK] Field "{field_name}" removed from dataset "{ds_name}"') try: settings = resolve_variant_settings() var_name = get_variant_name() selection = find_first_element(settings, ["selection"], SET_NS) if selection is not None: sel_item = find_element_by_child_value(selection, "item", "field", field_name, SET_NS) if sel_item is not None: remove_node_with_whitespace(sel_item) dirty = True; print(f'[OK] Field "{field_name}" removed from selection of variant "{var_name}"') except SystemExit: pass elif operation == "remove-total": for val in values: data_path = val.strip() total_el = find_element_by_child_value(xml_doc, "totalField", "dataPath", data_path, SCH_NS) if total_el is None: print(f'[WARN] TotalField "{data_path}" not found') continue remove_node_with_whitespace(total_el) dirty = True; print(f'[OK] TotalField "{data_path}" removed') elif operation == "remove-calculated-field": for val in values: data_path = val.strip() calc_el = find_element_by_child_value(xml_doc, "calculatedField", "dataPath", data_path, SCH_NS) if calc_el is None: print(f'[WARN] CalculatedField "{data_path}" not found') continue remove_node_with_whitespace(calc_el) dirty = True; print(f'[OK] CalculatedField "{data_path}" removed') try: settings = resolve_variant_settings() var_name = get_variant_name() selection = find_first_element(settings, ["selection"], SET_NS) if selection is not None: sel_item = find_element_by_child_value(selection, "item", "field", data_path, SET_NS) if sel_item is not None: remove_node_with_whitespace(sel_item) dirty = True; print(f'[OK] Field "{data_path}" removed from selection of variant "{var_name}"') except SystemExit: pass elif operation == "remove-parameter": for val in values: param_name = val.strip() param_el = find_element_by_child_value(xml_doc, "parameter", "name", param_name, SCH_NS) if param_el is None: print(f'[WARN] Parameter "{param_name}" not found') continue remove_node_with_whitespace(param_el) dirty = True; print(f'[OK] Parameter "{param_name}" removed') elif operation == "remove-filter": settings = resolve_variant_settings() var_name = get_variant_name() for val in values: field_name = val.strip() filter_el = find_first_element(settings, ["filter"], SET_NS) if filter_el is None: print(f'[WARN] No filter section in variant "{var_name}"') continue filter_item = find_element_by_child_value(filter_el, "item", "left", field_name, SET_NS) if filter_item is None: print(f'[WARN] Filter for "{field_name}" not found in variant "{var_name}"') continue remove_node_with_whitespace(filter_item) dirty = True; print(f'[OK] Filter for "{field_name}" removed from variant "{var_name}"') elif operation == "add-drilldown": # String-based manipulation — templates use dcsat namespace with inline xmlns with open(resolved_path, "r", encoding="utf-8-sig") as f: raw_text = f.read() nl = "\r\n" dcsat_ns_decl = 'xmlns:dcsat="http://v8.1c.ru/8.1/data-composition-system/area-template"' # Find all outer