diff --git a/.claude/skills/form-remove/SKILL.md b/.claude/skills/form-remove/SKILL.md index f35c272c2..b56092456 100644 --- a/.claude/skills/form-remove/SKILL.md +++ b/.claude/skills/form-remove/SKILL.md @@ -27,11 +27,12 @@ allowed-tools: | ObjectName | да | — | Имя объекта | | FormName | да | — | Имя формы для удаления | | SrcDir | нет | `src` | Каталог исходников | +| Force | нет | — | Удалить, даже если на форму ссылаются, и очистить ссылки | ## Команда ```powershell -powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "" -FormName "" [-SrcDir ""] +powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "" -FormName "" [-SrcDir ""] [-Force] ``` ## Что удаляется @@ -44,4 +45,4 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -O ## Что модифицируется - `/.xml` — убирается `
` из `ChildObjects` -- Если удаляемая форма была DefaultForm — очищается значение DefaultForm +- Свойства объекта, указывавшие на удалённую форму — очищаются diff --git a/.claude/skills/form-remove/scripts/remove-form.ps1 b/.claude/skills/form-remove/scripts/remove-form.ps1 index 20b9113ba..5de4b6dfe 100644 --- a/.claude/skills/form-remove/scripts/remove-form.ps1 +++ b/.claude/skills/form-remove/scripts/remove-form.ps1 @@ -1,4 +1,4 @@ -# form-remove v1.9 — Remove form from 1C object +# form-remove v1.10 — Remove form from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [Parameter(Mandatory)] @@ -8,7 +8,9 @@ param( [Parameter(Mandatory)] [string]$FormName, - [string]$SrcDir = "src" + [string]$SrcDir = "src", + + [switch]$Force ) $ErrorActionPreference = "Stop" @@ -33,6 +35,180 @@ if (-not (Test-Path $formMetaPath)) { exit 1 } +# --- Загрузка корневого XML: вид и имя объекта --- + +$rootXmlFull = Resolve-Path $rootXmlPath +$xmlDoc = New-Object System.Xml.XmlDocument +$xmlDoc.PreserveWhitespace = $true +$xmlDoc.Load($rootXmlFull.Path) + +$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable) +$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") + +$typeNode = $null +foreach ($c in $xmlDoc.DocumentElement.ChildNodes) { + if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) { $typeNode = $c; break } +} +if (-not $typeNode) { + Write-Error "Не удалось определить вид объекта в $rootXmlPath" + exit 1 +} +$mdType = $typeNode.LocalName +$nameNode = $typeNode.SelectSingleNode("md:Properties/md:Name", $nsMgr) +$objMetaName = if ($nameNode -and $nameNode.InnerText.Trim()) { $nameNode.InnerText.Trim() } else { [System.IO.Path]::GetFileNameWithoutExtension($rootXmlPath) } + +# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при +# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка. +$formRef = "$mdType.$objMetaName.Form.$FormName" + +# --- Чистка ссылок и сохранение в стиле файла-источника --- + +# Каноничное «не задано» зависит от файла: в корневом XML объекта и в Configuration.xml +# пустой слот штатен (164 508 пустых на корпус), а внутри Ext/Form.xml пустых +# и нет ни одного — там свойство просто отсутствует. +function Clear-FormRefs { + param([System.Xml.XmlDocument]$doc, [string]$ref) + + $isFormFile = $doc.DocumentElement -and $doc.DocumentElement.LocalName -eq "Form" + $touched = @() + foreach ($node in @($doc.SelectNodes("//*"))) { + if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue } + if ($node.SelectNodes("*").Count -gt 0) { continue } # только листья + # Сравнение регистронезависимое — как у платформы (в py-порту .lower()). + if ($node.InnerText.Trim() -ne $ref) { continue } + + $ln = $node.LocalName + $parent = $node.ParentNode + if ($ln -eq "Form" -and $parent -and $parent.LocalName -eq "Item") { + $touched += "$($parent.LocalName)/$ln" + Remove-NodeWithIndent $parent + } elseif ($isFormFile) { + $touched += $ln + Remove-NodeWithIndent $node + } else { + # IsEmpty, а не InnerText="": пустая строка сериализуется парой , а + # Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен . + $touched += $ln + $node.IsEmpty = $true + } + } + return $touched +} + +function Remove-NodeWithIndent { + param([System.Xml.XmlNode]$node) + $parent = $node.ParentNode + if (-not $parent) { return } + $prev = $node.PreviousSibling + if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) { + $parent.RemoveChild($prev) | Out-Null + } + $parent.RemoveChild($node) | Out-Null + # Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару + # \n\t\t. Платформа пишет только + # (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0). + if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true } +} + +function Save-XmlPreservingStyle { + param([System.Xml.XmlDocument]$doc, [string]$path) + + $encBom = New-Object System.Text.UTF8Encoding($true) + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Encoding = $encBom + $settings.Indent = $false + $settings.NewLineHandling = [System.Xml.NewLineHandling]::None + + # Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки. + $memStream = New-Object System.IO.MemoryStream + $writer = [System.Xml.XmlWriter]::Create($memStream, $settings) + $doc.Save($writer) + $writer.Flush(); $writer.Close() + + $xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray()) + $memStream.Close() + if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) } + $xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"') + # Пустой элемент: XmlWriter отдаёт ``, Конфигуратор пишет ``. Внутри + # CDATA/комментария ` />` может быть содержимым (там `>` не экранируется), + # поэтому они идут первыми ветками альтернации и возвращаются как есть. + $xmlText = [regex]::Replace($xmlText, '(?s)||(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } }) + # Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47), + # новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту. + $targetEol = if ((Test-Path -LiteralPath $path) -and ([System.IO.File]::ReadAllText($path) -notmatch "`r`n")) { "`n" } else { "`r`n" } + $xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol + [System.IO.File]::WriteAllText($path, $xmlText, $encBom) +} + +# --- Поиск ссылок на форму по всей конфигурации --- + +# Get-Item, а не Resolve-Path: последний оставляет короткое имя 8.3 (NSHIRO~1), а +# Get-ChildItem отдаёт длинное (nshirokov) — сравнение путей молча не совпадало. +function Get-LongPath { + param([string]$path) + if (-not (Test-Path -LiteralPath $path)) { return "" } + return (Get-Item -LiteralPath $path -Force).FullName +} + +# Корень конфигурации: обычно это сам SrcDir, но объект могут передать и из глубины. +$configDir = $null +$probe = Get-LongPath $SrcDir +for ($depth = 0; $depth -lt 4; $depth++) { + if (-not $probe) { break } + if (Test-Path (Join-Path $probe "Configuration.xml")) { $configDir = $probe; break } + $probe = Split-Path $probe +} + +$rootXmlLong = Get-LongPath $rootXmlFull.Path +$formMetaFull = Get-LongPath $formMetaPath +$formDirFull = Get-LongPath $formDir + +$references = @() +if ($configDir) { + # Полный обход, как в meta-remove: ссылки лежат и внутри Ext/Form.xml (ChoiceForm, + # SettingsStorage), узкий скан по корневым XML их не видит. + # EnumerateFiles, а не Get-ChildItem -Recurse: на ERP (73 904 XML) обход обёртками + # занимает 180 с против 47 с — чтение файлов не узкое место, узкое место перечисление. + $refPattern = '<([A-Za-z0-9_.]+)>' + [regex]::Escape($formRef) + '$suffix" + } + Write-Host "" + if (-not $Force) { + Write-Host "[ERROR] Удаление остановлено: форма используется." + Write-Host " Решает пользователь: убрать ссылки, отказаться от удаления или" + Write-Host " повторить с -Force — тогда ссылки будут очищены." + exit 1 + } + Write-Host "[WARN] -Force: ссылки будут очищены" + Write-Host "" +} elseif (-not $configDir) { + Write-Host "[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены" +} + # --- Удаление файлов --- if (Test-Path $formDir) { @@ -45,70 +221,35 @@ Write-Host "[OK] Удалён файл: $formMetaPath" # --- Модификация корневого XML --- -$rootXmlFull = Resolve-Path $rootXmlPath -$xmlDoc = New-Object System.Xml.XmlDocument -$xmlDoc.PreserveWhitespace = $true -$xmlDoc.Load($rootXmlFull.Path) - -$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable) -$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses") - # Удалить FormName из ChildObjects $formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr) foreach ($node in $formNodes) { if ($node.InnerText -eq $FormName) { - $parent = $node.ParentNode - # Удалить предшествующий whitespace - $prev = $node.PreviousSibling - if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) { - $parent.RemoveChild($prev) | Out-Null - } - $parent.RemoveChild($node) | Out-Null - # Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару - # \n\t\t. Платформа пишет только - # (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0). - if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true } + Remove-NodeWithIndent $node break } } -# Очистить любые Default*/Auxiliary* form-слоты, указывавшие на удалённую форму -# (form-add пишет свойство по назначению: DefaultObjectForm/DefaultListForm/ -# DefaultChoiceForm/DefaultRecordForm/DefaultForm — не только generic DefaultForm). -$formRefRe = "Form\.$([regex]::Escape($FormName))$" -foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) { - if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) { - # IsEmpty, а не InnerText="": пустая строка сериализуется парой , а - # Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен . - $node.IsEmpty = $true - } -} +# Очистить слоты своего объекта, указывавшие на удалённую форму: Default*/Auxiliary*Form +# (form-add пишет свойство по назначению) и ChoiceForm у реквизитов. +Clear-FormRefs $xmlDoc $formRef | Out-Null -# Сохранить с BOM -$encBom = New-Object System.Text.UTF8Encoding($true) -$settings = New-Object System.Xml.XmlWriterSettings -$settings.Encoding = $encBom -$settings.Indent = $false -$settings.NewLineHandling = [System.Xml.NewLineHandling]::None - -# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки. -$memStream = New-Object System.IO.MemoryStream -$writer = [System.Xml.XmlWriter]::Create($memStream, $settings) -$xmlDoc.Save($writer) -$writer.Flush(); $writer.Close() - -$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray()) -$memStream.Close() -if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) } -$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"') -# Пустой элемент: XmlWriter отдаёт ``, Конфигуратор пишет ``. Внутри -# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется), -# поэтому они идут первыми ветками альтернации и возвращаются как есть. -$xmlText = [regex]::Replace($xmlText, '(?s)||(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } }) -# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47), -# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту. -$targetEol = if ((Test-Path -LiteralPath $rootXmlFull.Path) -and ([System.IO.File]::ReadAllText($rootXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" } -$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol -[System.IO.File]::WriteAllText($rootXmlFull.Path, $xmlText, $encBom) +Save-XmlPreservingStyle $xmlDoc $rootXmlFull.Path Write-Host "[OK] Форма $FormName удалена из $rootXmlPath" + +# --- Чистка ссылок в других файлах (только с -Force) --- + +if ($references.Count -gt 0) { + foreach ($grp in ($references | Group-Object { $_.Path } | Sort-Object Name)) { + $path = $grp.Name + $doc = New-Object System.Xml.XmlDocument + $doc.PreserveWhitespace = $true + $doc.Load($path) + $touched = @(Clear-FormRefs $doc $formRef) + if ($touched.Count -eq 0) { continue } + Save-XmlPreservingStyle $doc $path + $rel = $path.Substring($configDir.Length + 1) + Write-Host "[OK] Очищена ссылка в $rel — $(($touched | Sort-Object -Unique) -join ', ')" + } +} diff --git a/.claude/skills/form-remove/scripts/remove-form.py b/.claude/skills/form-remove/scripts/remove-form.py index 4d171fdb8..e8030fac6 100644 --- a/.claude/skills/form-remove/scripts/remove-form.py +++ b/.claude/skills/form-remove/scripts/remove-form.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# form-remove v1.9 — Remove form from 1C object +# form-remove v1.10 — Remove form from 1C object # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse @@ -83,6 +83,69 @@ def save_xml_with_bom(tree, path): f.write(xml_bytes) +def long_path(path): + """Полный путь в длинной форме. Зеркало Get-LongPath в PS: там Resolve-Path оставляет + короткое имя 8.3 (NSHIRO~1), а перечисление отдаёт длинное — сравнение молча не совпадало.""" + if not os.path.exists(path): + return "" + return os.path.realpath(path) + + +def remove_node_with_indent(node): + """Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать + самозакрывающимся. Зеркало Remove-NodeWithIndent в PS.""" + parent = node.getparent() + if parent is None: + return + # В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а + # whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с + # узлом, поэтому его надо передать предшественнику — иначе ``. + prev = node.getprevious() + if prev is not None: + prev.tail = node.tail + else: + parent.text = node.tail + parent.remove(node) + # Опустевший контейнер: text="" сериализуется парой , + # а нужен — PS-порт через DOM даёт именно его. + if len(parent) == 0 and not (parent.text or "").strip(): + parent.text = None + + +def clear_form_refs(tree, ref): + """Очистить ссылки на форму. Каноничное «не задано» зависит от файла: в корневом XML + объекта и в Configuration.xml пустой слот штатен (164 508 пустых на корпус), а внутри + Ext/Form.xml пустых и нет ни одного — там свойство + просто отсутствует. Зеркало Clear-FormRefs в PS.""" + root = tree.getroot() + is_form_file = etree.QName(root).localname == "Form" + touched = [] + ref_lc = ref.lower() + for el in list(root.iter()): + if not isinstance(el.tag, str): + continue + if len(el) > 0: # только листья + continue + # Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает). + if (el.text or "").strip().lower() != ref_lc: + continue + + ln = etree.QName(el).localname + parent = el.getparent() + if ln == "Form" and parent is not None and etree.QName(parent).localname == "Item": + touched.append(f"{etree.QName(parent).localname}/{ln}") + remove_node_with_indent(parent) + elif is_form_file: + touched.append(ln) + remove_node_with_indent(el) + else: + # text=None, а не "": пустая строка сериализуется парой , а + # Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен . + touched.append(ln) + el.text = None + return touched + + def main(): sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") @@ -90,11 +153,13 @@ def main(): parser.add_argument("-ObjectName", "-ProcessorName", required=True) parser.add_argument("-FormName", required=True) parser.add_argument("-SrcDir", default="src") + parser.add_argument("-Force", action="store_true") args = ci_parse_args(parser) object_name = args.ObjectName form_name = args.FormName src_dir = args.SrcDir + force = args.Force # --- Checks --- @@ -112,6 +177,91 @@ def main(): print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr) sys.exit(1) + # --- Load root XML: kind and object name --- + + root_xml_full = long_path(root_xml_path) or os.path.abspath(root_xml_path) + parser_xml = etree.XMLParser(remove_blank_text=False) + tree = etree.parse(root_xml_full, parser_xml) + root = tree.getroot() + + type_node = None + for c in root: + if isinstance(c.tag, str): + type_node = c + break + if type_node is None: + print(f"Не удалось определить вид объекта в {root_xml_path}", file=sys.stderr) + sys.exit(1) + md_type = etree.QName(type_node).localname + name_node = type_node.find("md:Properties/md:Name", NSMAP) + obj_meta_name = (name_node.text or "").strip() if name_node is not None else "" + if not obj_meta_name: + obj_meta_name = os.path.splitext(os.path.basename(root_xml_path))[0] + + # Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при + # удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка. + form_ref = f"{md_type}.{obj_meta_name}.Form.{form_name}" + + # --- Find references across the configuration --- + + config_dir = None + probe = long_path(src_dir) or os.path.abspath(src_dir) + for _ in range(4): + if not probe: + break + if os.path.exists(os.path.join(probe, "Configuration.xml")): + config_dir = probe + break + parent_probe = os.path.dirname(probe) + if parent_probe == probe: + break + probe = parent_probe + + form_meta_full = long_path(form_meta_path) + form_dir_full = long_path(form_dir) + + references = [] + if config_dir: + ref_pattern = re.compile(r"<([A-Za-z0-9_.]+)>" + re.escape(form_ref) + r" 1 else "" + print(f" {rel} — <{tag}>{suffix}") + print() + if not force: + print("[ERROR] Удаление остановлено: форма используется.") + print(" Решает пользователь: убрать ссылки, отказаться от удаления или") + print(" повторить с -Force — тогда ссылки будут очищены.") + sys.exit(1) + print("[WARN] -Force: ссылки будут очищены") + print() + elif not config_dir: + print("[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены") + # --- Delete files --- if os.path.isdir(form_dir): @@ -123,48 +273,31 @@ def main(): # --- Modify root XML --- - root_xml_full = os.path.abspath(root_xml_path) - parser_xml = etree.XMLParser(remove_blank_text=False) - tree = etree.parse(root_xml_full, parser_xml) - root = tree.getroot() - # Remove
FormName
from ChildObjects for node in root.findall(".//md:ChildObjects/md:Form", NSMAP): if node.text and node.text.strip() == form_name: - parent = node.getparent() - prev = node.getprevious() - if prev is not None: - # Whitespace is in prev.tail - if prev.tail and prev.tail.strip() == "": - prev.tail = "" - else: - # First child — whitespace is in parent.text - if parent.text and parent.text.strip() == "": - parent.text = "" - parent.remove(node) - # Опустевший контейнер: text="" сериализуется парой , - # а нужен — PS-порт через DOM даёт именно его. - if len(parent) == 0 and not (parent.text or "").strip(): - parent.text = None + remove_node_with_indent(node) break - # Clear any Default*/Auxiliary* form slot that pointed to the removed form - # (form-add writes the purpose-specific property: DefaultObjectForm / DefaultListForm / - # DefaultChoiceForm / DefaultRecordForm / DefaultForm — not just generic DefaultForm). - ref_re = re.compile(rf"Form\.{re.escape(form_name)}$") - for el in root.iter(): - if not isinstance(el.tag, str): - continue - if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text): - # text=None, а не "": пустая строка сериализуется парой , а - # Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен . - el.text = None + # Очистить слоты своего объекта: Default*/Auxiliary*Form и ChoiceForm у реквизитов. + clear_form_refs(tree, form_ref) # Save with BOM save_xml_with_bom(tree, root_xml_full) print(f"[OK] Форма {form_name} удалена из {root_xml_path}") + # --- Clean references in other files (only with -Force) --- + + for fp in sorted({r["path"] for r in references}): + other_tree = etree.parse(fp, parser_xml) + touched = clear_form_refs(other_tree, form_ref) + if not touched: + continue + save_xml_with_bom(other_tree, fp) + rel = os.path.relpath(fp, config_dir) + print(f"[OK] Очищена ссылка в {rel} — {', '.join(sorted(set(touched)))}") + if __name__ == "__main__": main()