diff --git a/.claude/skills/skd-edit/scripts/skd-edit.ps1 b/.claude/skills/skd-edit/scripts/skd-edit.ps1 index 784264a4..773f2970 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.ps1 +++ b/.claude/skills/skd-edit/scripts/skd-edit.ps1 @@ -1,4 +1,4 @@ -# skd-edit v1.20 — Atomic 1C DCS editor +# skd-edit v1.21 — Atomic 1C DCS editor # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -183,6 +183,8 @@ function Read-FieldProperties($fieldEl) { $props = @{ dataPath = ""; field = ""; title = ""; type = "" roles = @(); restrict = @() + _rawTitle = $null + _unknownChildren = @() } foreach ($ch in $fieldEl.ChildNodes) { @@ -191,14 +193,22 @@ function Read-FieldProperties($fieldEl) { "dataPath" { $props.dataPath = $ch.InnerText.Trim() } "field" { $props.field = $ch.InnerText.Trim() } "title" { - # Extract text from LocalStringType + # Preserve full multi-lang title OuterXml — used to keep en/uk/etc. + # siblings when shorthand overrides only the ru content. Strip xmlns + # redeclarations that OuterXml adds for sub-elements. + $raw = $ch.OuterXml + $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $props._rawTitle = $raw + # Also extract ru content as plain string (backward compat — used by + # external consumers reading $existing.title). foreach ($item in $ch.ChildNodes) { if ($item.NodeType -eq 'Element' -and $item.LocalName -eq 'item') { + $lang = $null; $content = $null foreach ($gc in $item.ChildNodes) { - if ($gc.NodeType -eq 'Element' -and $gc.LocalName -eq 'content') { - $props.title = $gc.InnerText.Trim() - } + if ($gc.NodeType -eq 'Element' -and $gc.LocalName -eq 'lang') { $lang = $gc.InnerText.Trim() } + if ($gc.NodeType -eq 'Element' -and $gc.LocalName -eq 'content') { $content = $gc.InnerText.Trim() } } + if ($lang -eq 'ru' -and $null -ne $content) { $props.title = $content } } } } @@ -242,6 +252,13 @@ function Read-FieldProperties($fieldEl) { } } } + default { + # Defense in depth: preserve OuterXml of unknown children so rebuild + # doesn't silently drop them (custom , , etc.). + $raw = $ch.OuterXml + $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $props._unknownChildren += $raw + } } } return $props @@ -804,6 +821,28 @@ function Build-MLTextXml { return $lines -join "`n" } +# Patches the ru within an existing multi-lang title OuterXml, preserving +# en/uk/etc. siblings. Used when modify-* operates with a title-override shorthand on a +# field/parameter that already has multi-language titles (typical in ERP/БП/ЗУП). +# If no ru item exists, one is prepended before the first existing item. +function Patch-MLTextRu { + param([string]$rawOuterXml, [string]$newRuText, [string]$indent) + $escaped = Esc-Xml $newRuText + $ruItemPat = '(\s*ru\s*)[^<]*(\s*)' + if ([regex]::IsMatch($rawOuterXml, $ruItemPat)) { + return [regex]::Replace($rawOuterXml, $ruItemPat, { param($m) $m.Groups[1].Value + $escaped + $m.Groups[2].Value }) + } + # No ru item — prepend one inside the title element, before first . + $prep = "$indent`t`n$indent`t`tru`n$indent`t`t$escaped`n$indent`t" + if ($rawOuterXml -match '') { + $re = New-Object System.Text.RegularExpressions.Regex('(\s*)') + return $re.Replace($rawOuterXml, "`n$prep`$1", 1) + } + # Empty title — inject after opening tag. + $re2 = New-Object System.Text.RegularExpressions.Regex('(<(?:\w+:)?title[^>]*>)') + return $re2.Replace($rawOuterXml, "`$1`n$prep`n$indent", 1) +} + function Build-RoleXml { param([string[]]$roles, [string]$indent) @@ -854,7 +893,16 @@ function Build-FieldFragment { $lines += "$i`t$(Esc-Xml $parsed.dataPath)" $lines += "$i`t$(Esc-Xml $parsed.field)" - if ($parsed.title) { + # Title: prefer raw multi-lang title (preserves en/uk/etc.). When shorthand provides + # a new ru text, patch ru content inside the raw title; otherwise emit raw as-is. + # When no raw title exists, fall back to ru-only build from shorthand. + if ($parsed._rawTitle) { + if ($parsed.title -and $parsed.title -ne $parsed._existingTitleRu) { + $lines += "$i`t" + (Patch-MLTextRu $parsed._rawTitle $parsed.title "$i`t") + } else { + $lines += "$i`t" + $parsed._rawTitle + } + } elseif ($parsed.title) { $lines += (Build-MLTextXml -tag "title" -text $parsed.title -indent "$i`t") } @@ -875,6 +923,14 @@ function Build-FieldFragment { $lines += "$i`t" } + # Defense in depth: re-emit OuterXml of unknown children (e.g. , + # , custom extensions) that Read-FieldProperties captured. + if ($parsed._unknownChildren) { + foreach ($raw in $parsed._unknownChildren) { + $lines += "$i`t" + $raw + } + } + $lines += "$i" return $lines -join "`n" } @@ -1981,9 +2037,23 @@ switch ($Operation) { $existingTitle = $ch; break } } + # If the existing title has multiple (multi-language: ru + en + …), + # patch only the ru via raw-string surgery to preserve other langs. + # Otherwise rebuild as ru-only fragment. + $titleFrag = $null if ($existingTitle) { + $rawTitle = $existingTitle.OuterXml + $rawTitle = [regex]::Replace($rawTitle, ' xmlns(?::\w+)?="[^"]*"', '') + # Count occurrences — if >1, treat as multi-lang. + $itemCount = ([regex]::Matches($rawTitle, '')).Count + if ($itemCount -gt 1) { + $titleFrag = $childIndent + (Patch-MLTextRu $rawTitle $titleVal $childIndent) + } Remove-NodeWithWhitespace $existingTitle } + if (-not $titleFrag) { + $titleFrag = Build-MLTextXml -tag "title" -text $titleVal -indent $childIndent + } # Insert before first of (valueType, value, useRestriction, expression, availableAsField, ...) $titleRef = $null foreach ($ch in $paramEl.ChildNodes) { @@ -1991,7 +2061,6 @@ switch ($Operation) { $titleRef = $ch; break } } - $titleFrag = Build-MLTextXml -tag "title" -text $titleVal -indent $childIndent $titleNodes = Import-Fragment $xmlDoc $titleFrag foreach ($node in $titleNodes) { Insert-BeforeElement $paramEl $node $titleRef $childIndent @@ -3077,6 +3146,11 @@ switch ($Operation) { # Preserve raw only when user did NOT override type via shorthand — # otherwise the override path rebuilds valueType from $parsed.type. rawValueType = if ($parsed.type) { $null } else { $existing._rawValueType } + # Preserve raw multi-lang title; pass existing ru content for change detection. + _rawTitle = $existing._rawTitle + _existingTitleRu = $existing.title + # Pass-through unknown children (e.g. , , custom extensions). + _unknownChildren = $existing._unknownChildren } # Remember position (NextSibling after whitespace) @@ -3136,12 +3210,28 @@ switch ($Operation) { $fieldIndent = Get-ChildIndent $fieldEl - # Remove existing + # Remove existing — but first capture OuterXml of any sub-children that + # Build-RoleXml won't re-emit (e.g. , , + # custom extension elements). Preserved across rebuild. $oldRole = $null foreach ($ch in $fieldEl.ChildNodes) { if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'role' -and $ch.NamespaceURI -eq $schNs) { $oldRole = $ch; break } } - if ($oldRole) { Remove-NodeWithWhitespace $oldRole } + $knownRoleChildren = @('periodNumber','periodType','dimension','ignoreNullsInGroups','balance','account','accountTypeExpression','additionType','addition') + $preservedRoleChildren = @() + if ($oldRole) { + foreach ($gc in $oldRole.ChildNodes) { + if ($gc.NodeType -ne 'Element') { continue } + if ($knownRoleChildren -contains $gc.LocalName) { continue } + # kv keys override the same-named sub-element on rebuild — don't preserve + # what the user explicitly set. + if ($kv.Contains($gc.LocalName)) { continue } + $raw = $gc.OuterXml + $raw = [regex]::Replace($raw, ' xmlns(?::\w+)?="[^"]*"', '') + $preservedRoleChildren += $raw + } + Remove-NodeWithWhitespace $oldRole + } # Empty spec — remove only if ($flags.Count -eq 0 -and $kv.Count -eq 0) { @@ -3163,6 +3253,9 @@ switch ($Operation) { foreach ($k in $kv.Keys) { $lines += "$fieldIndent`t$(Esc-Xml $kv[$k])" } + foreach ($raw in $preservedRoleChildren) { + $lines += "$fieldIndent`t" + $raw + } $lines += "$fieldIndent" $fragXml = $lines -join "`n" diff --git a/.claude/skills/skd-edit/scripts/skd-edit.py b/.claude/skills/skd-edit/scripts/skd-edit.py index a1e0d2e5..6030cf37 100644 --- a/.claude/skills/skd-edit/scripts/skd-edit.py +++ b/.claude/skills/skd-edit/scripts/skd-edit.py @@ -1,4 +1,4 @@ -# skd-edit v1.20 — Atomic 1C DCS editor (Python port) +# skd-edit v1.21 — Atomic 1C DCS editor (Python port) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import os @@ -214,7 +214,8 @@ def parse_field_shorthand(s): def read_field_properties(field_el): - props = {"dataPath": "", "field": "", "title": "", "type": "", "roles": [], "restrict": [], "_rawTypeText": ""} + props = {"dataPath": "", "field": "", "title": "", "type": "", "roles": [], "restrict": [], "_rawTypeText": "", + "_rawTitle": None, "_unknownChildren": []} for ch in field_el: if not isinstance(ch.tag, str): @@ -225,17 +226,27 @@ def read_field_properties(field_el): elif ln == "field": props["field"] = (ch.text or "").strip() elif ln == "title": + # Preserve full multi-lang title OuterXml; also extract ru content for compat. + raw = etree.tostring(ch, encoding="unicode", with_tail=False) + raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + props["_rawTitle"] = raw for item in ch: if isinstance(item.tag, str) and local_name(item) == "item": + lang = None + content = None for gc in item: + if isinstance(gc.tag, str) and local_name(gc) == "lang": + lang = (gc.text or "").strip() if isinstance(gc.tag, str) and local_name(gc) == "content": - props["title"] = (gc.text or "").strip() + content = (gc.text or "").strip() + if lang == "ru" and content is not None: + props["title"] = content elif ln == "valueType": # Preserve full serialization so rebuild can re-emit qualifiers # (StringQualifiers, NumberQualifiers, DateQualifiers, …) that aren't # expressible via shorthand. Strip xmlns declarations that lxml re-emits when # serializing a sub-element (parent context already provides them). - raw = etree.tostring(ch, encoding="unicode") + raw = etree.tostring(ch, encoding="unicode", with_tail=False) raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) props["_rawValueType"] = raw for gc in ch: @@ -257,6 +268,12 @@ def read_field_properties(field_el): mapped = rev_map.get(local_name(gc)) if mapped: props["restrict"].append(mapped) + else: + # Defense in depth: preserve OuterXml of unknown children so rebuild + # doesn't silently drop them (custom , , etc.). + raw = etree.tostring(ch, encoding="unicode", with_tail=False) + raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + props["_unknownChildren"].append(raw) return props @@ -751,6 +768,19 @@ def build_mltext_xml(tag, text, indent): return "\n".join(lines) +def patch_mltext_ru(raw_outer_xml, new_ru_text, indent): + """Patch the ru within an existing multi-lang title OuterXml, + preserving en/uk/etc. siblings. Mirrors PS Patch-MLTextRu.""" + escaped = esc_xml(new_ru_text) + ru_item_pat = r"(\s*ru\s*)[^<]*(\s*)" + if re.search(ru_item_pat, raw_outer_xml): + return re.sub(ru_item_pat, lambda m: m.group(1) + escaped + m.group(2), raw_outer_xml) + prep = f"{indent}\t\n{indent}\t\tru\n{indent}\t\t{escaped}\n{indent}\t" + if "" in raw_outer_xml: + return re.sub(r"(\s*)", lambda m: "\n" + prep + m.group(1) + "", raw_outer_xml, count=1) + return re.sub(r"(<(?:\w+:)?title[^>]*>)", lambda m: m.group(1) + "\n" + prep + "\n" + indent, raw_outer_xml, count=1) + + def build_role_xml(roles, indent): if not roles: return "" @@ -784,7 +814,15 @@ def build_field_fragment(parsed, indent): lines.append(f"{i}\t{esc_xml(parsed['dataPath'])}") lines.append(f"{i}\t{esc_xml(parsed['field'])}") - if parsed.get("title"): + # Title: prefer raw multi-lang OuterXml (preserves en/uk/etc.). When shorthand + # provides a new ru text different from existing, patch the ru content. Otherwise + # emit raw as-is or build ru-only from shorthand if there was no prior title. + if parsed.get("_rawTitle"): + if parsed.get("title") and parsed["title"] != parsed.get("_existingTitleRu"): + lines.append(f"{i}\t" + patch_mltext_ru(parsed["_rawTitle"], parsed["title"], f"{i}\t")) + else: + lines.append(f"{i}\t" + parsed["_rawTitle"]) + elif parsed.get("title"): lines.append(build_mltext_xml("title", parsed["title"], f"{i}\t")) if parsed.get("restrict"): @@ -803,6 +841,10 @@ def build_field_fragment(parsed, indent): lines.append(build_value_type_xml(parsed["type"], f"{i}\t\t")) lines.append(f"{i}\t") + # Defense in depth: re-emit OuterXml of unknown children captured by Read. + for raw in (parsed.get("_unknownChildren") or []): + lines.append(f"{i}\t" + raw) + lines.append(f"{i}") return "\n".join(lines) @@ -1720,11 +1762,19 @@ elif operation == "modify-parameter": # Set/replace title (must come right after , before ) if title_val is not None: existing_title = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "title"), None) + # If existing title is multi-lang (has >1 ), patch ru content + # while preserving other languages. Otherwise rebuild as ru-only. + title_frag = None if existing_title is not None: + raw_title = etree.tostring(existing_title, encoding="unicode", with_tail=False) + raw_title = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw_title) + if raw_title.count("") > 1: + title_frag = child_indent + patch_mltext_ru(raw_title, title_val, child_indent) remove_node_with_whitespace(existing_title) + if title_frag is None: + title_frag = build_mltext_xml("title", title_val, child_indent) # Insert before the first child after title_ref = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) != "name"), None) - title_frag = build_mltext_xml("title", title_val, child_indent) for node in import_fragment(xml_doc, title_frag): insert_before_element(param_el, node, title_ref, child_indent) dirty = True; print(f'[OK] Parameter "{param_name}": title set to "{title_val}"') @@ -2548,6 +2598,11 @@ elif operation == "modify-field": "restrict": parsed["restrict"] if parsed.get("restrict") else existing["restrict"], # Preserve raw only when user did NOT override type via shorthand. "rawValueType": None if parsed.get("type") else existing.get("_rawValueType"), + # Preserve raw multi-lang title; pass existing ru content for change detection. + "_rawTitle": existing.get("_rawTitle"), + "_existingTitleRu": existing.get("title"), + # Pass-through unknown children (e.g. , , custom extensions). + "_unknownChildren": existing.get("_unknownChildren"), } # Find next element sibling for position @@ -2600,9 +2655,25 @@ elif operation == "set-field-role": field_indent = get_child_indent(field_el) - # Remove existing + # Remove existing — but first capture OuterXml of sub-children that the + # rebuild won't re-emit (custom , , etc.). old_role = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) == "role" and etree.QName(ch.tag).namespace == SCH_NS), None) + known_role_children = {"periodNumber", "periodType", "dimension", "ignoreNullsInGroups", + "balance", "account", "accountTypeExpression", "additionType", "addition"} + kv_keys = {k for k, _ in kv} + preserved_role_children = [] if old_role is not None: + for gc in old_role: + if not isinstance(gc.tag, str): + continue + ln = local_name(gc) + if ln in known_role_children: + continue + if ln in kv_keys: + continue + raw = etree.tostring(gc, encoding="unicode", with_tail=False) + raw = re.sub(r' xmlns(?::\w+)?="[^"]*"', "", raw) + preserved_role_children.append(raw) remove_node_with_whitespace(old_role) # Empty spec — remove only @@ -2620,6 +2691,8 @@ elif operation == "set-field-role": lines.append(f"{field_indent}\ttrue") for k, v in kv: lines.append(f"{field_indent}\t{esc_xml(v)}") + for raw in preserved_role_children: + lines.append(f"{field_indent}\t" + raw) lines.append(f"{field_indent}") frag_xml = "\n".join(lines) diff --git a/tests/skills/cases/skd-edit/fixtures/multilang-base/Template.xml b/tests/skills/cases/skd-edit/fixtures/multilang-base/Template.xml new file mode 100644 index 00000000..bcf4633d --- /dev/null +++ b/tests/skills/cases/skd-edit/fixtures/multilang-base/Template.xml @@ -0,0 +1,87 @@ + + + + ИсточникДанных1 + Local + + + DS + + Контрагент + Контрагент + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Контрагент</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Counterparty</v8:content> + </v8:item> + + + true + + + xs:string + + 100 + Variable + + + + + ru + L=ru_RU + + + + ИсточникДанных1 + ВЫБРАТЬ 1 КАК Контрагент + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Period</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/preserve-multilang-modify-field.json b/tests/skills/cases/skd-edit/preserve-multilang-modify-field.json new file mode 100644 index 00000000..6bd464e3 --- /dev/null +++ b/tests/skills/cases/skd-edit/preserve-multilang-modify-field.json @@ -0,0 +1,10 @@ +{ + "name": "modify-field сохраняет en/uk title (multi-lang) при ru-override", + "setup": "fixture:multilang-base", + "params": { + "templatePath": "Template.xml", + "operation": "modify-field", + "value": "Контрагент [Имя контрагента]" + }, + "idempotent": true +} diff --git a/tests/skills/cases/skd-edit/preserve-multilang-modify-parameter.json b/tests/skills/cases/skd-edit/preserve-multilang-modify-parameter.json new file mode 100644 index 00000000..672cbb92 --- /dev/null +++ b/tests/skills/cases/skd-edit/preserve-multilang-modify-parameter.json @@ -0,0 +1,10 @@ +{ + "name": "modify-parameter сохраняет en title (multi-lang) при ru-override", + "setup": "fixture:multilang-base", + "params": { + "templatePath": "Template.xml", + "operation": "modify-parameter", + "value": "Период [Новый период]" + }, + "idempotent": true +} diff --git a/tests/skills/cases/skd-edit/preserve-unknown-children-modify-field.json b/tests/skills/cases/skd-edit/preserve-unknown-children-modify-field.json new file mode 100644 index 00000000..62a86b55 --- /dev/null +++ b/tests/skills/cases/skd-edit/preserve-unknown-children-modify-field.json @@ -0,0 +1,10 @@ +{ + "name": "modify-field сохраняет неизвестные дочерние элементы (editFormat, appearance и т.п.)", + "setup": "fixture:multilang-base", + "params": { + "templatePath": "Template.xml", + "operation": "modify-field", + "value": "Контрагент @ignoreNullsInGroups" + }, + "idempotent": true +} diff --git a/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-field/Template.xml new file mode 100644 index 00000000..014403b1 --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-field/Template.xml @@ -0,0 +1,87 @@ + + + + ИсточникДанных1 + Local + + + DS + + Контрагент + Контрагент + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Имя контрагента</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Counterparty</v8:content> + </v8:item> + + + true + + + xs:string + + 100 + Variable + + + + + ru + L=ru_RU + + + + ИсточникДанных1 + ВЫБРАТЬ 1 КАК Контрагент + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Period</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-parameter/Template.xml b/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-parameter/Template.xml new file mode 100644 index 00000000..e1444c18 --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/preserve-multilang-modify-parameter/Template.xml @@ -0,0 +1,87 @@ + + + + ИсточникДанных1 + Local + + + DS + + Контрагент + Контрагент + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Контрагент</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Counterparty</v8:content> + </v8:item> + + + true + + + xs:string + + 100 + Variable + + + + + ru + L=ru_RU + + + + ИсточникДанных1 + ВЫБРАТЬ 1 КАК Контрагент + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Новый период</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Period</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + + diff --git a/tests/skills/cases/skd-edit/snapshots/preserve-unknown-children-modify-field/Template.xml b/tests/skills/cases/skd-edit/snapshots/preserve-unknown-children-modify-field/Template.xml new file mode 100644 index 00000000..55194c4c --- /dev/null +++ b/tests/skills/cases/skd-edit/snapshots/preserve-unknown-children-modify-field/Template.xml @@ -0,0 +1,87 @@ + + + + ИсточникДанных1 + Local + + + DS + + Контрагент + Контрагент + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Контрагент</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Counterparty</v8:content> + </v8:item> + + + true + + + xs:string + + 100 + Variable + + + + + ru + L=ru_RU + + + + ИсточникДанных1 + ВЫБРАТЬ 1 КАК Контрагент + + + Период + + <v8:item> + <v8:lang>ru</v8:lang> + <v8:content>Период</v8:content> + </v8:item> + <v8:item> + <v8:lang>en</v8:lang> + <v8:content>Period</v8:content> + </v8:item> + + + xs:dateTime + + DateTime + + + + + Основной + + + ru + Основной + + + + + + + + + + + + + +