Files
cc-1c-skills/.codex/skills/cf-edit/scripts/cf-edit.ps1
T

1346 lines
61 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# cf-edit v1.28 — 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","sort-childObjects")]
[string]$Operation,
[string]$Value,
[switch]$NoValidate
)
$ErrorActionPreference = "Stop"
# --- Разбор пользовательского JSON ---
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
# Возврат через -NoEnumerate: без него одноэлементный
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
try {
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
$parsed = $text | ConvertFrom-Json
} catch {
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
if ($Inline) {
$got = ($text -replace '\s+', ' ').Trim()
$label = 'got'
if (-not $got) { $got = '(empty)' }
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
$what = "${what}, ${label}: ${got}"
}
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
exit 1
}
Write-Output -NoEnumerate $parsed
}
# --- Чтение входного JSON-файла ---
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
# угаданное имя уйдёт в метаданные так же молча.
function Read-JsonInputFile([string]$path) {
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
# проверкой срабатывают раньше и сохраняют свой текст.
if (-not (Test-Path -LiteralPath $path)) {
[Console]::Error.WriteLine("[ERROR] File not found: $path")
exit 1
}
if (Test-Path -LiteralPath $path -PathType Container) {
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
exit 1
}
$bytes = [System.IO.File]::ReadAllBytes($path)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
}
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
}
try {
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
} catch {
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
exit 1
}
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Mode validation ---
if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 }
if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 }
# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
if (Test-Path $ConfigPath -PathType Container) {
$candidate = Join-Path $ConfigPath "Configuration.xml"
if (Test-Path $candidate) { $ConfigPath = $candidate }
else { Write-Error "No Configuration.xml in directory"; exit 1 }
}
if (-not (Test-Path $ConfigPath)) { Write-Error "File not found: $ConfigPath"; exit 1 }
$resolvedPath = (Resolve-Path $ConfigPath).Path
$script:configDir = [System.IO.Path]::GetDirectoryName($resolvedPath)
# --- Support guard (Ext/ParentConfigurations.bin) ---
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
# read-only configs unless allowed. Trigger = bin present; reaction from
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
# throws — guard errors degrade to allow.
function Get-RootUuid([string]$xmlPath) {
if (-not (Test-Path $xmlPath)) { return $null }
try {
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
$el = $mx.DocumentElement.FirstChild
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
} catch {}
return $null
}
function Test-ExternalObjectRoot([string]$xmlPath) {
if (-not (Test-Path $xmlPath)) { return $false }
try {
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
$el = $mx.DocumentElement.FirstChild
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
} catch {}
return $false
}
function Find-V8Project([string]$startDir) {
$d = $startDir
for ($i = 0; $i -lt 20 -and $d; $i++) {
$pj = Join-Path $d ".v8-project.json"
if (Test-Path $pj) { return $pj }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
return $null
}
function Get-EditMode([string]$cfgDir) {
try {
$pj = Find-V8Project (Get-Location).Path
if (-not $pj) { $pj = Find-V8Project $cfgDir }
if (-not $pj) { return 'deny' }
$proj = Get-Content -Raw $pj | ConvertFrom-Json
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
if ($proj.databases) {
foreach ($db in $proj.databases) {
if ($db.configSrc) {
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
}
}
}
}
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
return 'deny'
} catch { return 'deny' }
}
function Assert-EditAllowed([string]$targetPath, [string]$require) {
try {
$rp = $targetPath
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
if (Test-ExternalObjectRoot $rp) { return }
$elemUuid = Get-RootUuid $rp
$cfgDir = $null; $binPath = $null
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
for ($i = 0; $i -lt 12 -and $d; $i++) {
if (Test-ExternalObjectRoot "$d.xml") { return }
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
if (-not $cfgDir) {
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
}
if ($elemUuid -and $cfgDir) { break }
$parent = [System.IO.Path]::GetDirectoryName($d)
if ($parent -eq $d) { break }
$d = $parent
}
# New object (no element file): fall back to config root uuid.
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
if (-not $binPath -or -not (Test-Path $binPath)) { return }
$bytes = [System.IO.File]::ReadAllBytes($binPath)
if ($bytes.Length -le 32) { return }
$start = 0
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
if (-not $hm.Success) { return }
$G = [int]$hm.Groups[1].Value
$K = [int]$hm.Groups[2].Value
if ($K -eq 0) { return }
$best = $null
if ($elemUuid) {
$u = [regex]::Escape($elemUuid.ToLower())
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
$f1 = [int]$m.Groups[1].Value
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
}
}
$blocked = $false; $code = ""; $reason = ""
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
elseif ($require -eq 'removed') {
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
}
else {
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
}
if (-not $blocked) { return }
$mode = Get-EditMode $cfgDir
if ($mode -eq 'off') { return }
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
# latter throws and would be swallowed by this function's own catch.
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
if ($code -eq "capability-off") {
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
} elseif ($code -eq "not-removed") {
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
} else {
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
}
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
exit 1
} catch { return }
}
Assert-EditAllowed $resolvedPath 'editable'
# --- Load XML with PreserveWhitespace ---
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
$script:xmlDoc = New-Object System.Xml.XmlDocument
$script:xmlDoc.PreserveWhitespace = $true
$script:xmlDoc.Load($resolvedPath)
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
$script:addCount = 0
$script:removeCount = 0
$script:modifyCount = 0
function Info([string]$msg) { Write-Host "[INFO] $msg" }
function Warn([string]$msg) { Write-Host "[WARN] $msg" }
# --- Detect structure ---
$root = $script:xmlDoc.DocumentElement
$script:mdNs = "http://v8.1c.ru/8.3/MDClasses"
$script:xrNs = "http://v8.1c.ru/8.3/xcf/readable"
$script:xsiNs = "http://www.w3.org/2001/XMLSchema-instance"
$script:v8Ns = "http://v8.1c.ru/8.1/data/core"
$script:cfgEl = $null
foreach ($child in $root.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Configuration") {
$script:cfgEl = $child; break
}
}
if (-not $script:cfgEl) { Write-Error "No <Configuration> element found"; exit 1 }
$script:propsEl = $null
$script:childObjsEl = $null
foreach ($child in $script:cfgEl.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
if ($child.LocalName -eq "Properties") { $script:propsEl = $child }
if ($child.LocalName -eq "ChildObjects") { $script:childObjsEl = $child }
}
$script:objName = ""
foreach ($child in $script:propsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Name") {
$script:objName = $child.InnerText.Trim(); break
}
}
Info "Configuration: $($script:objName)"
# --- Canonical type order for ChildObjects (46 types) ---
$script:typeOrder = @(
"Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister",
"ChartOfCharacteristicTypes","ChartOfAccounts","AccountingRegister",
"ChartOfCalculationTypes","CalculationRegister",
"BusinessProcess","Task","IntegrationService"
)
# --- Type → on-disk directory name (plural) ---
$script:typeToDir = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
"Constant"="Constants"; "CommonForm"="CommonForms"; "Catalog"="Catalogs"; "Document"="Documents"
"DocumentNumerator"="DocumentNumerators"; "Sequence"="Sequences"; "DocumentJournal"="DocumentJournals"; "Enum"="Enums"
"Report"="Reports"; "DataProcessor"="DataProcessors"; "InformationRegister"="InformationRegisters"; "AccumulationRegister"="AccumulationRegisters"
"ChartOfCharacteristicTypes"="ChartsOfCharacteristicTypes"; "ChartOfAccounts"="ChartsOfAccounts"; "AccountingRegister"="AccountingRegisters"
"ChartOfCalculationTypes"="ChartsOfCalculationTypes"; "CalculationRegister"="CalculationRegisters"
"BusinessProcess"="BusinessProcesses"; "Task"="Tasks"; "IntegrationService"="IntegrationServices"
}
# --- XML manipulation helpers (from subsystem-edit pattern) ---
function Get-ChildIndent($container) {
foreach ($child in $container.ChildNodes) {
if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') {
if ($child.Value -match '^\r?\n(\t+)$') { return $Matches[1] }
if ($child.Value -match '^\r?\n(\t+)') { return $Matches[1] }
}
}
$depth = 0; $current = $container
while ($current -and $current -ne $script:xmlDoc.DocumentElement) { $depth++; $current = $current.ParentNode }
return "`t" * ($depth + 1)
}
function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) {
$ws = $script:xmlDoc.CreateWhitespace("`r`n$childIndent")
if ($refNode) {
$container.InsertBefore($ws, $refNode) | Out-Null
$container.InsertBefore($newNode, $ws) | Out-Null
} else {
$trailing = $container.LastChild
if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) {
$container.InsertBefore($ws, $trailing) | Out-Null
$container.InsertBefore($newNode, $trailing) | Out-Null
} else {
$container.AppendChild($ws) | Out-Null
$container.AppendChild($newNode) | Out-Null
$parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" }
$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent")
$container.AppendChild($closeWs) | Out-Null
}
}
}
function Remove-NodeWithWhitespace($node) {
$parent = $node.ParentNode
$prev = $node.PreviousSibling
$next = $node.NextSibling
if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) {
$parent.RemoveChild($prev) | Out-Null
} elseif ($next -and ($next.NodeType -eq 'Whitespace' -or $next.NodeType -eq 'SignificantWhitespace')) {
$parent.RemoveChild($next) | Out-Null
}
$parent.RemoveChild($node) | Out-Null
}
function Expand-SelfClosingElement($container, $parentIndent) {
if (-not $container.HasChildNodes -or $container.IsEmpty) {
$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent")
$container.AppendChild($closeWs) | Out-Null
}
}
function Import-Fragment([string]$xmlString) {
$wrapper = "<_W xmlns=`"$($script:mdNs)`" xmlns:xsi=`"$($script:xsiNs)`" xmlns:v8=`"$($script:v8Ns)`" xmlns:xr=`"$($script:xrNs)`" xmlns:xs=`"http://www.w3.org/2001/XMLSchema`">$xmlString</_W>"
$frag = New-Object System.Xml.XmlDocument
$frag.PreserveWhitespace = $true
$frag.LoadXml($wrapper)
$nodes = @()
foreach ($child in $frag.DocumentElement.ChildNodes) {
if ($child.NodeType -eq 'Element') {
$nodes += $script:xmlDoc.ImportNode($child, $true)
}
}
return ,$nodes
}
# --- 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(";;")) {
$trimmed = $part.Trim()
if ($trimmed) { $items += $trimmed }
}
return ,$items
}
# --- LocalString properties ---
$mlProps = @("Synonym","BriefInformation","DetailedInformation","Copyright","VendorInformationAddress","ConfigurationInformationAddress")
# Scalar properties
$scalarProps = @("Name","Version","Vendor","Comment","NamePrefix","UpdateCatalogAddress")
# Ref properties
$refProps = @("DefaultLanguage")
# --- Operation: modify-property ---
function Do-ModifyProperty([string]$batchVal) {
$items = Parse-BatchValue $batchVal
foreach ($item in $items) {
$eqIdx = $item.IndexOf("=")
if ($eqIdx -lt 1) {
Write-Error "Invalid property format '$item', expected 'Key=Value'"
exit 1
}
$propName = $item.Substring(0, $eqIdx).Trim()
$propValue = $item.Substring($eqIdx + 1).Trim()
# Find property element
$propEl = $null
foreach ($child in $script:propsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $propName) {
$propEl = $child; break
}
}
if (-not $propEl) {
Write-Error "Property '$propName' not found in Properties"
exit 1
}
if ($mlProps -contains $propName) {
# LocalString
if (-not $propValue) {
$propEl.InnerXml = ""
} else {
$indent = Get-ChildIndent $script:propsEl
$escaped = [System.Security.SecurityElement]::Escape($propValue)
$mlXml = "`r`n$indent`t<v8:item>`r`n$indent`t`t<v8:lang>ru</v8:lang>`r`n$indent`t`t<v8:content>$escaped</v8:content>`r`n$indent`t</v8:item>`r`n$indent"
$propEl.InnerXml = $mlXml
}
} elseif ($scalarProps -contains $propName -or $refProps -contains $propName) {
# Simple text
if (-not $propValue) { $propEl.InnerXml = "" }
else { $propEl.InnerText = $propValue }
} else {
# Enum or other — just set text
$propEl.InnerText = $propValue
}
$script:modifyCount++
Info "Set $propName = `"$propValue`""
}
}
# --- Operation: add-childObject ---
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
# Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
# его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
# остаётся рабочим каталогом проекта.
# 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 (Get-Location).Path
if (-not $pj) { $pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir)) }
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" }
}
# Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
# CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
# порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
# пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
# порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
# (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
# без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
# Явно названный вид сортируется в любом случае.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Test-OrderSensitiveType([string]$typeName) {
return @("CommonAttribute", "Subsystem", "CommandGroup", "Language") -ccontains $typeName
}
# Порядок имён объектов метаданных, как в дереве Конфигуратора.
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
# одинаково везде. Равные ключи разводит 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>: имена внутри вида, а без аргумента — и группы видов.
# Виды из Test-OrderSensitiveType по имени не сортируются, пока не названы явно.
# Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
# починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
# ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы и структура файла
# остаются как были, в дифе только перестановка строк.
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 { -not (Test-OrderSensitiveType $_) }) }
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))"
}
if ($requested.Count -gt 0) { return }
# Без аргумента приводим в порядок и сами группы видов: собранная навыками конфигурация
# может держать их не в каноне, и первая же выгрузка платформы даст диф. Переставляем
# содержимое существующих узлов, а не узлы, поэтому отступы и структура файла не меняются —
# в дифе только перестановка строк. Имя тега у XmlElement неизменяемо, поэтому там, где вид
# меняется, узел заменяется через ReplaceChild: он сохраняет окружающие пробельные узлы.
$elems = @()
foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -eq 'Element') { $elems += $child }
}
$tags = @(); $texts = @()
foreach ($e in $elems) { $tags += $e.get_LocalName(); $texts += $e.InnerText }
$rank = @()
for ($i = 0; $i -lt $tags.Count; $i++) {
$r = $script:typeOrder.IndexOf($tags[$i])
if ($r -lt 0) { $r = $script:typeOrder.Count }
$rank += $r
}
# Порядок стабильный: вторым ключом идёт исходная позиция
$order = @(0..($tags.Count - 1) | Sort-Object @{e={$rank[$_]}}, @{e={$_}})
$same = $true
for ($i = 0; $i -lt $order.Count; $i++) { if ($order[$i] -ne $i) { $same = $false; break } }
if ($same) { return }
for ($i = 0; $i -lt $elems.Count; $i++) {
$srcIdx = $order[$i]
if ($tags[$i] -ceq $tags[$srcIdx]) {
$elems[$i].InnerText = $texts[$srcIdx]
continue
}
$newEl = $script:xmlDoc.CreateElement($tags[$srcIdx], $script:mdNs)
$newEl.InnerText = $texts[$srcIdx]
[void]$script:childObjsEl.ReplaceChild($newEl, $elems[$i])
}
$script:modifyCount++
Info "Reordered type groups: $($elems.Count) entries"
}
# Стиль существующего файла для 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 }
$items = Parse-BatchValue $batchVal
$cfgIndent = Get-ChildIndent $script:cfgEl
# Expand self-closing if needed
if (-not $script:childObjsEl.HasChildNodes -or $script:childObjsEl.IsEmpty) {
Expand-SelfClosingElement $script:childObjsEl $cfgIndent
}
$childIndent = Get-ChildIndent $script:childObjsEl
foreach ($item in $items) {
$dotIdx = $item.IndexOf(".")
if ($dotIdx -lt 1) {
Write-Error "Invalid format '$item', expected 'Type.Name'"
exit 1
}
$typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1)
# Check type is valid
$typeIdx = $script:typeOrder.IndexOf($typeName)
if ($typeIdx -lt 0) {
Write-Error "Unknown type '$typeName'"
exit 1
}
# Check that the referenced object actually exists on disk.
# cf-edit add-childObject is a low-level operation for rare scenarios
# (e.g. restoring a rolled-back Configuration.xml when object files are intact).
# For creating NEW objects, meta-compile/role-compile/subsystem-compile already
# auto-register in Configuration.xml — calling cf-edit add-childObject there is
# unnecessary and error-prone.
$typeDir = $script:typeToDir[$typeName]
$objFile = Join-Path (Join-Path $script:configDir $typeDir) "$objNameVal.xml"
if (-not (Test-Path $objFile)) {
$hintSkill = switch ($typeName) {
"Subsystem" { "subsystem-compile" }
"Role" { "role-compile" }
default { "meta-compile" }
}
Write-Error @"
Object file not found: $typeDir/$objNameVal.xml
cf-edit add-childObject only references objects that already exist on disk.
To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
/$hintSkill with {"type":"$typeName","name":"$objNameVal"}
"@
exit 1
}
# Dedup check
$existing = $false
foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $typeName -and $child.InnerText -eq $objNameVal) {
$existing = $true; break
}
}
if ($existing) {
Warn "Already exists: $typeName.$objNameVal"
continue
}
# Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $script:configDir) -eq "byName")
$insertBefore = $null
$lastSameType = $null
$firstLaterType = $null
foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$childTypeIdx = $script:typeOrder.IndexOf($child.LocalName)
if ($childTypeIdx -lt 0) { continue }
if ($child.LocalName -eq $typeName) {
# Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
# объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
# никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
$lastSameType = $child
if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
$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
}
}
# Create element
$newEl = $script:xmlDoc.CreateElement($typeName, $script:mdNs)
$newEl.InnerText = $objNameVal
if ($insertBefore) {
Insert-BeforeElement $script:childObjsEl $newEl $insertBefore $childIndent
} else {
# Append at end (or after last same/preceding type)
Insert-BeforeElement $script:childObjsEl $newEl $null $childIndent
}
$script:addCount++
Info "Added: $typeName.$objNameVal"
}
}
# --- Operation: remove-childObject ---
function Do-RemoveChildObject([string]$batchVal) {
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
$items = Parse-BatchValue $batchVal
foreach ($item in $items) {
$dotIdx = $item.IndexOf(".")
if ($dotIdx -lt 1) {
Write-Error "Invalid format '$item', expected 'Type.Name'"
exit 1
}
$typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1)
$found = $false
foreach ($child in @($script:childObjsEl.ChildNodes)) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $typeName -and $child.InnerText -eq $objNameVal) {
Remove-NodeWithWhitespace $child
$script:removeCount++
Info "Removed: $typeName.$objNameVal"
$found = $true
break
}
}
if (-not $found) { Warn "Not found: $typeName.$objNameVal" }
}
}
# --- Operation: add-defaultRole ---
function Do-AddDefaultRole([string]$batchVal) {
$items = Parse-BatchValue $batchVal
# Find DefaultRoles element
$rolesEl = $null
foreach ($child in $script:propsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
$rolesEl = $child; break
}
}
if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found in Properties"; exit 1 }
$propsIndent = Get-ChildIndent $script:propsEl
if (-not $rolesEl.HasChildNodes -or $rolesEl.IsEmpty) {
Expand-SelfClosingElement $rolesEl $propsIndent
}
$roleIndent = Get-ChildIndent $rolesEl
foreach ($item in $items) {
$roleName = $item
if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }
# Dedup
$existing = $false
foreach ($child in $rolesEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.InnerText.Trim() -eq $roleName) {
$existing = $true; break
}
}
if ($existing) {
Warn "DefaultRole already exists: $roleName"
continue
}
$fragXml = "<xr:Item xsi:type=`"xr:MDObjectRef`">$roleName</xr:Item>"
$nodes = Import-Fragment $fragXml
if ($nodes.Count -gt 0) {
Insert-BeforeElement $rolesEl $nodes[0] $null $roleIndent
$script:addCount++
Info "Added DefaultRole: $roleName"
}
}
}
# --- Operation: remove-defaultRole ---
function Do-RemoveDefaultRole([string]$batchVal) {
$items = Parse-BatchValue $batchVal
$rolesEl = $null
foreach ($child in $script:propsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
$rolesEl = $child; break
}
}
if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found"; exit 1 }
foreach ($item in $items) {
$roleName = $item
if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }
$found = $false
foreach ($child in @($rolesEl.ChildNodes)) {
if ($child.NodeType -eq 'Element' -and $child.InnerText.Trim() -eq $roleName) {
Remove-NodeWithWhitespace $child
$script:removeCount++
Info "Removed DefaultRole: $roleName"
$found = $true
break
}
}
if (-not $found) { Warn "DefaultRole not found: $roleName" }
}
}
# --- Operation: set-panels ---
# Canonical English aliases — preferred form, used in docs and error messages.
$script:panelUuids = @{
"sections" = "b553047f-c9aa-4157-978d-448ecad24248"
"open" = "cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"
"favorites" = "13322b22-3960-4d68-93a6-fe2dd7f28ca3"
"history" = "c933ac92-92cd-459d-81cc-e0c8a83ced99"
"functions" = "b2735bd3-d822-4430-ba59-c9e869693b24"
}
# Russian synonyms — silently accepted (cf-info displays Russian names; users
# may copy them straight into cf-edit value).
$script:panelSynonyms = @{
"разделов" = "sections"; "разделы" = "sections"
"открытых" = "open"; "открытые" = "open"
"избранного" = "favorites";"избранное" = "favorites"
"истории" = "history"; "история" = "history"
"функций" = "functions";"функции" = "functions"
}
function Build-PanelEntryXml($entry, [string]$indent) {
# String alias -> <panel><uuid>...</uuid></panel>
if ($entry -is [string]) {
$key = $entry.ToLowerInvariant()
if ($script:panelSynonyms.ContainsKey($key)) { $key = $script:panelSynonyms[$key] }
if (-not $script:panelUuids.ContainsKey($key)) {
Write-Error "Unknown panel alias '$entry'. Allowed: $(($script:panelUuids.Keys | Sort-Object) -join ', ')"
exit 1
}
$u = $script:panelUuids[$key]
$instId = [guid]::NewGuid().ToString()
return "$indent<panel id=`"$instId`">`r`n$indent`t<uuid>$u</uuid>`r`n$indent</panel>"
}
# Object {group: [...]} -> <group id=""><group><panel/></group>...</group> (stack)
if ($entry.PSObject.Properties['group']) {
$children = $entry.group
if (-not $children -or $children.Count -eq 0) {
Write-Error "group must contain at least one entry"
exit 1
}
$gid = [guid]::NewGuid().ToString()
$inner = ""
foreach ($child in $children) {
$childXml = Build-PanelEntryXml $child "$indent`t`t"
$inner += "$indent`t<group>`r`n$childXml`r`n$indent`t</group>`r`n"
}
return "$indent<group id=`"$gid`">`r`n$inner$indent</group>"
}
Write-Error "Panel entry must be a string alias or object {group:[...]}, got: $($entry | ConvertTo-Json -Compress)"
exit 1
}
function Do-SetPanels($valArg) {
# Accept string (JSON), PSCustomObject, or hashtable
$layout = $valArg
if ($layout -is [string]) {
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
}
if (-not $layout) {
Write-Error "set-panels value is empty"
exit 1
}
$sides = @("top","left","right","bottom")
$bodyParts = @()
foreach ($side in $sides) {
$entries = $null
if ($layout.PSObject.Properties[$side]) { $entries = $layout.$side }
if ($null -eq $entries) { continue }
# Normalize to array
if ($entries -isnot [System.Array] -and $entries -isnot [System.Collections.IList]) {
$entries = @($entries)
}
foreach ($entry in $entries) {
$entryXml = Build-PanelEntryXml $entry "`t`t"
$bodyParts += "`t<$side>`r`n$entryXml`r`n`t</$side>"
}
}
# Reject unknown side keys (catches typos like "Top" vs "top")
foreach ($prop in $layout.PSObject.Properties) {
if ($sides -notcontains $prop.Name) {
Write-Error "Unknown side '$($prop.Name)'. Allowed: $($sides -join ', ')"
exit 1
}
}
$body = $bodyParts -join "`r`n"
$declarations = @"
<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
"@
$bodyBlock = if ($body) { "$body`r`n" } else { "" }
$caiXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
$bodyBlock$declarations
</ClientApplicationInterface>
"@
$extDir = Join-Path $script:configDir "Ext"
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
$script:modifyCount++
Info "Wrote panel layout: $caiPath"
}
# --- Operation: set-home-page ---
# Russian → English type aliases for form-ref normalization
$script:ruTypeMap = @{
"справочник" = "Catalog"
"документ" = "Document"
"перечисление" = "Enum"
"отчёт" = "Report"
"отчет" = "Report"
"обработка" = "DataProcessor"
"общаяформа" = "CommonForm"
"журналдокументов" = "DocumentJournal"
"планвидовхарактеристик" = "ChartOfCharacteristicTypes"
"плансчетов" = "ChartOfAccounts"
"планвидоврасчета" = "ChartOfCalculationTypes"
"планвидоврасчёта" = "ChartOfCalculationTypes"
"регистрсведений" = "InformationRegister"
"регистрнакопления" = "AccumulationRegister"
"регистрбухгалтерии" = "AccountingRegister"
"регистррасчета" = "CalculationRegister"
"регистррасчёта" = "CalculationRegister"
"бизнеспроцесс" = "BusinessProcess"
"задача" = "Task"
"бот" = "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 = @{}
foreach ($k in $script:typeToDir.Keys) { $script:dirToType[$script:typeToDir[$k].ToLowerInvariant()] = $k }
function Normalize-FormRef([string]$s) {
$s = $s.Trim()
if (-not $s) { return $s }
# UUID — leave as-is
if ($s -match '^[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}$') { return $s }
# Path form?
if ($s.Contains("/") -or $s.Contains("\")) {
$parts = $s.Replace("\","/").Split("/") | Where-Object { $_ -ne "" -and $_.ToLowerInvariant() -ne "ext" }
# Strip trailing Form.xml
if ($parts.Count -gt 0 -and $parts[-1].ToLowerInvariant() -eq "form.xml") {
$parts = @($parts[0..($parts.Count - 2)])
}
if ($parts.Count -ge 2) {
$typeDir = $parts[0]
$typeSingular = $script:dirToType[$typeDir.ToLowerInvariant()]
if ($typeSingular) {
if ($typeSingular -eq "CommonForm" -and $parts.Count -ge 2) {
return "CommonForm.$($parts[1])"
}
if ($parts.Count -ge 4 -and $parts[2].ToLowerInvariant() -eq "forms") {
return "$typeSingular.$($parts[1]).Form.$($parts[3])"
}
}
}
return $s
}
# Dot form — translate Russian head and 'Форма' segment, auto-insert 'Form'
$segs = $s.Split(".")
if ($segs.Count -ge 1) {
$head = $segs[0].ToLowerInvariant()
if ($script:ruTypeMap.ContainsKey($head)) { $segs[0] = $script:ruTypeMap[$head] }
for ($i = 1; $i -lt $segs.Count; $i++) {
if ($segs[$i] -eq "Форма") { $segs[$i] = "Form" }
}
# Auto-insert Form: for object types with 3 segments (Type.Object.FormName)
if ($segs.Count -eq 3 -and $script:typeOrder -contains $segs[0] -and $segs[0] -ne "CommonForm") {
$segs = @($segs[0], $segs[1], "Form", $segs[2])
}
}
return ($segs -join ".")
}
# Accept short DSL or canonical XML keys (silently)
function Get-FieldValue($obj, [string[]]$keys) {
foreach ($k in $keys) {
if ($obj.PSObject.Properties[$k]) { return $obj.PSObject.Properties[$k].Value }
}
return $null
}
function Build-HomePageItemXml($entry, [string]$indent) {
# Resolve fields
if ($entry -is [string]) {
$formRef = Normalize-FormRef $entry
$height = 10
$common = $true
$roles = $null
} else {
$formRaw = Get-FieldValue $entry @("form","Form")
if (-not $formRaw) { Write-Error "Home page item: 'form' is required, got: $($entry | ConvertTo-Json -Compress)"; exit 1 }
$formRef = Normalize-FormRef ([string]$formRaw)
$h = Get-FieldValue $entry @("height","Height")
$height = if ($null -ne $h) { [int]$h } else { 10 }
$vis = Get-FieldValue $entry @("visibility","Visibility")
$common = if ($null -ne $vis) { [bool]$vis } else { $true }
$roles = Get-FieldValue $entry @("roles")
}
$visParts = @()
$visParts += "$indent`t`t<xr:Common>$($common.ToString().ToLower())</xr:Common>"
if ($roles) {
# roles is PSCustomObject {Role.X: bool, ...}
foreach ($prop in $roles.PSObject.Properties) {
$rname = $prop.Name
if (-not $rname.StartsWith("Role.") -and -not ($rname -match '^[0-9a-fA-F]{8}-')) { $rname = "Role.$rname" }
$rval = ([bool]$prop.Value).ToString().ToLower()
$escName = [System.Security.SecurityElement]::Escape($rname)
$visParts += "$indent`t`t<xr:Value name=`"$escName`">$rval</xr:Value>"
}
}
$visBlock = $visParts -join "`r`n"
$escForm = [System.Security.SecurityElement]::Escape($formRef)
return @"
$indent<Item>
$indent`t<Form>$escForm</Form>
$indent`t<Height>$height</Height>
$indent`t<Visibility>
$visBlock
$indent`t</Visibility>
$indent</Item>
"@
}
function Do-SetHomePage($valArg) {
$layout = $valArg
if ($layout -is [string]) {
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
}
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
$allowedTemplates = @("OneColumn","TwoColumnsEqualWidth","TwoColumnsVariableWidth")
$tmpl = Get-FieldValue $layout @("template","WorkingAreaTemplate")
if (-not $tmpl) { $tmpl = "TwoColumnsEqualWidth" }
if ($allowedTemplates -notcontains $tmpl) {
Write-Error "Unknown template '$tmpl'. Allowed: $($allowedTemplates -join ', ')"; exit 1
}
$leftItems = Get-FieldValue $layout @("left","LeftColumn")
$rightItems = Get-FieldValue $layout @("right","RightColumn")
# Reject unknown keys
$known = @("template","WorkingAreaTemplate","left","LeftColumn","right","RightColumn")
foreach ($prop in $layout.PSObject.Properties) {
if ($known -notcontains $prop.Name) {
Write-Error "Unknown key '$($prop.Name)'. Allowed: template, left, right"; exit 1
}
}
if ($tmpl -eq "OneColumn" -and $rightItems) {
Write-Error "Template 'OneColumn' cannot have items in 'right' column"; exit 1
}
function Build-Column([string]$tag, $items) {
if (-not $items) { return "`t<$tag/>" }
if ($items -isnot [System.Array] -and $items -isnot [System.Collections.IList]) {
$items = @($items)
}
if ($items.Count -eq 0) { return "`t<$tag/>" }
$itemBlocks = @()
foreach ($it in $items) {
$itemBlocks += Build-HomePageItemXml $it "`t`t"
}
$body = $itemBlocks -join "`r`n"
return "`t<$tag>`r`n$body`r`n`t</$tag>"
}
$leftXml = Build-Column "LeftColumn" $leftItems
$rightXml = Build-Column "RightColumn" $rightItems
$hpXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
$leftXml
$rightXml
</HomePageWorkArea>
"@
$extDir = Join-Path $script:configDir "Ext"
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
$script:modifyCount++
Info "Wrote home page layout: $hpPath"
}
# --- Operation: set-defaultRoles ---
function Do-SetDefaultRoles([string]$batchVal) {
$items = Parse-BatchValue $batchVal
$rolesEl = $null
foreach ($child in $script:propsEl.ChildNodes) {
if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
$rolesEl = $child; break
}
}
if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found"; exit 1 }
# Clear all existing children
while ($rolesEl.HasChildNodes) {
$rolesEl.RemoveChild($rolesEl.FirstChild) | Out-Null
}
if ($items.Count -eq 0) {
$script:modifyCount++
Info "Cleared DefaultRoles"
return
}
$propsIndent = Get-ChildIndent $script:propsEl
$roleIndent = "$propsIndent`t"
# Add closing whitespace
$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$propsIndent")
$rolesEl.AppendChild($closeWs) | Out-Null
foreach ($item in $items) {
$roleName = $item
if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }
$fragXml = "<xr:Item xsi:type=`"xr:MDObjectRef`">$roleName</xr:Item>"
$nodes = Import-Fragment $fragXml
if ($nodes.Count -gt 0) {
Insert-BeforeElement $rolesEl $nodes[0] $null $roleIndent
}
}
$script:modifyCount++
Info "Set DefaultRoles: $($items.Count) roles"
}
# --- Execute operations ---
$operations = @()
if ($DefinitionFile) {
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
}
$jsonText = Read-JsonInputFile $DefinitionFile
$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
if ($ops -is [System.Array]) {
foreach ($op in $ops) { $operations += $op }
} else {
$operations += $ops
}
} else {
$operations += @{ operation = $Operation; value = $Value }
}
foreach ($op in $operations) {
$opName = if ($op.operation) { "$($op.operation)" } else { "$Operation" }
# Pass value through as-is (object or string); set-panels needs object form
$opValue = if ($null -ne $op.value) { $op.value } else { $Value }
$opValueStr = if ($opValue -is [string]) { $opValue } else { "$opValue" }
switch ($opName) {
"modify-property" { Do-ModifyProperty $opValueStr }
"add-childObject" { Do-AddChildObject $opValueStr }
"remove-childObject" { Do-RemoveChildObject $opValueStr }
"add-defaultRole" { Do-AddDefaultRole $opValueStr }
"remove-defaultRole" { Do-RemoveDefaultRole $opValueStr }
"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
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
$memStream = New-Object System.IO.MemoryStream
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$script:xmlDoc.Save($writer)
$writer.Flush(); $writer.Close()
$text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$memStream.Close()
$text = Finalize-XmlText $text $xmlStyle
$writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
[System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
Info "Saved: $resolvedPath"
# --- Auto-validate ---
if (-not $NoValidate) {
$validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\cf-validate") "scripts\cf-validate.ps1"
$validateScript = [System.IO.Path]::GetFullPath($validateScript)
if (Test-Path $validateScript) {
Write-Host ""
Write-Host "--- Running cf-validate ---"
& powershell.exe -NoProfile -File $validateScript -ConfigPath $resolvedPath
}
}
# --- Summary ---
Write-Host ""
Write-Host "=== cf-edit summary ==="
Write-Host " Configuration: $($script:objName)"
Write-Host " Added: $($script:addCount)"
Write-Host " Removed: $($script:removeCount)"
Write-Host " Modified: $($script:modifyCount)"
exit 0