mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-02 00:10:50 +03:00
feat(skills): порядок объектов метаданных в ChildObjects
Навыки-создатели дописывали новый объект в конец группы своего вида, а стандарт требует порядка по имени (АПК:1108). Замеры на 8 боевых выгрузках: 103 385 объектов лежат по алфавиту, нарушают его ровно дописки в хвост. Стенд подтвердил, что беспорядок вечен: платформа нормализует порядок ВИДОВ (возвращает канонический), но порядок имён внутри вида не трогает. Настройка newObjectPosition в .v8-project.json (end по умолчанию | byName) — решение проекта, а не вызова: читают её meta-compile, role-compile, xdto-compile, cfe-borrow и cf-edit add-childObject. Компаратор при этом константа, моделирующая дерево Конфигуратора: ключ «ранг+символ» без культурных таблиц, одинаковый в обоих портах на любой ОС. На корпусе он даёт 4 нарушения на 125 088 пар против 2866 у ordinal-сравнения, которым cf-edit и cfe-borrow сортировали до сих пор. Subsystem не упорядочивается автоматически нигде: пока подсистемы не перечислены в <SubsystemsOrder>, порядок дерева задаёт порядок разделов в панели, а платформа этот список сама не заводит. Настройка не чинит накопленное, поэтому cf-edit получил операцию sort-childObjects (вся конфигурация или названные виды). Она переставляет значения узлов, а не узлы, поэтому диф — чистая перестановка строк. Имя вида принимается в любом регистре, во множественном числе и по-русски; неизвестный вид — отказ со списком допустимых. Попутно: - PS-порты создателей переведены с DOM-сериализации на текстовую вставку: на выгрузке не в каноне Конфигуратора они переписывали заголовок без просьбы; - PS-сторона семьи detect_xml_style/finalize_xml_bytes закрыта в cf-edit и cfe-borrow — правка чужого файла наследует его BOM/EOL/заголовок; - xdto-compile присоединён к семье Register-InChildObjects вместо инлайн-копии; - закрыт ложный успех в py subsystem-compile: <ChildObjects /> с пробелом ET разбирает, а текстовые ветки не находили — файл писался без вставки; - cfe-borrow py не импортировал json, из-за чего резолвер молча возвращал end; - cf-edit add-childObject ставил объект в конец блока, за пределы своей группы, когда видов старше в файле не было. Проверки: 906/906 на PowerShell и 903/906 на Python, 10 гардов, верификация эталонов платформой без падений, раундтрип отсортированной конфигурации на 8.3.24 и 8.3.27, сортировка боевой выгрузки ACC (1,4 МБ) и расширения из корпуса. Задачу принёс PR #85; часть кода взята оттуда. Co-Authored-By: Sergei Pleshanov <72200277+Abacadabras@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoAJmoNbgWKobA7JGgN5S3
This commit is contained in:
co-authored by
Sergei Pleshanov
Claude Opus 5
parent
7409bacd47
commit
c7b0dce151
@@ -1,10 +1,10 @@
|
||||
# cf-edit v1.23 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.24 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||
[string]$DefinitionFile,
|
||||
[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page")]
|
||||
[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page","sort-childObjects")]
|
||||
[string]$Operation,
|
||||
[string]$Value,
|
||||
[switch]$NoValidate
|
||||
@@ -376,6 +376,21 @@ function Import-Fragment([string]$xmlString) {
|
||||
}
|
||||
|
||||
# --- Parse batch value (split by ;;) ---
|
||||
|
||||
# Имя вида из пользовательского ввода → каноническое имя или $null.
|
||||
# Ввод прощающий: регистр не важен, принимается имя каталога выгрузки (Catalogs → Catalog)
|
||||
# и русское имя вида в единственном и множественном числе.
|
||||
function Resolve-TypeName([string]$token) {
|
||||
$key = "$token".Trim()
|
||||
if (-not $key) { return $null }
|
||||
foreach ($canon in $script:typeOrder) { if ($canon -eq $key) { return $canon } }
|
||||
$byDir = $script:dirToType[$key.ToLowerInvariant()]
|
||||
if ($byDir) { return $byDir }
|
||||
$ru = $script:ruTypeMap[$key.ToLowerInvariant()]
|
||||
if ($ru) { return $ru }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Parse-BatchValue([string]$val) {
|
||||
$items = @()
|
||||
foreach ($part in $val.Split(";;")) {
|
||||
@@ -441,6 +456,169 @@ function Do-ModifyProperty([string]$batchVal) {
|
||||
}
|
||||
|
||||
# --- Operation: add-childObject ---
|
||||
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
|
||||
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
|
||||
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
|
||||
# Файл ищем от каталога конфигурации и лишь потом от cwd — в отличие от support-guard:
|
||||
# настройка принадлежит выгрузке, а рабочим каталогом при вызове навыка почти всегда
|
||||
# оказывается чужой проект со своим .v8-project.json, и он перекрыл бы нужный.
|
||||
# configSrc считается от каталога .v8-project.json, как задокументировано в
|
||||
# docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
function Get-NewObjectPosition([string]$cfgDir) {
|
||||
try {
|
||||
if (-not $cfgDir) { $cfgDir = "." }
|
||||
$pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir))
|
||||
if (-not $pj) { $pj = Find-V8Project (Get-Location).Path }
|
||||
if (-not $pj) { return "end" }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$projDir = [System.IO.Path]::GetDirectoryName($pj)
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc -and $db.newObjectPosition) {
|
||||
$src = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($projDir, $db.configSrc)).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ("$($db.newObjectPosition)" -eq "byName") { return "byName" }
|
||||
return "end"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ("$($proj.newObjectPosition)" -eq "byName") { return "byName" }
|
||||
return "end"
|
||||
} catch { return "end" }
|
||||
}
|
||||
|
||||
# Порядок имён объектов метаданных, как в дереве Конфигуратора.
|
||||
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
|
||||
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
|
||||
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
|
||||
# одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
|
||||
# Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
function Compare-MetadataNames([string]$a, [string]$b) {
|
||||
$keys = @("", "")
|
||||
$names = @($a, $b)
|
||||
for ($i = 0; $i -lt 2; $i++) {
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
foreach ($ch in $names[$i].ToLowerInvariant().ToCharArray()) {
|
||||
if ($ch -eq [char]0x0451) { $ch = [char]0x0435 }
|
||||
if ([char]::IsDigit($ch)) { [void]$sb.Append('1') }
|
||||
elseif ([char]::IsLetter($ch)) { [void]$sb.Append('2') }
|
||||
else { [void]$sb.Append('0') }
|
||||
[void]$sb.Append($ch)
|
||||
}
|
||||
$keys[$i] = $sb.ToString()
|
||||
}
|
||||
$r = [string]::CompareOrdinal($keys[0], $keys[1])
|
||||
if ($r -eq 0) { $r = [string]::CompareOrdinal($a, $b) }
|
||||
if ($r -lt 0) { return -1 }
|
||||
if ($r -gt 0) { return 1 }
|
||||
return 0
|
||||
}
|
||||
|
||||
# Сортировка имён компаратором Compare-MetadataNames. В py-порту ту же роль играет
|
||||
# functools.cmp_to_key — штатный способ отсортировать компаратором; в PS 5.1 его нет,
|
||||
# поэтому слияние вручную. Порядок обоих портов задаёт один и тот же компаратор.
|
||||
function Sort-MetadataNames([string[]]$names) {
|
||||
# Возврат без запятой-обёртки: приёмная сторона всегда пишет @(...), и одноэлементный
|
||||
# результат остаётся массивом. С `return ,@(...)` @() собрал бы ОДИН объект-массив.
|
||||
if ($names.Count -le 1) { return $names }
|
||||
$mid = [int]($names.Count / 2)
|
||||
$left = @(Sort-MetadataNames $names[0..($mid - 1)])
|
||||
$right = @(Sort-MetadataNames $names[$mid..($names.Count - 1)])
|
||||
$out = New-Object System.Collections.ArrayList
|
||||
$i = 0; $j = 0
|
||||
while ($i -lt $left.Count -and $j -lt $right.Count) {
|
||||
if ((Compare-MetadataNames $left[$i] $right[$j]) -le 0) { [void]$out.Add($left[$i]); $i++ }
|
||||
else { [void]$out.Add($right[$j]); $j++ }
|
||||
}
|
||||
while ($i -lt $left.Count) { [void]$out.Add($left[$i]); $i++ }
|
||||
while ($j -lt $right.Count) { [void]$out.Add($right[$j]); $j++ }
|
||||
return $out.ToArray()
|
||||
}
|
||||
|
||||
# Упорядочить <ChildObjects> по имени внутри вида.
|
||||
# Без значения — все виды, кроме Subsystem (порядок подсистем в дереве задаёт порядок
|
||||
# разделов в панели, пока их не перечислили в <SubsystemsOrder>); явно названный вид
|
||||
# сортируется в любом случае. Взаимный порядок видов не трогаем: платформа приводит его
|
||||
# к своему при первой же выгрузке. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы
|
||||
# и структура файла остаются как были, меняются только имена в строках.
|
||||
function Do-SortChildObjects([string]$batchVal) {
|
||||
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
|
||||
|
||||
# Ввод прощающий: регистр не важен, принимается и имя каталога (Catalogs → Catalog) —
|
||||
# в дереве выгрузки виды видны именно во множественном числе.
|
||||
# Без @(...) на приёме: Parse-BatchValue возвращает ,$items — обёртка, которую @()
|
||||
# собрал бы как ОДИН объект-массив, и вид не нашёлся бы в $script:typeOrder.
|
||||
$tokens = @()
|
||||
if ("$batchVal".Trim()) { $tokens = Parse-BatchValue $batchVal }
|
||||
$requested = @()
|
||||
foreach ($token in $tokens) {
|
||||
$canon = Resolve-TypeName $token
|
||||
if (-not $canon) { Write-Error "Unknown type '$token'. Valid: $($script:typeOrder -join ', ')"; exit 1 }
|
||||
$requested += $canon
|
||||
}
|
||||
|
||||
$groups = New-Object System.Collections.Specialized.OrderedDictionary
|
||||
foreach ($child in $script:childObjsEl.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
$ln = $child.get_LocalName()
|
||||
if (-not $groups.Contains($ln)) { $groups[$ln] = New-Object System.Collections.ArrayList }
|
||||
[void]$groups[$ln].Add($child)
|
||||
}
|
||||
|
||||
$targets = if ($requested.Count -gt 0) { $requested } else { @($groups.Keys | Where-Object { $_ -cne 'Subsystem' }) }
|
||||
foreach ($typeName in $targets) {
|
||||
if (-not $groups.Contains($typeName)) { continue }
|
||||
$els = $groups[$typeName]
|
||||
if ($els.Count -lt 2) { continue }
|
||||
$names = @(foreach ($e in $els) { $e.InnerText })
|
||||
$ordered = @(Sort-MetadataNames $names)
|
||||
$same = $true
|
||||
for ($i = 0; $i -lt $names.Count; $i++) { if ($names[$i] -cne $ordered[$i]) { $same = $false; break } }
|
||||
if ($same) { continue }
|
||||
for ($i = 0; $i -lt $els.Count; $i++) { $els[$i].InnerText = $ordered[$i] }
|
||||
$script:modifyCount++
|
||||
Info "Sorted: $typeName ($($els.Count))"
|
||||
}
|
||||
}
|
||||
|
||||
# Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
# финальный перенос. $null → файл новый (сохранить текущее поведение).
|
||||
# Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
function Detect-XmlStyle([string]$path) {
|
||||
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
||||
$raw = [System.IO.File]::ReadAllBytes($path)
|
||||
$bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
|
||||
$body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) }
|
||||
$head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body }
|
||||
$m = [regex]::Match($head, 'encoding="([^"]+)"')
|
||||
return @{
|
||||
bom = $bom
|
||||
crlf = $body.Contains("`r`n")
|
||||
enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" })
|
||||
finalNl = $body.EndsWith("`n")
|
||||
}
|
||||
}
|
||||
|
||||
# Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки
|
||||
# Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце.
|
||||
# Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
function Finalize-XmlText([string]$text, $style) {
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$encDecl = $(if ($style) { $style.enc } else { "UTF-8" })
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
$text = ($text -replace "`r`n", "`n").TrimEnd("`n")
|
||||
if ($style -and $style.finalNl) { $text += "`n" }
|
||||
if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" }
|
||||
return $text
|
||||
}
|
||||
|
||||
function Do-AddChildObject([string]$batchVal) {
|
||||
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
|
||||
|
||||
@@ -460,6 +638,8 @@ function Do-AddChildObject([string]$batchVal) {
|
||||
exit 1
|
||||
}
|
||||
$typeName = $item.Substring(0, $dotIdx)
|
||||
$canonType = Resolve-TypeName $typeName
|
||||
if ($canonType) { $typeName = $canonType }
|
||||
$objNameVal = $item.Substring($dotIdx + 1)
|
||||
|
||||
# Check type is valid
|
||||
@@ -504,11 +684,11 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
|
||||
continue
|
||||
}
|
||||
|
||||
# Find insertion point: after last element of same type, or after last element of preceding type
|
||||
# Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
|
||||
$byName = ($typeName -cne "Subsystem" -and (Get-NewObjectPosition $script:configDir) -eq "byName")
|
||||
$insertBefore = $null
|
||||
$lastSameType = $null
|
||||
$lastPrecedingType = $null
|
||||
$currentTypeIdx = -1
|
||||
$firstLaterType = $null
|
||||
|
||||
foreach ($child in $script:childObjsEl.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
@@ -516,17 +696,29 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
|
||||
if ($childTypeIdx -lt 0) { continue }
|
||||
|
||||
if ($child.LocalName -eq $typeName) {
|
||||
# Same type — check alphabetical order
|
||||
if ($child.InnerText -gt $objNameVal -and -not $insertBefore) {
|
||||
# Insert before this element (alphabetical)
|
||||
# Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
|
||||
# объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
|
||||
# никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
|
||||
$lastSameType = $child
|
||||
if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
|
||||
$insertBefore = $child
|
||||
}
|
||||
$lastSameType = $child
|
||||
} elseif ($childTypeIdx -lt $typeIdx) {
|
||||
$lastPrecedingType = $child
|
||||
} elseif ($childTypeIdx -gt $typeIdx -and -not $insertBefore) {
|
||||
# First element of a later type — insert before it
|
||||
$insertBefore = $child
|
||||
} elseif ($childTypeIdx -gt $typeIdx -and -not $firstLaterType) {
|
||||
$firstLaterType = $child
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $insertBefore) {
|
||||
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
|
||||
# то есть перед его следующим соседом. Через $firstLaterType этого не сделать:
|
||||
# если видов старше в файле нет, запись уехала бы в самый конец блока,
|
||||
# за пределы своей группы.
|
||||
if ($lastSameType) {
|
||||
$next = $lastSameType.NextSibling
|
||||
while ($next -and $next.NodeType -ne 'Element') { $next = $next.NextSibling }
|
||||
$insertBefore = $next
|
||||
} else {
|
||||
$insertBefore = $firstLaterType
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +750,8 @@ function Do-RemoveChildObject([string]$batchVal) {
|
||||
exit 1
|
||||
}
|
||||
$typeName = $item.Substring(0, $dotIdx)
|
||||
$canonType = Resolve-TypeName $typeName
|
||||
if ($canonType) { $typeName = $canonType }
|
||||
$objNameVal = $item.Substring($dotIdx + 1)
|
||||
|
||||
$found = $false
|
||||
@@ -787,6 +981,29 @@ $script:ruTypeMap = @{
|
||||
"бот" = "Bot"
|
||||
"планобмена" = "ExchangePlan"
|
||||
"хранилищенастроек" = "SettingsStorage"
|
||||
# Множественное число: в дереве конфигурации виды подписаны именно так.
|
||||
"справочники" = "Catalog"
|
||||
"документы" = "Document"
|
||||
"перечисления" = "Enum"
|
||||
"отчёты" = "Report"
|
||||
"отчеты" = "Report"
|
||||
"обработки" = "DataProcessor"
|
||||
"общиеформы" = "CommonForm"
|
||||
"журналыдокументов" = "DocumentJournal"
|
||||
"планывидовхарактеристик" = "ChartOfCharacteristicTypes"
|
||||
"планысчетов" = "ChartOfAccounts"
|
||||
"планывидоврасчета" = "ChartOfCalculationTypes"
|
||||
"планывидоврасчёта" = "ChartOfCalculationTypes"
|
||||
"регистрысведений" = "InformationRegister"
|
||||
"регистрынакопления" = "AccumulationRegister"
|
||||
"регистрыбухгалтерии" = "AccountingRegister"
|
||||
"регистрырасчета" = "CalculationRegister"
|
||||
"регистрырасчёта" = "CalculationRegister"
|
||||
"бизнеспроцессы" = "BusinessProcess"
|
||||
"задачи" = "Task"
|
||||
"боты" = "Bot"
|
||||
"планыобмена" = "ExchangePlan"
|
||||
"хранилищанастроек" = "SettingsStorage"
|
||||
}
|
||||
# plural folder → singular type
|
||||
$script:dirToType = @{}
|
||||
@@ -1028,11 +1245,16 @@ foreach ($op in $operations) {
|
||||
"set-defaultRoles" { Do-SetDefaultRoles $opValueStr }
|
||||
"set-panels" { Do-SetPanels $opValue }
|
||||
"set-home-page" { Do-SetHomePage $opValue }
|
||||
"sort-childObjects" { Do-SortChildObjects $opValueStr }
|
||||
default { Write-Error "Unknown operation: $opName"; exit 1 }
|
||||
}
|
||||
}
|
||||
|
||||
# --- Save ---
|
||||
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
|
||||
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
|
||||
$xmlStyle = Detect-XmlStyle $resolvedPath
|
||||
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||
$settings.Indent = $false
|
||||
@@ -1043,22 +1265,12 @@ $writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$script:xmlDoc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$bytes = $memStream.ToArray()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
$text = Finalize-XmlText $text $xmlStyle
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
$writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
|
||||
Info "Saved: $resolvedPath"
|
||||
|
||||
# --- Auto-validate ---
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-edit v1.23 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.24 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -353,6 +354,122 @@ SCALAR_PROPS = ["Name", "Version", "Vendor", "Comment", "NamePrefix", "UpdateCat
|
||||
REF_PROPS = ["DefaultLanguage"]
|
||||
|
||||
|
||||
def get_new_object_position(cfg_dir):
|
||||
"""Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||
|
||||
databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
|
||||
иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
|
||||
(так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
|
||||
Файл ищем от каталога конфигурации и лишь потом от cwd — в отличие от support-guard:
|
||||
настройка принадлежит выгрузке, а рабочим каталогом при вызове навыка почти всегда
|
||||
оказывается чужой проект со своим .v8-project.json, и он перекрыл бы нужный.
|
||||
configSrc считается от каталога .v8-project.json, как задокументировано в
|
||||
docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
"""
|
||||
try:
|
||||
pj = _sg_find_v8project(os.path.abspath(cfg_dir or ".")) or _sg_find_v8project(os.getcwd())
|
||||
if not pj:
|
||||
return "end"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
proj_dir = os.path.dirname(pj)
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir or ".")).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src and db.get("newObjectPosition"):
|
||||
src_full = os.path.normcase(os.path.abspath(os.path.join(proj_dir, src))).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
return "byName" if str(db["newObjectPosition"]).lower() == "byname" else "end"
|
||||
if str(proj.get("newObjectPosition") or "").lower() == "byname":
|
||||
return "byName"
|
||||
return "end"
|
||||
except Exception:
|
||||
return "end"
|
||||
|
||||
|
||||
def compare_metadata_names(a, b):
|
||||
"""Порядок имён объектов метаданных, как в дереве Конфигуратора.
|
||||
|
||||
Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
|
||||
букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
|
||||
используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
|
||||
одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
|
||||
Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
"""
|
||||
keys = []
|
||||
for name in (a, b):
|
||||
parts = []
|
||||
for ch in name.lower():
|
||||
if ch == "ё":
|
||||
ch = "е"
|
||||
if ch.isdigit():
|
||||
parts.append("1" + ch)
|
||||
elif ch.isalpha():
|
||||
parts.append("2" + ch)
|
||||
else:
|
||||
parts.append("0" + ch)
|
||||
keys.append("".join(parts))
|
||||
if keys[0] != keys[1]:
|
||||
return -1 if keys[0] < keys[1] else 1
|
||||
if a != b:
|
||||
return -1 if a < b else 1
|
||||
return 0
|
||||
|
||||
|
||||
RU_TYPE_MAP = {
|
||||
"справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
|
||||
"отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
|
||||
"общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
|
||||
"планвидовхарактеристик": "ChartOfCharacteristicTypes",
|
||||
"плансчетов": "ChartOfAccounts",
|
||||
"планвидоврасчета": "ChartOfCalculationTypes",
|
||||
"планвидоврасчёта": "ChartOfCalculationTypes",
|
||||
"регистрсведений": "InformationRegister",
|
||||
"регистрнакопления": "AccumulationRegister",
|
||||
"регистрбухгалтерии": "AccountingRegister",
|
||||
"регистррасчета": "CalculationRegister",
|
||||
"регистррасчёта": "CalculationRegister",
|
||||
"бизнеспроцесс": "BusinessProcess",
|
||||
"бот": "Bot",
|
||||
"задача": "Task", "планобмена": "ExchangePlan",
|
||||
"хранилищенастроек": "SettingsStorage",
|
||||
# Множественное число: в дереве конфигурации виды подписаны именно так.
|
||||
"справочники": "Catalog", "документы": "Document", "перечисления": "Enum",
|
||||
"отчёты": "Report", "отчеты": "Report", "обработки": "DataProcessor",
|
||||
"общиеформы": "CommonForm", "журналыдокументов": "DocumentJournal",
|
||||
"планывидовхарактеристик": "ChartOfCharacteristicTypes",
|
||||
"планысчетов": "ChartOfAccounts",
|
||||
"планывидоврасчета": "ChartOfCalculationTypes",
|
||||
"планывидоврасчёта": "ChartOfCalculationTypes",
|
||||
"регистрысведений": "InformationRegister",
|
||||
"регистрынакопления": "AccumulationRegister",
|
||||
"регистрыбухгалтерии": "AccountingRegister",
|
||||
"регистррасчета": "CalculationRegister", "регистрырасчета": "CalculationRegister",
|
||||
"регистрырасчёта": "CalculationRegister",
|
||||
"бизнеспроцессы": "BusinessProcess",
|
||||
"боты": "Bot",
|
||||
"задачи": "Task", "планыобмена": "ExchangePlan",
|
||||
"хранилищанастроек": "SettingsStorage",
|
||||
}
|
||||
|
||||
|
||||
def resolve_type_name(token):
|
||||
"""Имя вида из пользовательского ввода → каноническое имя или None.
|
||||
|
||||
Ввод прощающий: регистр не важен, принимается имя каталога выгрузки
|
||||
(Catalogs → Catalog) и русское имя вида в единственном и множественном числе.
|
||||
"""
|
||||
key = (token or "").strip().lower()
|
||||
if not key:
|
||||
return None
|
||||
for canon in TYPE_ORDER:
|
||||
if canon.lower() == key:
|
||||
return canon
|
||||
for canon, dir_name in TYPE_TO_DIR.items():
|
||||
if dir_name.lower() == key:
|
||||
return canon
|
||||
return RU_TYPE_MAP.get(key)
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -498,7 +615,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False)
|
||||
parser.add_argument("-ConfigPath", "-Path", required=True)
|
||||
parser.add_argument("-DefinitionFile", default=None)
|
||||
parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page"])
|
||||
parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page", "sort-childObjects"])
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
@@ -637,7 +754,7 @@ def main():
|
||||
if dot_idx < 1:
|
||||
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
type_name = item[:dot_idx]
|
||||
type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
|
||||
obj_name_val = item[dot_idx + 1:]
|
||||
|
||||
if type_name not in TYPE_ORDER:
|
||||
@@ -674,8 +791,15 @@ def main():
|
||||
warn(f"Already exists: {type_name}.{obj_name_val}")
|
||||
continue
|
||||
|
||||
# Find insertion point
|
||||
# Место вставки. Вид — по TYPE_ORDER; внутри вида — по newObjectPosition:
|
||||
# end (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени.
|
||||
# Subsystem по имени не упорядочиваем никогда: порядок подсистем в дереве задаёт
|
||||
# порядок разделов в панели, пока их не перечислили в <SubsystemsOrder>.
|
||||
by_name = (type_name != "Subsystem"
|
||||
and get_new_object_position(config_dir) == "byName")
|
||||
insert_before = None
|
||||
last_same = None
|
||||
first_later = None
|
||||
for child in child_objs_el:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
@@ -685,10 +809,24 @@ def main():
|
||||
child_type_idx = TYPE_ORDER.index(child_type_name)
|
||||
|
||||
if child_type_name == type_name:
|
||||
if (child.text or "") > obj_name_val and insert_before is None:
|
||||
last_same = child
|
||||
if (by_name and insert_before is None
|
||||
and compare_metadata_names(child.text or "", obj_name_val) > 0):
|
||||
insert_before = child
|
||||
elif child_type_idx > type_idx and insert_before is None:
|
||||
insert_before = child
|
||||
elif child_type_idx > type_idx and first_later is None:
|
||||
first_later = child
|
||||
|
||||
if insert_before is None:
|
||||
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
|
||||
# то есть перед его следующим соседом. Через first_later этого не сделать:
|
||||
# если видов старше в файле нет, запись уехала бы в самый конец блока,
|
||||
# за пределы своей группы.
|
||||
if last_same is not None:
|
||||
siblings = [c for c in child_objs_el if isinstance(c.tag, str)]
|
||||
pos = siblings.index(last_same)
|
||||
insert_before = siblings[pos + 1] if pos + 1 < len(siblings) else None
|
||||
else:
|
||||
insert_before = first_later
|
||||
|
||||
new_el = etree.Element(f"{{{MD_NS}}}{type_name}")
|
||||
new_el.text = obj_name_val
|
||||
@@ -701,6 +839,48 @@ def main():
|
||||
add_count += 1
|
||||
info(f"Added: {type_name}.{obj_name_val}")
|
||||
|
||||
def do_sort_child_objects(batch_val):
|
||||
"""Упорядочить <ChildObjects> по имени внутри вида.
|
||||
|
||||
Без значения — все виды, кроме Subsystem (порядок подсистем в дереве задаёт порядок
|
||||
разделов в панели, пока их не перечислили в <SubsystemsOrder>); явно названный вид
|
||||
сортируется в любом случае. Взаимный порядок видов не трогаем: платформа приводит его
|
||||
к своему при первой же выгрузке. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы —
|
||||
отступы и структура файла остаются как были, меняются только имена в строках.
|
||||
"""
|
||||
nonlocal modify_count
|
||||
if child_objs_el is None:
|
||||
print("No <ChildObjects> element found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
requested = []
|
||||
for token in (parse_batch_value(batch_val) if str(batch_val or "").strip() else []):
|
||||
canon = resolve_type_name(token)
|
||||
if canon is None:
|
||||
print(f"Unknown type '{token}'. Valid: {', '.join(TYPE_ORDER)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
requested.append(canon)
|
||||
|
||||
groups = {}
|
||||
for child in child_objs_el:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
groups.setdefault(localname(child), []).append(child)
|
||||
|
||||
targets = requested or [t for t in groups if t != "Subsystem"]
|
||||
for type_name in targets:
|
||||
els = groups.get(type_name, [])
|
||||
if len(els) < 2:
|
||||
continue
|
||||
names = [e.text or "" for e in els]
|
||||
ordered = sorted(names, key=functools.cmp_to_key(compare_metadata_names))
|
||||
if names == ordered:
|
||||
continue
|
||||
for el, name in zip(els, ordered):
|
||||
el.text = name
|
||||
modify_count += 1
|
||||
info(f"Sorted: {type_name} ({len(els)})")
|
||||
|
||||
def do_remove_child_object(batch_val):
|
||||
nonlocal remove_count
|
||||
if child_objs_el is None:
|
||||
@@ -713,7 +893,7 @@ def main():
|
||||
if dot_idx < 1:
|
||||
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
type_name = item[:dot_idx]
|
||||
type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
|
||||
obj_name_val = item[dot_idx + 1:]
|
||||
|
||||
found = False
|
||||
@@ -933,24 +1113,6 @@ def main():
|
||||
info(f"Wrote panel layout: {cai_path}")
|
||||
|
||||
# --- set-home-page (writes Ext/HomePageWorkArea.xml from scratch) ---
|
||||
RU_TYPE_MAP = {
|
||||
"справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
|
||||
"отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
|
||||
"общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
|
||||
"планвидовхарактеристик": "ChartOfCharacteristicTypes",
|
||||
"плансчетов": "ChartOfAccounts",
|
||||
"планвидоврасчета": "ChartOfCalculationTypes",
|
||||
"планвидоврасчёта": "ChartOfCalculationTypes",
|
||||
"регистрсведений": "InformationRegister",
|
||||
"регистрнакопления": "AccumulationRegister",
|
||||
"регистрбухгалтерии": "AccountingRegister",
|
||||
"регистррасчета": "CalculationRegister",
|
||||
"регистррасчёта": "CalculationRegister",
|
||||
"бизнеспроцесс": "BusinessProcess",
|
||||
"бот": "Bot",
|
||||
"задача": "Task", "планобмена": "ExchangePlan",
|
||||
"хранилищенастроек": "SettingsStorage",
|
||||
}
|
||||
DIR_TO_TYPE = {v.lower(): k for k, v in TYPE_TO_DIR.items()}
|
||||
UUID_RE = __import__("re").compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
|
||||
@@ -1130,6 +1292,8 @@ def main():
|
||||
do_set_panels(op_value)
|
||||
elif op_key == "set-home-page":
|
||||
do_set_home_page(op_value)
|
||||
elif op_key == "sort-childobjects":
|
||||
do_sort_child_objects(op_value if isinstance(op_value, str) else str(op_value))
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user