#!/usr/bin/env python3 # skd-compile v1.21 — 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 def esc_xml(s): return s.replace('&', '&').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): if not text: lines.append(f"{indent}<{tag}/>") return 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", "\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 or string(N) m = re.match(r'^string(\((\d+)\))?$', type_str) if m: length = m.group(2) if m.group(2) else '0' lines.append(f'{indent}xs:string') lines.append(f'{indent}') lines.append(f'{indent}\t{length}') lines.append(f'{indent}\tVariable') lines.append(f'{indent}') return # decimal(D,F) or decimal(D,F,nonneg) m = re.match(r'^decimal\((\d+),(\d+)(,nonneg)?\)$', type_str) if m: digits = m.group(1) fraction = m.group(2) sign = 'Nonnegative' if m.group(3) else 'Any' lines.append(f'{indent}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 m = re.match(r'^(date|dateTime)$', type_str) if m: fractions_map = {'date': 'Date', 'dateTime': 'DateTime'} 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 # 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': {}, } # 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) # 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 # --- 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 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" 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): result['value'] = m.group(4).strip() 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", } # ===== 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_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': {}, } # Parse role if field_def.get('role'): if isinstance(field_def['role'], str): f['roles'] = [field_def['role']] else: # Object form -- collect truthy keys for k, v in field_def['role'].items(): if v is True: f['roles'].append(k) # Parse restrictions if field_def.get('restrict'): f['restrict'] = list(field_def['restrict']) # Parse appearance if field_def.get('appearance'): for k, v in field_def['appearance'].items(): f['appearance'][k] = str(v) if field_def.get('presentationExpression'): f['presentationExpression'] = str(field_def['presentationExpression']) # attrRestrict if field_def.get('attrRestrict'): f['attrRestrict'] = list(field_def['attrRestrict']) # role object extras if field_def.get('role') and not isinstance(field_def['role'], str): f['roleObj'] = field_def['role'] 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 if (f.get('roles') and len(f['roles']) > 0) or f.get('roleObj'): lines.append(f'{indent}\t') for role in f.get('roles', []): if role == 'period': lines.append(f'{indent}\t\t1') lines.append(f'{indent}\t\tMain') else: lines.append(f'{indent}\t\ttrue') if f.get('roleObj'): ro = f['roleObj'] if ro.get('accountTypeExpression'): lines.append(f'{indent}\t\t{esc_xml(str(ro["accountTypeExpression"]))}') if ro.get('balanceGroup'): lines.append(f'{indent}\t\t{esc_xml(str(ro["balanceGroup"]))}') 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') # Appearance if f.get('appearance') and len(f['appearance']) > 0: lines.append(f'{indent}\t') for key, val in f['appearance'].items(): lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t{esc_xml(key)}') 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': lines.append(f'{indent}\t\t\t{esc_xml(val)}') else: lines.append(f'{indent}\t\t\t{esc_xml(val)}') lines.append(f'{indent}\t\t') lines.append(f'{indent}\t') # PresentationExpression if f.get('presentationExpression'): lines.append(f'{indent}\t{esc_xml(f["presentationExpression"])}') lines.append(f'{indent}') # === DataSets === def emit_data_set(lines, ds, indent, default_source): # Determine type if ds.get('items'): ds_type = 'DataSetUnion' elif ds.get('objectName'): ds_type = 'DataSetObject' else: ds_type = 'DataSetQuery' lines.append(f'{indent}') 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']: emit_data_set(lines, item, f'{indent}\t', default_source) 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"]))}') 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(): 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') 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 emit_param_value(lines, type_str, val, indent): if val is None: return val_str = str(val) if type_str == 'StandardPeriod': # Always emit startDate/endDate to match how 1C Designer saves the schema. lines.append(f'{indent}') 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}') 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)}') 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 emit_param_value(lines, parsed.get('type', ''), parsed.get('value'), '\t\t') # Hidden implies useRestriction=true + availableAsField=false if parsed.get('hidden') is True: parsed['availableAsField'] = False parsed['useRestriction'] = True # UseRestriction if parsed.get('useRestriction') is True or (p is not None and not isinstance(p, str) and p.get('useRestriction') is True): lines.append('\t\ttrue') # 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']: av_val = str(av.get('value', '')) av_type = 'xs:string' if re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', av_val): av_type = 'dcscor:DesignTimeValue' lines.append('\t\t') 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)}') 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: parsed = { 'name': str(p.get('name', '')), 'type': resolve_type_str(str(p['type'])) if p.get('type') else '', '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 # @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) 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': 'date', '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': 'date', '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 = { '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): if color.startswith('style:'): style_name = color[6:] lines.append(f'{indent}d8p1:{style_name}') else: 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' lines.append('\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 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') 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 = rows[r][c] if c < len(rows[r]) else None if isinstance(cell_val, str) and 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 = rows[r][c] if c < len(rows[r]) else None if isinstance(cell_val, str) and cell_val == '>': h_merge[r][c] = True # Build drilldown map: param_name -> drilldown_value drilldown_map = {} if t.get('parameters'): for tp in t['parameters']: if tp.get('drilldown'): drilldown_map[str(tp['name'])] = str(tp['drilldown']) lines.append('\t') # === 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') # === GroupTemplates === def emit_group_templates(lines, defn): if not defn.get('groupTemplates'): return for gt in defn['groupTemplates']: ttype = str(gt.get('templateType', '')) or 'Header' is_header = (ttype == 'GroupHeader') tag = 'groupHeaderTemplate' if is_header else 'groupTemplate' xml_ttype = 'Header' if is_header else ttype lines.append(f'\t<{tag}>') if gt.get('groupName'): lines.append(f'\t\t{esc_xml(str(gt["groupName"]))}') elif gt.get('groupField'): lines.append(f'\t\t{esc_xml(str(gt["groupField"]))}') lines.append(f'\t\t{esc_xml(xml_ttype)}') lines.append(f'\t\t') lines.append(f'\t') # === Settings Variants === def emit_selection(lines, items, indent, skip_auto=False): if not items or len(items) == 0: return lines.append(f'{indent}') for item in items: if isinstance(item, str): if item == 'Auto': if not skip_auto: lines.append(f'{indent}\t') else: lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(item)}') lines.append(f'{indent}\t') elif item.get('folder'): lines.append(f'{indent}\t') lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t\t\tru') lines.append(f'{indent}\t\t\t\t{esc_xml(str(item["folder"]))}') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t') for sub in (item.get('items') or []): sub_name = str(sub.get('field', sub)) if isinstance(sub, dict) else str(sub) lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t{esc_xml(sub_name)}') lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\tAuto') lines.append(f'{indent}\t') else: lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(str(item["field"]))}') if item.get('title'): lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t\t\tru') lines.append(f'{indent}\t\t\t\t{esc_xml(str(item["title"]))}') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t') lines.append(f'{indent}\t') lines.append(f'{indent}') def emit_filter_item(lines, item, indent): if item.get('group'): # FilterItemGroup group_type_map = {'And': 'AndGroup', 'Or': 'OrGroup', 'Not': 'NotGroup'} group_type = group_type_map.get(str(item['group']), f'{item["group"]}Group') lines.append(f'{indent}') lines.append(f'{indent}\t{group_type}') if item.get('items'): for sub in item['items']: if isinstance(sub, str): parsed = parse_filter_shorthand(sub) sub = {'field': parsed['field'], 'op': parsed['op']} if parsed['use'] is False: sub['use'] = False if parsed.get('value') is not None: sub['value'] = parsed['value'] if parsed.get('valueType'): sub['valueType'] = parsed['valueType'] if parsed.get('userSettingID'): sub['userSettingID'] = parsed['userSettingID'] if parsed.get('viewMode'): sub['viewMode'] = parsed['viewMode'] emit_filter_item(lines, sub, f'{indent}\t') lines.append(f'{indent}') return # FilterItemComparison lines.append(f'{indent}') if item.get('use') is False: lines.append(f'{indent}\tfalse') lines.append(f'{indent}\t{esc_xml(str(item["field"]))}') comp_type = COMPARISON_TYPES.get(str(item.get('op', '')), str(item.get('op', ''))) lines.append(f'{indent}\t{esc_xml(comp_type)}') # Right value if item.get('value') is not None: vt = str(item.get('valueType', '')) if item.get('valueType') else '' if not vt: v = item['value'] if isinstance(v, bool): vt = 'xs:boolean' elif isinstance(v, (int, float)): vt = 'xs:decimal' elif re.match(r'^\d{4}-\d{2}-\d{2}T', str(v)): vt = 'xs:dateTime' else: vt = 'xs:string' if isinstance(item['value'], bool): v_str = str(item['value']).lower() else: v_str = esc_xml(str(item['value'])) lines.append(f'{indent}\t{v_str}') if item.get('presentation'): emit_mltext(lines, f'{indent}\t', 'dcsset:presentation', item["presentation"]) if item.get('viewMode'): lines.append(f'{indent}\t{esc_xml(str(item["viewMode"]))}') if item.get('userSettingID'): uid = new_uuid() if str(item['userSettingID']) == 'auto' else str(item['userSettingID']) lines.append(f'{indent}\t{esc_xml(uid)}') if item.get('userSettingPresentation'): emit_mltext(lines, f'{indent}\t', 'dcsset:userSettingPresentation', item["userSettingPresentation"]) lines.append(f'{indent}') def emit_filter(lines, items, indent): if not items or len(items) == 0: return lines.append(f'{indent}') for item in items: if isinstance(item, str): parsed = parse_filter_shorthand(item) filter_obj = { 'field': parsed['field'], 'op': parsed['op'], } if parsed['use'] is False: filter_obj['use'] = False if parsed.get('value') is not None: filter_obj['value'] = parsed['value'] if parsed.get('valueType'): filter_obj['valueType'] = parsed['valueType'] if parsed.get('userSettingID'): filter_obj['userSettingID'] = parsed['userSettingID'] if parsed.get('viewMode'): filter_obj['viewMode'] = parsed['viewMode'] emit_filter_item(lines, filter_obj, f'{indent}\t') else: emit_filter_item(lines, item, f'{indent}\t') lines.append(f'{indent}') def emit_order(lines, items, indent, skip_auto=False): if not items or len(items) == 0: return lines.append(f'{indent}') for item in items: if isinstance(item, str): if item == 'Auto': if not skip_auto: lines.append(f'{indent}\t') else: parts = item.split() field = parts[0] direction = 'Asc' if len(parts) > 1 and re.match(r'(?i)^desc$', parts[1]): direction = 'Desc' elif len(parts) > 1 and re.match(r'(?i)^asc$', parts[1]): direction = 'Asc' lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(field)}') lines.append(f'{indent}\t\t{direction}') lines.append(f'{indent}\t') lines.append(f'{indent}') def emit_appearance_value(lines, key, val, indent): lines.append(f'{indent}') if isinstance(val, dict) and val.get('use') is False: lines.append(f'{indent}\tfalse') lines.append(f'{indent}\t{esc_xml(key)}') actual_val = str(val.get('value', '')) else: lines.append(f'{indent}\t{esc_xml(key)}') actual_val = str(val) # Auto-detect value type if re.match(r'^(style|web|win):', actual_val): lines.append(f'{indent}\t{esc_xml(actual_val)}') elif actual_val == 'true' or actual_val == 'false': lines.append(f'{indent}\t{actual_val}') elif key in ('\u0422\u0435\u043a\u0441\u0442', '\u0417\u0430\u0433\u043e\u043b\u043e\u0432\u043e\u043a', '\u0424\u043e\u0440\u043c\u0430\u0442'): emit_mltext(lines, f'{indent}\t', 'dcscor:value', actual_val) else: lines.append(f'{indent}\t{esc_xml(actual_val)}') lines.append(f'{indent}') def emit_conditional_appearance(lines, items, indent): if not items or len(items) == 0: return lines.append(f'{indent}') for ca in items: lines.append(f'{indent}\t') # Selection if ca.get('selection') and len(ca['selection']) > 0: lines.append(f'{indent}\t\t') for sel in ca['selection']: lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t\t\t{esc_xml(str(sel))}') lines.append(f'{indent}\t\t\t') lines.append(f'{indent}\t\t') else: lines.append(f'{indent}\t\t') # Filter if ca.get('filter'): emit_filter(lines, ca['filter'], f'{indent}\t\t') # Appearance if ca.get('appearance'): lines.append(f'{indent}\t\t') for k, v in ca['appearance'].items(): emit_appearance_value(lines, k, v, f'{indent}\t\t\t') lines.append(f'{indent}\t\t') # Presentation if ca.get('presentation'): lines.append(f'{indent}\t\t{esc_xml(str(ca["presentation"]))}') # ViewMode if ca.get('viewMode'): lines.append(f'{indent}\t\t{esc_xml(str(ca["viewMode"]))}') # UserSettingID if ca.get('userSettingID'): uid = new_uuid() if str(ca['userSettingID']) == 'auto' else str(ca['userSettingID']) lines.append(f'{indent}\t\t{esc_xml(uid)}') lines.append(f'{indent}\t') lines.append(f'{indent}') def emit_output_parameters(lines, params, indent): if not params: return lines.append(f'{indent}') for key, val in params.items(): val_str = str(val) ptype = OUTPUT_PARAM_TYPES.get(key, 'xs:string') lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(key)}') if ptype == 'mltext': emit_mltext(lines, f'{indent}\t\t', 'dcscor:value', val_str) else: lines.append(f'{indent}\t\t{esc_xml(val_str)}') lines.append(f'{indent}\t') lines.append(f'{indent}') def emit_data_parameters(lines, items, indent): if not items or len(items) == 0: return lines.append(f'{indent}') for dp in items: # Support string shorthand if isinstance(dp, str): parsed = parse_data_param_shorthand(dp) dp = { 'parameter': parsed['parameter'], } if parsed.get('value') is not None: dp['value'] = parsed['value'] if parsed['use'] is False: dp['use'] = False if parsed.get('userSettingID'): dp['userSettingID'] = parsed['userSettingID'] if parsed.get('viewMode'): dp['viewMode'] = parsed['viewMode'] lines.append(f'{indent}\t') if dp.get('use') is False: lines.append(f'{indent}\t\tfalse') lines.append(f'{indent}\t\t{esc_xml(str(dp["parameter"]))}') # Value if dp.get('nilValue') is True: lines.append(f'{indent}\t\t') elif dp.get('value') is not None: val = dp['value'] vtype = str(dp.get('valueType') or '') if isinstance(val, dict) and val.get('variant'): # StandardPeriod lines.append(f'{indent}\t\t') lines.append(f'{indent}\t\t\t{esc_xml(str(val["variant"]))}') lines.append(f'{indent}\t\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t\t') elif vtype == 'boolean' or isinstance(val, bool): bv = str(val).lower() lines.append(f'{indent}\t\t{esc_xml(bv)}') elif re.match(r'^date', vtype) or re.match(r'^\d{4}-\d{2}-\d{2}T', str(val)): lines.append(f'{indent}\t\t{esc_xml(str(val))}') elif re.match(r'^decimal', vtype): lines.append(f'{indent}\t\t{esc_xml(str(val))}') elif re.match(r'^string', vtype): lines.append(f'{indent}\t\t{esc_xml(str(val))}') elif re.match(r'^(\u041f\u043b\u0430\u043d\u0421\u0447\u0435\u0442\u043e\u0432|\u0421\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a|\u041f\u0435\u0440\u0435\u0447\u0438\u0441\u043b\u0435\u043d\u0438\u0435|\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u041f\u043b\u0430\u043d\u0412\u0438\u0434\u043e\u0432\u0425\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0441\u0442\u0438\u043a|\u041f\u043b\u0430\u043d\u0412\u0438\u0434\u043e\u0432\u0420\u0430\u0441\u0447\u0435\u0442\u0430|\u0411\u0438\u0437\u043d\u0435\u0441\u041f\u0440\u043e\u0446\u0435\u0441\u0441|\u0417\u0430\u0434\u0430\u0447\u0430|\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0421\u0432\u0435\u0434\u0435\u043d\u0438\u0439|\u041f\u043b\u0430\u043d\u041e\u0431\u043c\u0435\u043d\u0430)\.', str(val)) or re.match(r'^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', str(val)): lines.append(f'{indent}\t\t{esc_xml(str(val))}') else: lines.append(f'{indent}\t\t{esc_xml(str(val))}') if dp.get('viewMode'): lines.append(f'{indent}\t\t{esc_xml(str(dp["viewMode"]))}') if dp.get('userSettingID'): uid = new_uuid() if str(dp['userSettingID']) == 'auto' else str(dp['userSettingID']) lines.append(f'{indent}\t\t{esc_xml(uid)}') if dp.get('userSettingPresentation'): emit_mltext(lines, f'{indent}\t\t', 'dcsset:userSettingPresentation', dp["userSettingPresentation"]) lines.append(f'{indent}\t') lines.append(f'{indent}') # === Structure items (recursive) === def emit_group_items(lines, group_by, indent): if not group_by or len(group_by) == 0: return lines.append(f'{indent}') for field in group_by: if isinstance(field, str): lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(field)}') lines.append(f'{indent}\t\tItems') lines.append(f'{indent}\t\tNone') lines.append(f'{indent}\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t') else: lines.append(f'{indent}\t') lines.append(f'{indent}\t\t{esc_xml(str(field["field"]))}') gt = str(field.get('groupType', 'Items')) lines.append(f'{indent}\t\t{esc_xml(gt)}') pat = str(field.get('periodAdditionType', 'None')) lines.append(f'{indent}\t\t{esc_xml(pat)}') lines.append(f'{indent}\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t\t0001-01-01T00:00:00') lines.append(f'{indent}\t') lines.append(f'{indent}') def parse_structure_shorthand(s): segments = re.split(r'\s*>\s*', s) innermost = None for i in range(len(segments) - 1, -1, -1): seg = segments[i].strip() group = {'type': 'group'} if re.match(r'(?i)^(details|\u0434\u0435\u0442\u0430\u043b\u0438)$', seg): group['groupBy'] = [] else: # Named group: "ИмяГруппы[Поле]" m_named = re.match(r'^(.+)\[(.+)\]$', seg) if m_named: group['name'] = m_named.group(1).strip() group['groupBy'] = [m_named.group(2).strip()] else: group['groupBy'] = [seg] if innermost is not None: group['children'] = [innermost] innermost = group if innermost: return [innermost] return [] def emit_structure_item(lines, item, indent): item_type = str(item.get('type', 'group')) if item_type == 'group': lines.append(f'{indent}') if item.get('name'): lines.append(f'{indent}\t{esc_xml(str(item["name"]))}') emit_group_items(lines, item.get('groupBy') or item.get('groupFields'), f'{indent}\t') # Default order to ["Auto"] if not specified order_items = item.get('order') or ['Auto'] emit_order(lines, order_items, f'{indent}\t') # Default selection to ["Auto"] if not specified sel_items = item.get('selection') or ['Auto'] emit_selection(lines, sel_items, f'{indent}\t') emit_filter(lines, item.get('filter'), f'{indent}\t') if item.get('outputParameters'): emit_output_parameters(lines, item['outputParameters'], f'{indent}\t') # Nested children if item.get('children'): for child in item['children']: emit_structure_item(lines, child, f'{indent}\t') lines.append(f'{indent}') elif item_type == 'table': lines.append(f'{indent}') if item.get('name'): lines.append(f'{indent}\t{esc_xml(str(item["name"]))}') # Columns if item.get('columns'): for col in item['columns']: lines.append(f'{indent}\t') emit_group_items(lines, col.get('groupBy') or col.get('groupFields'), f'{indent}\t\t') col_order = col.get('order') or ['Auto'] emit_order(lines, col_order, f'{indent}\t\t') col_sel = col.get('selection') or ['Auto'] emit_selection(lines, col_sel, f'{indent}\t\t') lines.append(f'{indent}\t') # Rows if item.get('rows'): for row in item['rows']: lines.append(f'{indent}\t') if row.get('name'): lines.append(f'{indent}\t\t{esc_xml(str(row["name"]))}') emit_group_items(lines, row.get('groupBy') or row.get('groupFields'), f'{indent}\t\t') row_order = row.get('order') or ['Auto'] emit_order(lines, row_order, f'{indent}\t\t') row_sel = row.get('selection') or ['Auto'] emit_selection(lines, row_sel, f'{indent}\t\t') lines.append(f'{indent}\t') lines.append(f'{indent}') elif item_type == 'chart': lines.append(f'{indent}') if item.get('name'): lines.append(f'{indent}\t{esc_xml(str(item["name"]))}') # Points if item.get('points'): lines.append(f'{indent}\t') emit_group_items(lines, item['points'].get('groupBy') or item['points'].get('groupFields'), f'{indent}\t\t') pt_order = item['points'].get('order') or ['Auto'] emit_order(lines, pt_order, f'{indent}\t\t') pt_sel = item['points'].get('selection') or ['Auto'] emit_selection(lines, pt_sel, f'{indent}\t\t') lines.append(f'{indent}\t') # Series if item.get('series'): lines.append(f'{indent}\t') emit_group_items(lines, item['series'].get('groupBy') or item['series'].get('groupFields'), f'{indent}\t\t') sr_order = item['series'].get('order') or ['Auto'] emit_order(lines, sr_order, f'{indent}\t\t') sr_sel = item['series'].get('selection') or ['Auto'] emit_selection(lines, sr_sel, f'{indent}\t\t') lines.append(f'{indent}\t') # Selection (chart values) emit_selection(lines, item.get('selection'), f'{indent}\t') if item.get('outputParameters'): emit_output_parameters(lines, item['outputParameters'], f'{indent}\t') lines.append(f'{indent}') def emit_settings_variants(lines, defn): variants = defn.get('settingsVariants') # Default variant if none specified if not variants or len(variants) == 0: variants = [{ 'name': '\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439', 'presentation': '\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439', 'settings': { 'selection': ['Auto'], 'structure': [{ 'type': 'group', 'order': ['Auto'], 'selection': ['Auto'], }], }, }] for v in variants: lines.append('\t') lines.append(f'\t\t{esc_xml(str(v["name"]))}') pres = v.get('presentation') or v.get('title') or v['name'] emit_mltext(lines, '\t\t', 'dcsset:presentation', pres) lines.append('\t\t') s = v.get('settings', {}) # Selection if s.get('selection'): emit_selection(lines, s['selection'], '\t\t\t', skip_auto=True) # Filter if s.get('filter'): emit_filter(lines, s['filter'], '\t\t\t') # Order if s.get('order'): emit_order(lines, s['order'], '\t\t\t', skip_auto=True) # ConditionalAppearance if s.get('conditionalAppearance'): emit_conditional_appearance(lines, s['conditionalAppearance'], '\t\t\t') # OutputParameters if s.get('outputParameters'): emit_output_parameters(lines, s['outputParameters'], '\t\t\t') # DataParameters if s.get('dataParameters') == 'auto': # Auto-generate dataParameters for all non-hidden params. # Pattern follows 1C Designer / ERP persistence: # value set (non-default) → emit value, use=true (implicit) # value missing / Custom period → false + auto_dp = [] for ap in _all_params: if ap['hidden']: continue item = { 'parameter': ap['name'], 'userSettingID': 'auto', } has_meaningful_value = False if ap.get('type') == 'StandardPeriod': variant = 'Custom' av = ap.get('value') if av is not None: if isinstance(av, dict) and av.get('variant'): variant = str(av['variant']) elif str(av): variant = str(av) item['value'] = {'variant': variant} if variant != 'Custom': has_meaningful_value = True elif ap.get('value') is not None and str(ap.get('value')) != '': item['value'] = ap['value'] item['valueType'] = str(ap.get('type') or '') has_meaningful_value = True else: item['nilValue'] = True if not has_meaningful_value: item['use'] = False auto_dp.append(item) if auto_dp: emit_data_parameters(lines, auto_dp, '\t\t\t') elif s.get('dataParameters'): emit_data_parameters(lines, s['dataParameters'], '\t\t\t') # Structure (supports string shorthand) if s.get('structure'): struct_items = s['structure'] if isinstance(struct_items, str): struct_items = parse_structure_shorthand(struct_items) elif isinstance(struct_items, dict): struct_items = [struct_items] for item in struct_items: emit_structure_item(lines, item, '\t\t\t') lines.append('\t\t') lines.append('\t') def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser(description='Compile 1C DCS from JSON', allow_abbrev=False) parser.add_argument('-DefinitionFile', type=str, default=None) parser.add_argument('-Value', type=str, default=None) parser.add_argument('-OutputPath', type=str, required=True) args = parser.parse_args() # --- 1. Load and validate JSON --- if args.DefinitionFile and args.Value: print("Cannot use both -DefinitionFile and -Value", file=sys.stderr) sys.exit(1) if not args.DefinitionFile and not args.Value: print("Either -DefinitionFile or -Value is required", file=sys.stderr) sys.exit(1) if args.DefinitionFile: def_file = args.DefinitionFile if not os.path.isabs(def_file): def_file = os.path.join(os.getcwd(), def_file) if not os.path.exists(def_file): print(f"Definition file not found: {def_file}", file=sys.stderr) sys.exit(1) with open(def_file, 'r', encoding='utf-8-sig') as f: json_text = f.read() else: json_text = args.Value defn = json.loads(json_text) if not defn.get('dataSets') or len(defn['dataSets']) == 0: print("JSON must have at least one entry in 'dataSets'", file=sys.stderr) sys.exit(1) # Base directory for resolving @file references in query global query_base_dir query_base_dir = os.path.dirname(def_file) if args.DefinitionFile else os.getcwd() # Load user style presets out_path_resolved = args.OutputPath if os.path.isabs(args.OutputPath) else os.path.join(os.getcwd(), args.OutputPath) load_user_styles(query_base_dir, out_path_resolved) # --- 2. Resolve defaults --- # DataSources data_sources = [] if defn.get('dataSources'): for ds in defn['dataSources']: data_sources.append({ 'name': str(ds['name']), 'type': str(ds.get('type', 'Local')), }) else: data_sources.append({'name': '\u0418\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0414\u0430\u043d\u043d\u044b\u04451', 'type': 'Local'}) default_source = data_sources[0]['name'] # Auto-name dataSets ds_index = 1 for ds in defn['dataSets']: if not ds.get('name'): ds['name'] = f'\u041d\u0430\u0431\u043e\u0440\u0414\u0430\u043d\u043d\u044b\u0445{ds_index}' ds_index += 1 # --- 3. Assemble XML --- lines = [] lines.append('') lines.append('') emit_data_sources(lines, data_sources) emit_data_sets(lines, defn, default_source) emit_data_set_links(lines, defn) emit_calc_fields(lines, defn) emit_total_fields(lines, defn) emit_parameters(lines, defn) emit_templates(lines, defn) emit_group_templates(lines, defn) emit_settings_variants(lines, defn) lines.append('') # --- 4. Write output --- output_path = args.OutputPath if not os.path.isabs(output_path): output_path = os.path.join(os.getcwd(), output_path) parent_dir = os.path.dirname(output_path) if parent_dir and not os.path.exists(parent_dir): os.makedirs(parent_dir, exist_ok=True) content = '\n'.join(lines) + '\n' write_utf8_bom(output_path, content) # --- 5. Statistics --- ds_count = len(defn['dataSets']) field_count = 0 for ds in defn['dataSets']: if ds.get('fields'): field_count += len(ds['fields']) calc_count = len(defn['calculatedFields']) if defn.get('calculatedFields') else 0 total_count = len(defn['totalFields']) if defn.get('totalFields') else 0 param_count = len(defn['parameters']) if defn.get('parameters') else 0 variant_count = len(defn['settingsVariants']) if defn.get('settingsVariants') else 1 file_size = os.path.getsize(output_path) print(f"OK {args.OutputPath}") print(f" DataSets: {ds_count} Fields: {field_count} Calculated: {calc_count} Totals: {total_count} Params: {param_count} Variants: {variant_count}") print(f" Size: {file_size} bytes") if __name__ == '__main__': main()