#!/usr/bin/env python3 # skd-compile v1.107 — Compile 1C DCS from JSON # 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 # ============================================================ # 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 def esc_xml(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') def fmt_dec(v): """Format decimal: 30.0 → '30', 16.625 → '16.625' (match PS1 output).""" return str(int(v)) if v == int(v) else str(v) 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 emit_mltext(lines, indent, tag, text, no_xsi_type=False): # Empty value → self-closing tag (matches platform output) if text is None or (isinstance(text, str) and text == ''): if no_xsi_type: lines.append(f"{indent}<{tag}/>") else: lines.append(f'{indent}<{tag} xsi:type="v8:LocalStringType"/>') return if not text: lines.append(f"{indent}<{tag}/>") return if no_xsi_type: lines.append(f"{indent}<{tag}>") else: lines.append(f'{indent}<{tag} xsi:type="v8:LocalStringType">') # Multi-lang: object form { ru: "...", en: "..." } -- one per language if isinstance(text, dict): for lang, content in text.items(): lines.append(f"{indent}\t") lines.append(f"{indent}\t\t{esc_xml(str(lang))}") lines.append(f"{indent}\t\t{esc_xml(str(content))}") lines.append(f"{indent}\t") else: lines.append(f"{indent}\t") lines.append(f"{indent}\t\tru") lines.append(f"{indent}\t\t{esc_xml(str(text))}") lines.append(f"{indent}\t") lines.append(f"{indent}") def new_uuid(): return str(uuid.uuid4()) def write_utf8_bom(path, content): with open(path, 'w', encoding='utf-8-sig', newline='') as f: f.write(content) # --- Type system --- TYPE_SYNONYMS = { # Russian names (lowercase) "\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", "\u0432\u0440\u0435\u043c\u044f": "time", "\u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439\u043f\u0435\u0440\u0438\u043e\u0434": "StandardPeriod", # English canonical (lowercase) "bool": "boolean", "str": "string", "int": "decimal", "integer": "decimal", "number": "decimal", "num": "decimal", # Reference synonyms (Russian, lowercase) "\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", } def resolve_type_str(type_str): if not type_str: return type_str # Check for parameterized types: число(15,2), строка(100), etc. 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 # Check for reference types: СправочникСсылка.Организации -> CatalogRef.Организации if '.' in type_str: dot_idx = type_str.index('.') prefix = type_str[:dot_idx] suffix = type_str[dot_idx:] # includes the dot resolved = TYPE_SYNONYMS.get(prefix.lower()) if resolved: return f"{resolved}{suffix}" return type_str # Simple name lookup (case-insensitive) resolved = TYPE_SYNONYMS.get(type_str.lower()) if resolved: return resolved return type_str def emit_value_type(lines, type_spec, indent): if not type_spec: return # Multi-type: iterate and emit each type with its qualifiers if isinstance(type_spec, list): for t in type_spec: emit_single_value_type(lines, str(t), indent) return emit_single_value_type(lines, str(type_spec), indent) def emit_single_value_type(lines, type_str, indent): if not type_str: return # Resolve synonyms first type_str = resolve_type_str(type_str) # boolean if type_str == 'boolean': lines.append(f'{indent}xs:boolean') return # 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 # decimal forms (defaults — bare decimal = money 10,2; decimal(N) = integer N,0): # decimal → 10,2,Any # decimal(N) → N,0,Any # decimal(N,nonneg) → N,0,Nonnegative # decimal(N,M) → N,M,Any # decimal(N,M,nonneg) → N,M,Nonnegative 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 # date / dateTime / time — all use xs:dateTime, differ only in DateFractions m = re.match(r'^(date|dateTime|time)$', type_str) if m: fractions_map = {'date': 'Date', 'dateTime': 'DateTime', 'time': 'Time'} fractions = fractions_map[type_str] lines.append(f'{indent}xs:dateTime') lines.append(f'{indent}') lines.append(f'{indent}\t{fractions}') lines.append(f'{indent}') return # StandardPeriod if type_str == 'StandardPeriod': lines.append(f'{indent}v8:StandardPeriod') return # Reference types: CatalogRef.XXX, DocumentRef.XXX, EnumRef.XXX, etc. if re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef)\.', type_str): lines.append(f'{indent}d5p1:{esc_xml(type_str)}') return # TypeSet (композитный тип-набор): голое имя без точки. if re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef|InformationRegisterRef|AnyRef)$', type_str): lines.append(f'{indent}d5p1:{esc_xml(type_str)}') return # Fallback -- assume dot-qualified types are also config references if '.' in type_str: lines.append(f'{indent}d5p1:{esc_xml(type_str)}') return lines.append(f'{indent}{esc_xml(type_str)}') # --- Field shorthand parser --- def parse_field_shorthand(s): result = { 'dataPath': '', 'field': '', 'title': '', 'type': '', 'roles': [], 'restrict': [], 'appearance': {}, 'roleExtras': {}, } # Extract @roles role_matches = re.findall(r'@(\w+)', s) for m in role_matches: result['roles'].append(m) s = re.sub(r'\s*@\w+', '', s) # Extract #restrictions restrict_matches = re.findall(r'#(\w+)', s) for m in restrict_matches: result['restrict'].append(m) s = re.sub(r'\s*#\w+', '', s) # Extract role kv=value (e.g. balanceGroupName=Сумма) for m in re.finditer(r'(\w+)=(\S+)', s): result['roleExtras'][m.group(1)] = m.group(2) s = re.sub(r'\s*\w+=\S+', '', s) # Split name: type 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 # Universal role spec parser: string / list / dict / None # Returns {'tokens': [...], 'extras': {...}} def parse_role_spec(spec): tokens = [] extras = {} if spec is None: pass elif isinstance(spec, str): if ' ' not in spec and '=' not in spec: tokens.append(spec) else: s = spec.strip() for m in re.finditer(r'@(\w+)', s): tokens.append(m.group(1)) s = re.sub(r'\s*@\w+', '', s).strip() for m in re.finditer(r'(\w+)=(\S+)', s): extras[m.group(1)] = m.group(2) elif isinstance(spec, list): for t in spec: tokens.append(str(t)) elif isinstance(spec, dict): for k, v in spec.items(): if isinstance(v, bool): if v: tokens.append(k) elif isinstance(v, (int, float, str)): extras[k] = str(v) # Deprecated alias: balanceGroup → balanceGroupName if 'balanceGroup' in extras and 'balanceGroupName' not in extras: extras['balanceGroupName'] = extras.pop('balanceGroup') return {'tokens': tokens, 'extras': extras} # --- Total field shorthand parser --- def parse_total_shorthand(s): parts = s.split(':', 1) data_path = parts[0].strip() func_part = parts[1].strip() # Known DCS aggregate functions (ru + en) _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: # Identity or custom expression — use as-is return {'dataPath': data_path, 'expression': func_part} # --- Parameter shorthand parser --- def split_value_list_csv(s): """Split on top-level commas (respecting single/double quotes), strip quotes, drop empties. No ':' handling — values may contain colons (dateTime).""" result = [] if s is None: return result 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)) for raw in items: t = raw.strip() if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')): t = t[1:-1] if t != "": result.append(t) return result def parse_param_shorthand(s): result = {'name': '', 'type': '', 'value': None, 'autoDates': False, 'title': None} # Extract @autoDates flag if '@autoDates' in s: result['autoDates'] = True s = re.sub(r'\s*@autoDates', '', s) # Extract @valueList flag if '@valueList' in s: result['valueListAllowed'] = True s = re.sub(r'\s*@valueList', '', s) # Extract @hidden flag if '@hidden' in s: result['hidden'] = True s = re.sub(r'\s*@hidden', '', 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() # Split "Name: Type = Value" — RHS may be empty (`= ` / `=`) → treated as empty value 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(4): rhs = m.group(4).strip() items = split_value_list_csv(rhs) if len(items) >= 2: # Multi-value default → list; valueListAllowed implied result['value'] = items result['valueListAllowed'] = True elif len(items) == 1: result['value'] = items[0] else: result['value'] = rhs else: result['name'] = s.strip() return result # --- Calculated field shorthand parser --- 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 = 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() else: lhs = s rhs = '' title = '' m = re.search(r'\[([^\]]+)\]', lhs) if m: title = m.group(1) lhs = re.sub(r'\s*\[[^\]]+\]', '', lhs) lhs = lhs.strip() type_str = '' data_path = lhs 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, } # --- DataParameter shorthand parser --- 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", ] def parse_data_param_shorthand(s): result = {'parameter': '', 'value': None, 'use': True, 'userSettingID': None, 'viewMode': None} # Extract @flags if '@user' in s: result['userSettingID'] = 'auto' s = re.sub(r'\s*@user', '', s) if '@off' in s: result['use'] = False s = re.sub(r'\s*@off', '', s) if '@quickAccess' in s: result['viewMode'] = 'QuickAccess' s = re.sub(r'\s*@quickAccess', '', s) if '@normal' in s: result['viewMode'] = 'Normal' s = re.sub(r'\s*@normal', '', s) s = s.strip() # Split "Name = Value" m = re.match(r'^([^=]+)=\s*(.+)$', s) if m: result['parameter'] = m.group(1).strip() val_str = m.group(2).strip() if val_str in PERIOD_VARIANTS: result['value'] = {'variant': val_str} elif re.match(r'^\d{4}-\d{2}-\d{2}T', val_str): result['value'] = val_str elif val_str == 'true' or val_str == 'false': result['value'] = val_str == 'true' else: result['value'] = val_str else: result['parameter'] = s return result # --- Filter item shorthand parser --- def parse_filter_shorthand(s): result = {'field': '', 'op': 'Equal', 'value': None, 'use': True, 'userSettingID': None, 'viewMode': None, 'presentation': None} # Extract @flags if '@user' in s: result['userSettingID'] = 'auto' s = re.sub(r'\s*@user', '', s) if '@off' in s: result['use'] = False s = re.sub(r'\s*@off', '', s) if '@quickAccess' in s: result['viewMode'] = 'QuickAccess' s = re.sub(r'\s*@quickAccess', '', s) if '@normal' in s: result['viewMode'] = 'Normal' s = re.sub(r'\s*@normal', '', s) if '@inaccessible' in s: result['viewMode'] = 'Inaccessible' s = re.sub(r'\s*@inaccessible', '', s) s = s.strip() # Operators sorted longest first op_patterns = [ '<>', '>=', '<=', '=', '>', '<', 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).strip() if m.group(3) else '' # Parse value (skip "_" which means empty/placeholder) if val_part and val_part != '_': if val_part == 'true' or val_part == 'false': result['value'] = val_part == 'true' 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' result['op'] = op_raw else: result['field'] = s return result # --- Comparison type mapper --- COMPARISON_TYPES = { '=': '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', } # --- Output parameter type detection --- 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", "\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", "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0418\u0442\u043e\u0433\u043e\u0432": "dcscor:DataCompositionTotalPlacement", "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0413\u0440\u0443\u043f\u043f\u0438\u0440\u043e\u0432\u043a\u0438": "dcsset:DataCompositionFieldGroupPlacement", "\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0420\u0435\u0441\u0443\u0440\u0441\u043e\u0432": "dcsset:DataCompositionResourcesPlacement", "\u0422\u0438\u043f\u041c\u0430\u043a\u0435\u0442\u0430": "dcsset:DataCompositionGroupTemplateType", } # ===== Emit sections ===== def emit_data_sources(lines, data_sources): for ds in data_sources: lines.append('\t') lines.append(f'\t\t{esc_xml(ds["name"])}') lines.append(f'\t\t{esc_xml(ds["type"])}') lines.append('\t') # === Fields === def emit_input_parameters(lines, ip, indent): if not ip: return items = list(ip) if len(items) == 0: return lines.append(f'{indent}') for item in items: lines.append(f'{indent}\t') if 'use' in item and item['use'] is False: lines.append(f'{indent}\t\tfalse') lines.append(f'{indent}\t\t{esc_xml(str(item.get("parameter", "")))}') if 'choiceParameters' in item: cp_items = list(item['choiceParameters']) if item['choiceParameters'] else [] if len(cp_items) == 0: lines.append(f'{indent}\t\t') else: lines.append(f'{indent}\t\t') for cp in cp_items: lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t\t\t{esc_xml(str(cp.get("name", "")))}') for v in cp.get('values', []) or []: if isinstance(v, bool): vs = 'true' if v else 'false' lines.append(f'{indent}\t\t\t\t{vs}') elif isinstance(v, (int, float)): lines.append(f'{indent}\t\t\t\t{v}') else: lines.append(f'{indent}\t\t\t\t{esc_xml(str(v))}') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t') elif 'choiceParameterLinks' in item: cpl_items = list(item['choiceParameterLinks']) if item['choiceParameterLinks'] else [] if len(cpl_items) == 0: lines.append(f'{indent}\t\t') else: lines.append(f'{indent}\t\t') for cpl in cpl_items: lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t\t\t{esc_xml(str(cpl.get("name", "")))}') lines.append(f'{indent}\t\t\t\t{esc_xml(str(cpl.get("value", "")))}') mode = cpl.get('mode') or 'Auto' lines.append(f'{indent}\t\t\t\t{mode}') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t') elif 'value' in item: val = item['value'] # Явный кастомный type из decompile: {uri, name} vt_src = item.get('valueType') custom_uri = None; custom_name = None if isinstance(vt_src, dict): custom_uri = vt_src.get('uri') custom_name = vt_src.get('name') if custom_uri and custom_name: lines.append(f'{indent}\t\t{esc_xml(str(val))}') elif isinstance(val, bool): vstr = 'true' if val else 'false' lines.append(f'{indent}\t\t{vstr}') elif isinstance(val, (int, float)): lines.append(f'{indent}\t\t{val}') elif isinstance(val, dict): # Multilang dict {ru, en, ...} → LocalStringType emit_mltext(lines, f'{indent}\t\t', 'dcscor:value', val) else: lines.append(f'{indent}\t\t{esc_xml(str(val))}') lines.append(f'{indent}\t') lines.append(f'{indent}') def emit_field(lines, field_def, indent): if isinstance(field_def, str): f = parse_field_shorthand(field_def) else: f = { 'dataPath': str(field_def.get('dataPath', '')) or str(field_def.get('field', '')), 'field': str(field_def.get('field', '')) or str(field_def.get('dataPath', '')), 'title': field_def.get('title') if field_def.get('title') else '', 'type': ( [resolve_type_str(str(t)) for t in field_def['type']] if isinstance(field_def['type'], list) else resolve_type_str(str(field_def['type'])) ) if field_def.get('type') else '', 'roles': [], 'restrict': [], 'appearance': {}, 'roleExtras': {}, } # Parse role (string shorthand / list / dict — единый формат с /skd-edit set-field-role) if field_def.get('role') is not None: parsed = parse_role_spec(field_def['role']) f['roles'] = parsed['tokens'] f['roleExtras'] = parsed['extras'] # Parse restrictions if field_def.get('restrict'): f['restrict'] = list(field_def['restrict']) # Parse appearance (сохраняем значение как есть — может быть string или multilang dict) if field_def.get('appearance'): for k, v in field_def['appearance'].items(): f['appearance'][k] = v if field_def.get('presentationExpression'): f['presentationExpression'] = str(field_def['presentationExpression']) # attrRestrict if field_def.get('attrRestrict'): f['attrRestrict'] = list(field_def['attrRestrict']) # availableValues — array of {value, presentation} if field_def.get('availableValues'): f['availableValues'] = field_def['availableValues'] # orderExpression — {expression, orderType, autoOrder} if field_def.get('orderExpression'): f['orderExpression'] = field_def['orderExpression'] # inputParameters — массив элементов, типизированных по форме value if field_def.get('inputParameters') is not None: f['inputParameters'] = field_def['inputParameters'] # folder: true → DataSetFieldFolder if field_def.get('folder') is True: f['folder'] = True # DataSetFieldFolder — только dataPath + title if f.get('folder'): lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(f["dataPath"])}') if f.get('title'): emit_mltext(lines, f'{indent}\t', 'title', f['title']) lines.append(f'{indent}') return lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(f["dataPath"])}') lines.append(f'{indent}\t{esc_xml(f["field"])}') # Title if f.get('title'): emit_mltext(lines, f'{indent}\t', 'title', f['title']) # UseRestriction restrict_map = { 'noField': 'field', 'noFilter': 'condition', 'noCondition': 'condition', 'noGroup': 'group', 'noOrder': 'order', } if f.get('restrict') and len(f['restrict']) > 0: lines.append(f'{indent}\t') for r in f['restrict']: xml_name = restrict_map.get(str(r)) if xml_name: lines.append(f'{indent}\t\t<{xml_name}>true') lines.append(f'{indent}\t') # AttributeUseRestriction if f.get('attrRestrict') and len(f['attrRestrict']) > 0: lines.append(f'{indent}\t') for r in f['attrRestrict']: xml_name = restrict_map.get(str(r)) if xml_name: lines.append(f'{indent}\t\t<{xml_name}>true') lines.append(f'{indent}\t') # Role extras = f.get('roleExtras') or {} has_extras = len(extras) > 0 if (f.get('roles') and len(f['roles']) > 0) or has_extras: lines.append(f'{indent}\t') for role in f.get('roles', []): if role == 'period': # @period — sugar для periodNumber=1 + periodType=Main; extras могут переопределить. if 'periodNumber' not in extras: lines.append(f'{indent}\t\t1') if 'periodType' not in extras: lines.append(f'{indent}\t\tMain') else: lines.append(f'{indent}\t\ttrue') for k, v in extras.items(): lines.append(f'{indent}\t\t{esc_xml(str(v))}') lines.append(f'{indent}\t') # OrderExpression — после role, до valueType if f.get('orderExpression'): oe_raw = f['orderExpression'] oe_list = oe_raw if isinstance(oe_raw, list) else [oe_raw] for oe in oe_list: expr = str(oe.get('expression', '')) o_type = str(oe.get('orderType', 'Asc')) auto = oe.get('autoOrder', False) auto_str = 'true' if auto else 'false' lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(expr)}') lines.append(f'{indent}\t\t{o_type}') lines.append(f'{indent}\t\t{auto_str}') lines.append(f'{indent}\t') # ValueType if f.get('type'): lines.append(f'{indent}\t') emit_value_type(lines, f['type'], f'{indent}\t\t') lines.append(f'{indent}\t') # AvailableValues — list of allowed values with optional multilang presentation if f.get('availableValues'): for av in f['availableValues']: lines.append(f'{indent}\t') av_val = av.get('value') av_type = str(av.get('valueType', '')) if av.get('valueType') else '' if not av_type: if isinstance(av_val, bool): av_type = 'xs:boolean' elif isinstance(av_val, (int, float)): av_type = 'xs:decimal' elif re.match(r'^\d{4}-\d{2}-\d{2}T', str(av_val)): av_type = 'xs:dateTime' else: av_type = 'xs:string' av_str = str(av_val).lower() if isinstance(av_val, bool) else esc_xml(str(av_val)) lines.append(f'{indent}\t\t{av_str}') if av.get('presentation'): emit_mltext(lines, f'{indent}\t\t', 'presentation', av['presentation']) lines.append(f'{indent}\t') # Appearance if f.get('appearance') and len(f['appearance']) > 0: lines.append(f'{indent}\t') for key, val in f['appearance'].items(): # \u0413\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u041f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0433\u043e xsi:type, \u043d\u0435 \u0441\u0442\u0440\u043e\u043a\u0430 if key == '\u0413\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u041f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435' and not isinstance(val, dict): lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t{esc_xml(key)}') lines.append(f'{indent}\t\t\t{esc_xml(str(val))}') lines.append(f'{indent}\t\t') else: emit_appearance_value(lines, key, val, f'{indent}\t\t') lines.append(f'{indent}\t') # PresentationExpression if f.get('presentationExpression'): lines.append(f'{indent}\t{esc_xml(f["presentationExpression"])}') # InputParameters — в конце field if f.get('inputParameters'): emit_input_parameters(lines, f['inputParameters'], f'{indent}\t') lines.append(f'{indent}') # === DataSets === def emit_data_set(lines, ds, indent, default_source, tag_name='dataSet'): # Determine type if ds.get('items'): ds_type = 'DataSetUnion' elif ds.get('objectName'): ds_type = 'DataSetObject' else: ds_type = 'DataSetQuery' lines.append(f'{indent}<{tag_name} xsi:type="{ds_type}">') lines.append(f'{indent}\t{esc_xml(str(ds.get("name", "")))}') # Fields if ds.get('fields'): for f in ds['fields']: emit_field(lines, f, f'{indent}\t') # DataSource (not for Union) if ds_type != 'DataSetUnion': src = str(ds['source']) if ds.get('source') else default_source lines.append(f'{indent}\t{esc_xml(src)}') # Type-specific content if ds_type == 'DataSetQuery': query_text = resolve_query_value(str(ds.get("query", "")), query_base_dir) lines.append(f'{indent}\t{esc_xml(query_text)}') if ds.get('autoFillFields') is False: lines.append(f'{indent}\tfalse') elif ds_type == 'DataSetObject': lines.append(f'{indent}\t{esc_xml(str(ds["objectName"]))}') elif ds_type == 'DataSetUnion': for item in ds['items']: # Union inner items are wrapped as emit_data_set(lines, item, f'{indent}\t', default_source, tag_name='item') lines.append(f'{indent}') def emit_data_sets(lines, defn, default_source): for ds in defn['dataSets']: emit_data_set(lines, ds, '\t', default_source) # === DataSetLinks === def emit_data_set_links(lines, defn): if not defn.get('dataSetLinks'): return for link in defn['dataSetLinks']: lines.append('\t') src_ds = str(link.get('source') or link.get('sourceDataSet') or '') dst_ds = str(link.get('dest') or link.get('destinationDataSet') or '') src_ex = str(link.get('sourceExpr') or link.get('sourceExpression') or '') dst_ex = str(link.get('destExpr') or link.get('destinationExpression') or '') lines.append(f'\t\t{esc_xml(src_ds)}') lines.append(f'\t\t{esc_xml(dst_ds)}') lines.append(f'\t\t{esc_xml(src_ex)}') lines.append(f'\t\t{esc_xml(dst_ex)}') if link.get('parameter'): lines.append(f'\t\t{esc_xml(str(link["parameter"]))}') if link.get('parameterListAllowed'): lines.append('\t\ttrue') if link.get('startExpression') is not None: lines.append(f'\t\t{esc_xml(str(link["startExpression"]))}') if link.get('linkConditionExpression') is not None: lines.append(f'\t\t{esc_xml(str(link["linkConditionExpression"]))}') lines.append('\t') # === CalculatedFields === def emit_calc_fields(lines, defn): if not defn.get('calculatedFields'): return restrict_map = { 'noField': 'field', 'noFilter': 'condition', 'noCondition': 'condition', 'noGroup': 'group', 'noOrder': 'order', } for cf in defn['calculatedFields']: # Collect dataPath/expression/title/type/restrict/appearance from either # shorthand string or object form. Object form accepts dataPath/field/name # as synonyms; useRestriction/restrict accepts object, array, or flag string. title = '' type_str = '' restrict_tokens = [] restrict_obj = None appearance = None if isinstance(cf, str): parsed = parse_calc_shorthand(cf) data_path = parsed['dataPath'] expression = parsed['expression'] title = parsed.get('title', '') or '' type_str = parsed.get('type', '') or '' restrict_tokens = list(parsed.get('restrict') or []) else: data_path = str(cf.get('dataPath') or cf.get('field') or cf.get('name') or '') expression = str(cf.get('expression', '')) if cf.get('title'): title = cf['title'] if cf.get('type'): type_str = resolve_type_str(str(cf['type'])) restrict_val = cf.get('restrict') if cf.get('restrict') is not None else cf.get('useRestriction') if restrict_val: if isinstance(restrict_val, dict): restrict_obj = restrict_val elif isinstance(restrict_val, str): # Flag-string form: "#noField #noFilter #noGroup #noOrder" (or without `#`) for tok in restrict_val.split(): t = tok.strip().lstrip('#') if t: restrict_tokens.append(t) else: # Array form: ["noField", "noFilter", ...] for r in restrict_val: restrict_tokens.append(str(r)) appearance = cf.get('appearance') lines.append('\t') lines.append(f'\t\t{esc_xml(data_path)}') lines.append(f'\t\t{esc_xml(expression)}') if title: emit_mltext(lines, '\t\t', 'title', title) if type_str: lines.append('\t\t') emit_value_type(lines, type_str, '\t\t\t') lines.append('\t\t') if restrict_obj or restrict_tokens: lines.append('\t\t') if restrict_obj: for xml_name, flag in restrict_obj.items(): if flag: lines.append(f'\t\t\t<{esc_xml(str(xml_name))}>true') else: for r in restrict_tokens: xml_name = restrict_map.get(str(r)) if xml_name: lines.append(f'\t\t\t<{xml_name}>true') lines.append('\t\t') if appearance: lines.append('\t\t') for k, v in appearance.items(): if k == 'ГоризонтальноеПоложение' and not isinstance(v, dict): lines.append('\t\t\t') lines.append(f'\t\t\t\t{esc_xml(k)}') lines.append(f'\t\t\t\t{esc_xml(str(v))}') lines.append('\t\t\t') else: emit_appearance_value(lines, k, v, '\t\t\t') lines.append('\t\t') lines.append('\t') # === TotalFields === def emit_total_fields(lines, defn): if not defn.get('totalFields'): return for tf in defn['totalFields']: if isinstance(tf, str): parsed = parse_total_shorthand(tf) groups = None else: parsed = { 'dataPath': str(tf.get('dataPath', '')), 'expression': str(tf.get('expression', '')), } groups = tf.get('group') lines.append('\t') lines.append(f'\t\t{esc_xml(parsed["dataPath"])}') lines.append(f'\t\t{esc_xml(parsed["expression"])}') if groups: if isinstance(groups, list): for g in groups: lines.append(f'\t\t{esc_xml(str(g))}') else: lines.append(f'\t\t{esc_xml(str(groups))}') lines.append('\t') # === Parameters === def is_empty_value(v): 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 emit_empty_value(lines, type_str, indent, tag_prefix='', value_list_allowed=False): if value_list_allowed: return t = type_str or '' # Нормализация: убираем префикс xs: (валидный для valueType из decompile/DSL) t_bare = t[3:] if t.startswith('xs:') else t pf = tag_prefix if t == '': lines.append(f'{indent}<{pf}value xsi:nil="true"/>') elif t == 'StandardPeriod': lines.append(f'{indent}<{pf}value 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_bare): lines.append(f'{indent}<{pf}value xsi:type="xs:string"/>') elif re.match(r'^(date|time)', t_bare): lines.append(f'{indent}<{pf}value xsi:type="xs:dateTime">0001-01-01T00:00:00') elif re.match(r'^decimal', t_bare): lines.append(f'{indent}<{pf}value xsi:type="xs:decimal">0') elif t_bare == 'boolean': lines.append(f'{indent}<{pf}value xsi:type="xs:boolean">false') else: # Ref types or unknown — safe nil lines.append(f'{indent}<{pf}value xsi:nil="true"/>') def emit_param_value(lines, type_str, val, indent, value_list_allowed=False): if is_empty_value(val): emit_empty_value(lines, type_str, indent, '', value_list_allowed) return # val может быть строкой (variant only) или dict {variant, startDate?, endDate?}. variant_str = None sd_str = None ed_str = None if isinstance(val, dict): variant_str = str(val.get('variant')) if val.get('variant') is not None else None sd_str = str(val['startDate']) if 'startDate' in val else None ed_str = str(val['endDate']) if 'endDate' in val else None val_str = variant_str if variant_str else str(val) if type_str == 'StandardPeriod': # Platform-pattern: startDate/endDate ТОЛЬКО для variant=Custom. lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(val_str)}') if val_str == 'Custom': sd_out = sd_str if sd_str else '0001-01-01T00:00:00' ed_out = ed_str if ed_str else '0001-01-01T00:00:00' lines.append(f'{indent}\t{esc_xml(sd_out)}') lines.append(f'{indent}\t{esc_xml(ed_out)}') lines.append(f'{indent}') elif type_str and re.match(r'^date', type_str): lines.append(f'{indent}{esc_xml(val_str)}') elif type_str == 'boolean': lines.append(f'{indent}{esc_xml(val_str)}') elif type_str and re.match(r'^decimal', type_str): lines.append(f'{indent}{esc_xml(val_str)}') elif type_str and re.match(r'^string', type_str): lines.append(f'{indent}{esc_xml(val_str)}') elif type_str and re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', type_str): lines.append(f'{indent}{esc_xml(val_str)}') else: # Guess from value if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str): lines.append(f'{indent}{esc_xml(val_str)}') elif val_str == 'true' or val_str == 'false': lines.append(f'{indent}{esc_xml(val_str)}') elif re.match(r'^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', val_str): lines.append(f'{indent}{esc_xml(val_str)}') else: lines.append(f'{indent}{esc_xml(val_str)}') def emit_single_param(lines, p, parsed): lines.append('\t') lines.append(f'\t\t{esc_xml(parsed["name"])}') # Title (from parsed first, then from object form; accept `presentation` as # a synonym — 1C UI labels a parameter's caption "Представление"). title = '' if parsed.get('title'): title = parsed['title'] elif p is not None and not isinstance(p, str) and p.get('title'): title = p['title'] elif p is not None and not isinstance(p, str) and p.get('presentation'): title = p['presentation'] if title: emit_mltext(lines, '\t\t', 'title', title) # ValueType if parsed.get('type'): lines.append('\t\t') emit_value_type(lines, parsed['type'], '\t\t\t') lines.append('\t\t') # Value — for valueListAllowed params Designer omits when empty vla = bool(parsed.get('valueListAllowed')) p_type = parsed.get('type', '') if isinstance(p_type, (list, tuple)): # Composite type — Designer writes xsi:nil for any empty composite; # non-empty composite values are uncommon and would need per-type tagging. if is_empty_value(parsed.get('value')): if not vla: lines.append('\t\t') elif parsed.get('nilValue') is True: # Принудительный xsi:nil даже когда тип известен (для bit-perfect round-trip). if not vla: lines.append('\t\t') elif isinstance(parsed.get('value'), list): # Multi-value (массив значений по умолчанию для valueListAllowed-параметра). for v in parsed['value']: emit_param_value(lines, p_type, v, '\t\t', False) else: emit_param_value(lines, p_type, parsed.get('value'), '\t\t', vla) # Hidden implies useRestriction=true + availableAsField=false if parsed.get('hidden') is True: parsed['availableAsField'] = False parsed['useRestriction'] = True # UseRestriction — платформа всегда эмитит этот тег у параметра (true/false) ur_emit = ( parsed.get('useRestriction') is True or (p is not None and not isinstance(p, str) and p.get('useRestriction') is True) ) lines.append(f'\t\t{"true" if ur_emit else "false"}') # Expression if parsed.get('expression'): lines.append(f'\t\t{esc_xml(parsed["expression"])}') if parsed.get('hidden'): parsed['availableAsField'] = False # AvailableAsField if parsed.get('availableAsField') is False: lines.append('\t\tfalse') # ValueListAllowed if parsed.get('valueListAllowed'): lines.append('\t\ttrue') # AvailableValues if p is not None and not isinstance(p, str) and p.get('availableValues'): for av in p['availableValues']: lines.append('\t\t') if is_empty_value(av.get('value')): emit_empty_value(lines, parsed.get('type', ''), '\t\t\t', '', False) else: av_v = av['value'] if isinstance(av_v, bool): lines.append(f'\t\t\t{str(av_v).lower()}') elif isinstance(av_v, (int, float)): lines.append(f'\t\t\t{av_v}') else: av_val = str(av_v) av_type = 'xs:string' if re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', av_val): av_type = 'dcscor:DesignTimeValue' lines.append(f'\t\t\t{esc_xml(av_val)}') # `title` accepted as synonym of `presentation` — both map to the same UI label. av_pres = av.get('presentation') or av.get('title') or '' if av_pres: emit_mltext(lines, '\t\t\t', 'presentation', av_pres) lines.append('\t\t') # DenyIncompleteValues deny = parsed.get('denyIncompleteValues') is True or ( p is not None and not isinstance(p, str) and p.get('denyIncompleteValues') is True) if deny: lines.append('\t\ttrue') # Use use_val = None if p is not None and not isinstance(p, str) and p.get('use'): use_val = str(p['use']) elif parsed.get('use'): use_val = str(parsed['use']) if use_val: lines.append(f'\t\t{esc_xml(use_val)}') # InputParameters на параметре (ФорматРедактирования и т.п.) if p is not None and not isinstance(p, str) and p.get('inputParameters'): emit_input_parameters(lines, p['inputParameters'], '\t\t') lines.append('\t') _all_params = [] def emit_parameters(lines, defn): global _all_params _all_params = [] if not defn.get('parameters'): return for p in defn['parameters']: if isinstance(p, str): parsed = parse_param_shorthand(p) else: # Composite type: ["string(10,fix)", "CatalogRef.X"] → list of resolved # strings; emit_value_type handles lists, empty value falls through to nil. raw_type = p.get('type') if isinstance(raw_type, (list, tuple)): resolved_type = [resolve_type_str(str(t)) for t in raw_type] elif raw_type: resolved_type = resolve_type_str(str(raw_type)) else: resolved_type = '' parsed = { 'name': str(p.get('name', '')), 'type': resolved_type, 'value': p.get('value'), 'autoDates': False, } if p.get('expression'): parsed['expression'] = str(p['expression']) if p.get('availableAsField') is False: parsed['availableAsField'] = False if p.get('valueListAllowed') is True: parsed['valueListAllowed'] = True if p.get('hidden') is True: parsed['hidden'] = True if p.get('autoDates') is True: parsed['autoDates'] = True if p.get('nilValue') is True: parsed['nilValue'] = True # @autoDates implies use=Always + denyIncompleteValues=true by default # (derived &НачалоПериода/&КонецПериода need a populated period). # Explicit values in object form override these defaults. if parsed.get('autoDates'): is_obj = p is not None and not isinstance(p, str) if not (is_obj and p.get('use') is not None): parsed['use'] = 'Always' if not (is_obj and p.get('denyIncompleteValues') is not None): parsed['denyIncompleteValues'] = True emit_single_param(lines, p, parsed) # Track parameter for auto dataParameters _all_params.append({ 'name': parsed['name'], 'hidden': bool(parsed.get('hidden')), 'type': parsed.get('type', ''), 'value': parsed.get('value'), }) # @autoDates: auto-generate НачалоПериода and КонецПериода (canonical БСП pattern). # type=dateTime + DateFractions=DateTime — иначе КонецПериода обрезается до 00:00:00 # и запрос `Дата МЕЖДУ &НачалоПериода И &КонецПериода` теряет данные за последний день. if parsed.get('autoDates'): param_name = parsed['name'] begin_parsed = { 'name': '\u041d\u0430\u0447\u0430\u043b\u043e\u041f\u0435\u0440\u0438\u043e\u0434\u0430', 'title': '\u041d\u0430\u0447\u0430\u043b\u043e \u043f\u0435\u0440\u0438\u043e\u0434\u0430', 'type': 'dateTime', 'value': '0001-01-01T00:00:00', 'useRestriction': True, 'expression': f'&{param_name}.\u0414\u0430\u0442\u0430\u041d\u0430\u0447\u0430\u043b\u0430', } emit_single_param(lines, None, begin_parsed) end_parsed = { 'name': '\u041a\u043e\u043d\u0435\u0446\u041f\u0435\u0440\u0438\u043e\u0434\u0430', 'title': '\u041a\u043e\u043d\u0435\u0446 \u043f\u0435\u0440\u0438\u043e\u0434\u0430', 'type': 'dateTime', 'value': '0001-01-01T00:00:00', 'useRestriction': True, 'expression': f'&{param_name}.\u0414\u0430\u0442\u0430\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u044f', } emit_single_param(lines, None, end_parsed) # === AreaTemplate DSL === AREA_STYLE_PRESETS = { 'none': { 'font': None, 'fontSize': None, 'bold': False, 'italic': False, 'hAlign': None, 'vAlign': None, 'wrap': False, 'bgColor': None, 'textColor': None, 'borderColor': None, 'borders': False, }, 'data': { 'font': 'Arial', 'fontSize': 10, 'bold': False, 'italic': False, 'hAlign': None, 'vAlign': None, 'wrap': False, 'bgColor': 'style:ReportGroup1BackColor', 'textColor': None, 'borderColor': 'style:ReportLineColor', 'borders': True, }, 'header': { 'font': 'Arial', 'fontSize': 10, 'bold': False, 'italic': False, 'hAlign': 'Center', 'vAlign': None, 'wrap': True, 'bgColor': 'style:ReportHeaderBackColor', 'textColor': None, 'borderColor': 'style:ReportLineColor', 'borders': True, }, 'subheader': { 'font': 'Arial', 'fontSize': 10, 'bold': False, 'italic': False, 'hAlign': 'Center', 'vAlign': None, 'wrap': True, 'bgColor': None, 'textColor': None, 'borderColor': 'style:ReportLineColor', 'borders': True, }, 'total': { 'font': 'Arial', 'fontSize': 10, 'bold': False, 'italic': False, 'hAlign': None, 'vAlign': None, 'wrap': False, 'bgColor': None, 'textColor': None, 'borderColor': 'style:ReportLineColor', 'borders': True, }, } def load_user_styles(base_dir, output_path=None): # Search order (first found wins): 1) definition dir, 2) cwd, 3) scan-up from OutputPath for presets/skills/skd/ search_paths = [ os.path.join(base_dir, 'skd-styles.json'), os.path.join(os.getcwd(), 'skd-styles.json'), ] if output_path: scan_dir = os.path.dirname(output_path) while scan_dir: search_paths.append(os.path.join(scan_dir, 'presets', 'skills', 'skd', 'skd-styles.json')) parent_dir = os.path.dirname(scan_dir) if parent_dir == scan_dir: break scan_dir = parent_dir for p in search_paths: if os.path.isfile(p): with open(p, 'r', encoding='utf-8-sig') as f: user_styles = json.load(f) for name, overrides in user_styles.items(): base = dict(AREA_STYLE_PRESETS.get(name, AREA_STYLE_PRESETS['data'])) base.update(overrides) AREA_STYLE_PRESETS[name] = base return def _emit_color_value(lines, color, indent): # Префиксы style:/web:/win: → соответствующий xmlns + dN:Name color_prefix_to_uri = { 'style:': 'http://v8.1c.ru/8.1/data/ui/style', 'web:': 'http://v8.1c.ru/8.1/data/ui/colors/web', 'win:': 'http://v8.1c.ru/8.1/data/ui/colors/windows', } for pfx, uri in color_prefix_to_uri.items(): if color.startswith(pfx): name = color[len(pfx):] lines.append(f'{indent}d8p1:{name}') return lines.append(f'{indent}{esc_xml(color)}') def _emit_cell_appearance(lines, style, width=0, v_merge=False, h_merge=False, min_height=0, extra_items=None): ind = '\t\t\t\t\t\t' # Если ничего внутри appearance не будет — не эмитим блок вовсе # (оригинал платформы для cells без атрибутов не пишет ). has_content = bool( style.get('bgColor') or style.get('textColor') or style.get('borders') or style.get('font') or style.get('hAlign') or style.get('vAlign') or style.get('wrap') or (width > 0) or (min_height > 0) or v_merge or h_merge or (extra_items and len(extra_items) > 0) ) if not has_content: return lines.append('\t\t\t\t\t') # Background color if style.get('bgColor'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0426\u0432\u0435\u0442\u0424\u043e\u043d\u0430') _emit_color_value(lines, style['bgColor'], f'{ind}\t') lines.append(f'{ind}') # Text color if style.get('textColor'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0426\u0432\u0435\u0442\u0422\u0435\u043a\u0441\u0442\u0430') _emit_color_value(lines, style['textColor'], f'{ind}\t') lines.append(f'{ind}') # Borders if style.get('borders'): if style.get('borderColor'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0426\u0432\u0435\u0442\u0413\u0440\u0430\u043d\u0438\u0446\u044b') _emit_color_value(lines, style['borderColor'], f'{ind}\t') lines.append(f'{ind}') lines.append(f'{ind}') lines.append(f'{ind}\t\u0421\u0442\u0438\u043b\u044c\u0413\u0440\u0430\u043d\u0438\u0446\u044b') lines.append(f'{ind}\t') lines.append(f'{ind}\t\tNone') lines.append(f'{ind}\t') for side in ['\u0421\u043b\u0435\u0432\u0430', '\u0421\u0432\u0435\u0440\u0445\u0443', '\u0421\u043f\u0440\u0430\u0432\u0430', '\u0421\u043d\u0438\u0437\u0443']: lines.append(f'{ind}\t') lines.append(f'{ind}\t\t\u0421\u0442\u0438\u043b\u044c\u0413\u0440\u0430\u043d\u0438\u0446\u044b.{side}') lines.append(f'{ind}\t\t') lines.append(f'{ind}\t\t\tSolid') lines.append(f'{ind}\t\t') lines.append(f'{ind}\t') lines.append(f'{ind}') # Font (skip if style has no font configured \u2014 for "none" preset) if style.get('font'): bold_str = 'true' if style.get('bold') else 'false' italic_str = 'true' if style.get('italic') else 'false' lines.append(f'{ind}') lines.append(f'{ind}\t\u0428\u0440\u0438\u0444\u0442') lines.append(f'{ind}\t') lines.append(f'{ind}') # Horizontal alignment if style.get('hAlign'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0413\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435\u041f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435') lines.append(f'{ind}\t{esc_xml(style["hAlign"])}') lines.append(f'{ind}') # Vertical alignment if style.get('vAlign'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0412\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435\u041f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435') lines.append(f'{ind}\t{esc_xml(style["vAlign"])}') lines.append(f'{ind}') # Wrap if style.get('wrap'): lines.append(f'{ind}') lines.append(f'{ind}\t\u0420\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u0435') lines.append(f'{ind}\tWrap') lines.append(f'{ind}') # Width if width and width > 0: lines.append(f'{ind}') lines.append(f'{ind}\t\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f\u0428\u0438\u0440\u0438\u043d\u0430') lines.append(f'{ind}\t{fmt_dec(width)}') lines.append(f'{ind}') lines.append(f'{ind}') lines.append(f'{ind}\t\u041c\u0430\u043a\u0441\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f\u0428\u0438\u0440\u0438\u043d\u0430') lines.append(f'{ind}\t{fmt_dec(width)}') lines.append(f'{ind}') # Min height if min_height and min_height > 0: lines.append(f'{ind}') lines.append(f'{ind}\t\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u0430\u044f\u0412\u044b\u0441\u043e\u0442\u0430') lines.append(f'{ind}\t{min_height}') lines.append(f'{ind}') # Vertical merge if v_merge: lines.append(f'{ind}') lines.append(f'{ind}\t\u041e\u0431\u044a\u0435\u0434\u0438\u043d\u044f\u0442\u044c\u041f\u043e\u0412\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u0438') lines.append(f'{ind}\ttrue') lines.append(f'{ind}') # Horizontal merge if h_merge: lines.append(f'{ind}') lines.append(f'{ind}\t\u041e\u0431\u044a\u0435\u0434\u0438\u043d\u044f\u0442\u044c\u041f\u043e\u0413\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u0438') lines.append(f'{ind}\ttrue') lines.append(f'{ind}') # Extra appearance items (e.g. drilldown) if extra_items: for ei in extra_items: lines.append(ei) lines.append('\t\t\t\t\t') # Cell может быть string ("text"/"{param}"/"|"/">"/null) или объектом {value, style}. def _get_cell_value(cell): if cell is None: return None if isinstance(cell, str): return cell if isinstance(cell, dict): if 'value' in cell: return cell['value'] return cell # multilang dict without wrapper return None def _get_cell_style_or_default(cell, default_style): if isinstance(cell, dict) and 'style' in cell: s_name = str(cell['style']) if s_name in AREA_STYLE_PRESETS: return AREA_STYLE_PRESETS[s_name] print(f"Warning: Unknown cell style preset '{s_name}', falling back to template default", file=sys.stderr) return default_style def _emit_area_template_dsl(lines, t): style_name = str(t.get('style', '')) or 'data' if style_name not in AREA_STYLE_PRESETS: print(f"Warning: Unknown area style preset '{style_name}', falling back to 'data'", file=sys.stderr) style_name = 'data' style = AREA_STYLE_PRESETS[style_name] rows = list(t['rows']) widths = list(t.get('widths', [])) min_height = float(t.get('minHeight', 0)) col_count = len(widths) if widths else len(rows[0]) # Build vertical merge map v_merge = {} for r in range(len(rows) - 1, 0, -1): v_merge[r] = {} for c in range(col_count): cell_val = _get_cell_value(rows[r][c]) if c < len(rows[r]) else None if cell_val == '|': v_merge[r][c] = True if 0 not in v_merge: v_merge[0] = {} # Build horizontal merge map h_merge = {} for r in range(len(rows)): h_merge[r] = {} for c in range(col_count): cell_val = _get_cell_value(rows[r][c]) if c < len(rows[r]) else None if cell_val == '>': h_merge[r][c] = True # Build drilldown map: param_name -> drilldown_value (только shortcut-форма: drilldown — строка). # Форма C (drilldown — объект) — DetailsAreaTemplateParameter с произвольным именем, в map не идёт. drilldown_map = {} if t.get('parameters'): for tp in t['parameters']: dd = tp.get('drilldown') if dd and isinstance(dd, str): drilldown_map[str(tp['name'])] = dd lines.append('\t') # \u042d\u043c\u0438\u0441\u0441\u0438\u044f \u043e\u0434\u043d\u043e\u0433\u043e \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u0448\u0430\u0431\u043b\u043e\u043d\u0430. \u0420\u0430\u0437\u043b\u0438\u0447\u0430\u0435\u0442 \u0442\u0440\u0438 \u0444\u043e\u0440\u043c\u044b: # A. {name, expression} \u2192 ExpressionAreaTemplateParameter # B. {name, expression, drilldown: "X"} \u2192 Expression + Details(\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430_X, \u0418\u043c\u044f\u0420\u0435\u0441\u0443\u0440\u0441\u0430, DrillDown) # C. {name, drilldown: {field, expression, action?}} \u2192 DetailsAreaTemplateParameter \u0441 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u043b\u044c\u043d\u044b\u043c \u0438\u043c\u0435\u043d\u0435\u043c def _emit_area_template_parameter(lines, tp, indent): dd = tp.get('drilldown') if isinstance(dd, dict): # \u0424\u043e\u0440\u043c\u0430 C dd_field = str(dd.get('field', '')) dd_expr = str(dd.get('expression', '')) dd_act = str(dd.get('action') or 'DrillDown') lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(str(tp["name"]))}') lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(dd_field)}') lines.append(f'{indent}\t\t{esc_xml(dd_expr)}') lines.append(f'{indent}\t') lines.append(f'{indent}\t{esc_xml(dd_act)}') lines.append(f'{indent}') return # \u0424\u043e\u0440\u043c\u0430 A \u0438\u043b\u0438 B lines.append(f'{indent}') lines.append(f'{indent}\t{esc_xml(str(tp["name"]))}') lines.append(f'{indent}\t{esc_xml(str(tp.get("expression", "")))}') lines.append(f'{indent}') if dd and isinstance(dd, str): # \u0424\u043e\u0440\u043c\u0430 B: shortcut \u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430_ + \u0418\u043c\u044f\u0420\u0435\u0441\u0443\u0440\u0441\u0430 + DrillDown dd_val = dd lines.append(f'{indent}') lines.append(f'{indent}\t\u0420\u0430\u0441\u0448\u0438\u0444\u0440\u043e\u0432\u043a\u0430_{esc_xml(dd_val)}') lines.append(f'{indent}\t') lines.append(f'{indent}\t\t\u0418\u043c\u044f\u0420\u0435\u0441\u0443\u0440\u0441\u0430') lines.append(f'{indent}\t\t"{esc_xml(dd_val)}"') lines.append(f'{indent}\t') lines.append(f'{indent}\tDrillDown') lines.append(f'{indent}') # === Templates === def emit_templates(lines, defn): if not defn.get('templates'): return for t in defn['templates']: if t.get('rows'): _emit_area_template_dsl(lines, t) else: lines.append('\t') # === FieldTemplates === # Привязка