Compare commits

..
Author SHA1 Message Date
github-actions[bot] 8feccbad71 Auto-build: agents (powershell) from d4832ce 2026-08-30 17:24:17 +00:00
3768 changed files with 35037 additions and 231520 deletions
-32
View File
@@ -1,32 +0,0 @@
{
"name": "cc-1c-skills",
"interface": {
"displayName": "1C Skills"
},
"plugins": [
{
"name": "1c-skills",
"source": {
"source": "url",
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
"ref": "port-codex"
},
"policy": {
"installation": "AVAILABLE"
},
"category": "Development"
},
{
"name": "1c-skills-py",
"source": {
"source": "url",
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
"ref": "port-codex-py"
},
"policy": {
"installation": "AVAILABLE"
},
"category": "Development"
}
]
}
@@ -24,7 +24,7 @@ allowed-tools:
| `NoValidate` | Пропустить авто-валидацию | | `NoValidate` | Пропустить авто-валидацию |
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1' powershell.exe -NoProfile -File ".agents/skills/cf-edit/scripts/cf-edit.ps1" -ConfigPath '<path>' -Operation modify-property -Value 'Version=1.0.0.1'
``` ```
## Операции ## Операции
@@ -34,6 +34,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -Confi
| `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство | | `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство |
| `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически | | `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически |
| `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects | | `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects |
| `sort-childObjects` | вид, напр. `Catalog` (batch `;;`), либо пусто | Упорядочить ChildObjects по имени внутри вида. Без значения — все виды, кроме четырёх (см. reference) |
| `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию | | `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию |
| `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию | | `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию |
| `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию | | `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию |
@@ -39,6 +39,20 @@
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"` Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
## sort-childObjects
Упорядочивает объекты в `<ChildObjects>` по имени **внутри вида**. Значение — имя вида (`Catalog`, `Role`, …), batch через `;;`. Без значения обрабатываются все виды, какие есть в файле.
```
-Operation sort-childObjects — все виды, кроме перечисленных ниже
-Operation sort-childObjects -Value "Catalog" — только справочники
-Operation sort-childObjects -Value "Catalog ;; Role"
```
Не сортируются, пока вид не назван явно: `CommonAttribute`, `Subsystem`, `CommandGroup`, `Language`.
Вызов без значения дополнительно ставит группы видов в канонический порядок; вызов с явным видом трогает только имена внутри него.
## add-defaultRole / remove-defaultRole / set-defaultRoles ## add-defaultRole / remove-defaultRole / set-defaultRoles
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически). Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
@@ -1,15 +1,80 @@
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml) # cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath, [Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
[string]$DefinitionFile, [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]$Operation,
[string]$Value, [string]$Value,
[switch]$NoValidate [switch]$NoValidate
) )
$ErrorActionPreference = "Stop" $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 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Mode validation --- # --- Mode validation ---
@@ -163,6 +228,8 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
Assert-EditAllowed $resolvedPath 'editable' Assert-EditAllowed $resolvedPath 'editable'
# --- Load XML with PreserveWhitespace --- # --- Load XML with PreserveWhitespace ---
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
$script:xmlDoc = New-Object System.Xml.XmlDocument $script:xmlDoc = New-Object System.Xml.XmlDocument
$script:xmlDoc.PreserveWhitespace = $true $script:xmlDoc.PreserveWhitespace = $true
$script:xmlDoc.Load($resolvedPath) $script:xmlDoc.Load($resolvedPath)
@@ -210,14 +277,14 @@ foreach ($child in $script:propsEl.ChildNodes) {
} }
Info "Configuration: $($script:objName)" Info "Configuration: $($script:objName)"
# --- Canonical type order for ChildObjects (44 types) --- # --- Canonical type order for ChildObjects (46 types) ---
$script:typeOrder = @( $script:typeOrder = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -230,7 +297,7 @@ $script:typeOrder = @(
$script:typeToDir = @{ $script:typeToDir = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles" "Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans" "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences" "XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions" "EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups" "FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
@@ -309,6 +376,21 @@ function Import-Fragment([string]$xmlString) {
} }
# --- Parse batch value (split by ;;) --- # --- 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) { function Parse-BatchValue([string]$val) {
$items = @() $items = @()
foreach ($part in $val.Split(";;")) { foreach ($part in $val.Split(";;")) {
@@ -374,6 +456,220 @@ function Do-ModifyProperty([string]$batchVal) {
} }
# --- Operation: add-childObject --- # --- 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) { function Do-AddChildObject([string]$batchVal) {
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 } if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
@@ -393,6 +689,8 @@ function Do-AddChildObject([string]$batchVal) {
exit 1 exit 1
} }
$typeName = $item.Substring(0, $dotIdx) $typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1) $objNameVal = $item.Substring($dotIdx + 1)
# Check type is valid # Check type is valid
@@ -437,11 +735,11 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
continue continue
} }
# Find insertion point: after last element of same type, or after last element of preceding type # Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $script:configDir) -eq "byName")
$insertBefore = $null $insertBefore = $null
$lastSameType = $null $lastSameType = $null
$lastPrecedingType = $null $firstLaterType = $null
$currentTypeIdx = -1
foreach ($child in $script:childObjsEl.ChildNodes) { foreach ($child in $script:childObjsEl.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue } if ($child.NodeType -ne 'Element') { continue }
@@ -449,17 +747,29 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
if ($childTypeIdx -lt 0) { continue } if ($childTypeIdx -lt 0) { continue }
if ($child.LocalName -eq $typeName) { if ($child.LocalName -eq $typeName) {
# Same type — check alphabetical order # Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
if ($child.InnerText -gt $objNameVal -and -not $insertBefore) { # объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
# Insert before this element (alphabetical) # никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
$lastSameType = $child
if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
$insertBefore = $child $insertBefore = $child
} }
$lastSameType = $child } elseif ($childTypeIdx -gt $typeIdx -and -not $firstLaterType) {
} elseif ($childTypeIdx -lt $typeIdx) { $firstLaterType = $child
$lastPrecedingType = $child }
} elseif ($childTypeIdx -gt $typeIdx -and -not $insertBefore) { }
# First element of a later type — insert before it
$insertBefore = $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
} }
} }
@@ -491,6 +801,8 @@ function Do-RemoveChildObject([string]$batchVal) {
exit 1 exit 1
} }
$typeName = $item.Substring(0, $dotIdx) $typeName = $item.Substring(0, $dotIdx)
$canonType = Resolve-TypeName $typeName
if ($canonType) { $typeName = $canonType }
$objNameVal = $item.Substring($dotIdx + 1) $objNameVal = $item.Substring($dotIdx + 1)
$found = $false $found = $false
@@ -637,10 +949,7 @@ function Do-SetPanels($valArg) {
# Accept string (JSON), PSCustomObject, or hashtable # Accept string (JSON), PSCustomObject, or hashtable
$layout = $valArg $layout = $valArg
if ($layout -is [string]) { if ($layout -is [string]) {
try { $layout = $layout | ConvertFrom-Json } catch { $layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
Write-Error "set-panels value must be valid JSON object, got: $valArg"
exit 1
}
} }
if (-not $layout) { if (-not $layout) {
Write-Error "set-panels value is empty" Write-Error "set-panels value is empty"
@@ -691,7 +1000,9 @@ $bodyBlock$declarations
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null } if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml" $caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
$utf8Bom = New-Object System.Text.UTF8Encoding($true) $utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom) # Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
$script:modifyCount++ $script:modifyCount++
Info "Wrote panel layout: $caiPath" Info "Wrote panel layout: $caiPath"
} }
@@ -721,6 +1032,29 @@ $script:ruTypeMap = @{
"бот" = "Bot" "бот" = "Bot"
"планобмена" = "ExchangePlan" "планобмена" = "ExchangePlan"
"хранилищенастроек" = "SettingsStorage" "хранилищенастроек" = "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 # plural folder → singular type
$script:dirToType = @{} $script:dirToType = @{}
@@ -822,9 +1156,7 @@ $indent</Item>
function Do-SetHomePage($valArg) { function Do-SetHomePage($valArg) {
$layout = $valArg $layout = $valArg
if ($layout -is [string]) { if ($layout -is [string]) {
try { $layout = $layout | ConvertFrom-Json } catch { $layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
Write-Error "set-home-page value must be valid JSON object"; exit 1
}
} }
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 } if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
@@ -880,7 +1212,9 @@ $rightXml
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null } if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
$hpPath = Join-Path $extDir "HomePageWorkArea.xml" $hpPath = Join-Path $extDir "HomePageWorkArea.xml"
$utf8Bom = New-Object System.Text.UTF8Encoding($true) $utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom) # Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
$script:modifyCount++ $script:modifyCount++
Info "Wrote home page layout: $hpPath" Info "Wrote home page layout: $hpPath"
} }
@@ -936,8 +1270,8 @@ if ($DefinitionFile) {
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) { if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile $DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
} }
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile $jsonText = Read-JsonInputFile $DefinitionFile
$ops = $jsonText | ConvertFrom-Json $ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
if ($ops -is [System.Array]) { if ($ops -is [System.Array]) {
foreach ($op in $ops) { $operations += $op } foreach ($op in $ops) { $operations += $op }
} else { } else {
@@ -962,11 +1296,16 @@ foreach ($op in $operations) {
"set-defaultRoles" { Do-SetDefaultRoles $opValueStr } "set-defaultRoles" { Do-SetDefaultRoles $opValueStr }
"set-panels" { Do-SetPanels $opValue } "set-panels" { Do-SetPanels $opValue }
"set-home-page" { Do-SetHomePage $opValue } "set-home-page" { Do-SetHomePage $opValue }
"sort-childObjects" { Do-SortChildObjects $opValueStr }
default { Write-Error "Unknown operation: $opName"; exit 1 } default { Write-Error "Unknown operation: $opName"; exit 1 }
} }
} }
# --- Save --- # --- Save ---
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$xmlStyle = Detect-XmlStyle $resolvedPath
$settings = New-Object System.Xml.XmlWriterSettings $settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($true) $settings.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings.Indent = $false $settings.Indent = $false
@@ -977,14 +1316,12 @@ $writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$script:xmlDoc.Save($writer) $script:xmlDoc.Save($writer)
$writer.Flush(); $writer.Close() $writer.Flush(); $writer.Close()
$bytes = $memStream.ToArray() $text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$memStream.Close() $memStream.Close()
$text = [System.Text.Encoding]::UTF8.GetString($bytes) $text = Finalize-XmlText $text $xmlStyle
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
$utf8Bom = New-Object System.Text.UTF8Encoding($true) $writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom) [System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
Info "Saved: $resolvedPath" Info "Saved: $resolvedPath"
# --- Auto-validate --- # --- Auto-validate ---
@@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml) # cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
import functools
import json import json
import os import os
import re import re
@@ -12,6 +13,127 @@ import uuid as _uuid
from html import escape as html_escape from html import escape as html_escape
from lxml import etree from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def parse_json_input(text, source, expected=None, inline=False):
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
expected заполняем только для полиморфного входа: у файла подсказка
была бы наполнителем имя файла и текст парсера самодостаточны. inline печатает ещё и то,
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
"""
import json as _pj
import sys as _psys
try:
if not str(text).strip():
raise ValueError("input is empty")
return _pj.loads(text)
except ValueError as exc:
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
if inline:
got = " ".join(str(text).split())
label = "got"
if not got:
got = "(empty)"
elif len(got) > 60:
label = "got (first 60 chars)"
got = got[:60]
what = "%s, %s: %s" % (what, label, got)
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
_psys.exit(1)
def read_json_file(path):
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
BOM объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
"""
import os as _pos
import sys as _psys
if not _pos.path.exists(path):
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
_psys.exit(1)
if _pos.path.isdir(path):
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
_psys.exit(1)
with open(path, "rb") as _fh:
data = _fh.read()
if data[:3] == b"\xef\xbb\xbf":
return data[3:].decode("utf-8")
if data[:2] == b"\xff\xfe":
return data[2:].decode("utf-16-le")
if data[:2] == b"\xfe\xff":
return data[2:].decode("utf-16-be")
try:
return data.decode("utf-8")
except UnicodeDecodeError as exc:
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
% (path, exc), file=_psys.stderr)
_psys.exit(1)
class CIDict(dict):
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
# сохраняется — от него зависит порядок эмиссии.
def _actual(self, key):
if not isinstance(key, str) or dict.__contains__(self, key):
return key
ci = self.__dict__.get('_ci')
if ci is None or len(ci) != len(self):
ci = {k.lower(): k for k in self if isinstance(k, str)}
self.__dict__['_ci'] = ci
return ci.get(key.lower(), key)
def __getitem__(self, key):
return dict.__getitem__(self, self._actual(key))
def __contains__(self, key):
return dict.__contains__(self, self._actual(key))
def get(self, key, default=None):
return dict.get(self, self._actual(key), default)
def pop(self, key, *default):
return dict.pop(self, self._actual(key), *default)
def __setitem__(self, key, value):
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
dict.__setitem__(self, self._actual(key), value)
def ci_json(obj):
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
if isinstance(obj, dict):
return CIDict((k, ci_json(v)) for k, v in obj.items())
if isinstance(obj, list):
return [ci_json(v) for v in obj]
return obj
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# ============================================================ # ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md # Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
@@ -195,14 +317,14 @@ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
V8_NS = "http://v8.1c.ru/8.1/data/core" V8_NS = "http://v8.1c.ru/8.1/data/core"
XS_NS = "http://www.w3.org/2001/XMLSchema" XS_NS = "http://www.w3.org/2001/XMLSchema"
# Canonical type order for ChildObjects (44 types) # Canonical type order for ChildObjects (46 types)
TYPE_ORDER = [ TYPE_ORDER = [
"Language", "Subsystem", "StyleItem", "Style", "Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate", "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan", "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference", "XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption", "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup", "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document", "Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum", "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister", "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
@@ -215,7 +337,7 @@ TYPE_ORDER = [
TYPE_TO_DIR = { TYPE_TO_DIR = {
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles", "Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates", "CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans", "FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "PaletteColor": "PaletteColors", "PaletteColor": "PaletteColors", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences", "XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions", "EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups", "FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
@@ -232,6 +354,137 @@ SCALAR_PROPS = ["Name", "Version", "Vendor", "Comment", "NamePrefix", "UpdateCat
REF_PROPS = ["DefaultLanguage"] REF_PROPS = ["DefaultLanguage"]
def get_new_object_position(cfg_dir):
"""Куда навык ставит новую запись в <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.
"""
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(os.path.abspath(cfg_dir or "."))
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 is_order_sensitive_type(type_name):
"""Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
CommonAttribute исключение самого стандарта (#std467): у общих реквизитов-разделителей
порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
(в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
Явно названный вид сортируется в любом случае.
Реестр семьи: tests/skills/check-inline-drift.mjs.
"""
return type_name in ("CommonAttribute", "Subsystem", "CommandGroup", "Language")
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): def localname(el):
return etree.QName(el.tag).localname return etree.QName(el.tag).localname
@@ -341,21 +594,22 @@ def _detect_xml_style(path):
def _finalize_xml_bytes(xml_bytes, style): def _finalize_xml_bytes(xml_bytes, style):
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None).""" """Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
enc_decl = style["enc"] if style else "utf-8" выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
enc_decl = style["enc"] if style else "UTF-8"
xml_bytes = xml_bytes.replace( xml_bytes = xml_bytes.replace(
b"<?xml version='1.0' encoding='UTF-8'?>", b"<?xml version='1.0' encoding='UTF-8'?>",
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>') b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
# Канонизировать переносы к LF (убирает &#13; от \r в tail'ах) # Канонизировать переносы к LF (убирает &#13; от \r в tail'ах)
xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"") xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"")
.replace(b"\r\n", b"\n").replace(b"\r", b"\n")) .replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
# Финальный перенос — как в оригинале (новый файл → есть) # Финальный перенос — как в оригинале (новый файл → нет, канон #57)
want_final_nl = style["final_nl"] if style else True want_final_nl = style["final_nl"] if style else False
xml_bytes = xml_bytes.rstrip(b"\n") xml_bytes = xml_bytes.rstrip(b"\n")
if want_final_nl: if want_final_nl:
xml_bytes += b"\n" xml_bytes += b"\n"
# EOL — как в оригинале (новый файл → LF, текущее поведение) # EOL — как в оригинале (новый файл → CRLF, канон #57)
if style and style["crlf"]: if (style["crlf"] if style else True):
xml_bytes = xml_bytes.replace(b"\n", b"\r\n") xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
return xml_bytes return xml_bytes
@@ -376,10 +630,10 @@ def main():
parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False) parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False)
parser.add_argument("-ConfigPath", "-Path", required=True) parser.add_argument("-ConfigPath", "-Path", required=True)
parser.add_argument("-DefinitionFile", default=None) 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("-Value", default=None)
parser.add_argument("-NoValidate", action="store_true") parser.add_argument("-NoValidate", action="store_true")
args = parser.parse_args() args = ci_parse_args(parser)
if args.DefinitionFile and args.Operation: if args.DefinitionFile and args.Operation:
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr) print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
@@ -515,7 +769,7 @@ def main():
if dot_idx < 1: if dot_idx < 1:
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr) print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
sys.exit(1) 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:] obj_name_val = item[dot_idx + 1:]
if type_name not in TYPE_ORDER: if type_name not in TYPE_ORDER:
@@ -552,8 +806,15 @@ def main():
warn(f"Already exists: {type_name}.{obj_name_val}") warn(f"Already exists: {type_name}.{obj_name_val}")
continue continue
# Find insertion point # Место вставки. Вид — по TYPE_ORDER; внутри вида — по newObjectPosition:
# end (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени.
# Subsystem по имени не упорядочиваем никогда: порядок подсистем в дереве задаёт
# порядок разделов в панели, пока их не перечислили в <SubsystemsOrder>.
by_name = (not is_order_sensitive_type(type_name)
and get_new_object_position(config_dir) == "byName")
insert_before = None insert_before = None
last_same = None
first_later = None
for child in child_objs_el: for child in child_objs_el:
if not isinstance(child.tag, str): if not isinstance(child.tag, str):
continue continue
@@ -563,10 +824,24 @@ def main():
child_type_idx = TYPE_ORDER.index(child_type_name) child_type_idx = TYPE_ORDER.index(child_type_name)
if child_type_name == 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 insert_before = child
elif child_type_idx > type_idx and insert_before is None: elif child_type_idx > type_idx and first_later is None:
insert_before = child 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 = etree.Element(f"{{{MD_NS}}}{type_name}")
new_el.text = obj_name_val new_el.text = obj_name_val
@@ -579,6 +854,69 @@ def main():
add_count += 1 add_count += 1
info(f"Added: {type_name}.{obj_name_val}") info(f"Added: {type_name}.{obj_name_val}")
def do_sort_child_objects(batch_val):
"""Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.
Виды из is_order_sensitive_type по имени не сортируются, пока не названы явно.
Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы отступы и структура файла
остаются как были, в дифе только перестановка строк.
"""
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 not is_order_sensitive_type(t)]
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)})")
if requested:
# Вид назван явно — точечная операция: взаимный порядок групп не трогаем.
return
# Без аргумента приводим в порядок и сами группы видов: собранная навыками
# конфигурация может держать их не в каноне, и первая же выгрузка платформы даст
# диф. Переставляем содержимое существующих узлов, а не узлы, поэтому отступы и
# структура файла не меняются — в дифе только перестановка строк.
elems = [c for c in child_objs_el if isinstance(c.tag, str)]
pairs = [(localname(c), c.text or "") for c in elems]
ranked = sorted(range(len(pairs)),
key=lambda i: (TYPE_ORDER.index(pairs[i][0]) if pairs[i][0] in TYPE_ORDER else len(TYPE_ORDER), i))
wanted = [pairs[i] for i in ranked]
if wanted == pairs:
return
for el, (tag, text) in zip(elems, wanted):
el.tag = f'{{{MD_NS}}}{tag}'
el.text = text
modify_count += 1
info(f"Reordered type groups: {len(elems)} entries")
def do_remove_child_object(batch_val): def do_remove_child_object(batch_val):
nonlocal remove_count nonlocal remove_count
if child_objs_el is None: if child_objs_el is None:
@@ -591,7 +929,7 @@ def main():
if dot_idx < 1: if dot_idx < 1:
print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr) print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
sys.exit(1) 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:] obj_name_val = item[dot_idx + 1:]
found = False found = False
@@ -761,11 +1099,8 @@ def main():
nonlocal modify_count nonlocal modify_count
layout = value layout = value
if isinstance(layout, str): if isinstance(layout, str):
try: layout = ci_json(parse_json_input(
layout = json.loads(layout) layout, "-Value for operation 'set-panels'", "a JSON object with panel layout", inline=True))
except json.JSONDecodeError:
print(f"set-panels value must be valid JSON object", file=sys.stderr)
sys.exit(1)
if not isinstance(layout, dict) or not layout: if not isinstance(layout, dict) or not layout:
print("set-panels value must be non-empty object", file=sys.stderr) print("set-panels value must be non-empty object", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -814,24 +1149,6 @@ def main():
info(f"Wrote panel layout: {cai_path}") info(f"Wrote panel layout: {cai_path}")
# --- set-home-page (writes Ext/HomePageWorkArea.xml from scratch) --- # --- 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()} 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}$") 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}$")
@@ -916,11 +1233,8 @@ def main():
nonlocal modify_count nonlocal modify_count
layout = value layout = value
if isinstance(layout, str): if isinstance(layout, str):
try: layout = ci_json(parse_json_input(
layout = json.loads(layout) layout, "-Value for operation 'set-home-page'", "a JSON object with home page layout", inline=True))
except json.JSONDecodeError:
print("set-home-page value must be valid JSON object", file=sys.stderr)
sys.exit(1)
if not isinstance(layout, dict) or not layout: if not isinstance(layout, dict) or not layout:
print("set-home-page value must be non-empty object", file=sys.stderr) print("set-home-page value must be non-empty object", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -984,8 +1298,7 @@ def main():
def_file = args.DefinitionFile def_file = args.DefinitionFile
if not os.path.isabs(def_file): if not os.path.isabs(def_file):
def_file = os.path.join(os.getcwd(), def_file) def_file = os.path.join(os.getcwd(), def_file)
with open(def_file, "r", encoding="utf-8-sig") as fh: ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
ops = json.loads(fh.read())
if isinstance(ops, list): if isinstance(ops, list):
operations = ops operations = ops
else: else:
@@ -995,24 +1308,28 @@ def main():
for op in operations: for op in operations:
op_name = op.get("operation", args.Operation or "") op_name = op.get("operation", args.Operation or "")
# PS сравнивает имя операции через switch, а он регистронезависим.
op_key = str(op_name).lower()
op_value = op.get("value", args.Value or "") op_value = op.get("value", args.Value or "")
if op_name == "modify-property": if op_key == "modify-property":
do_modify_property(op_value if isinstance(op_value, str) else str(op_value)) do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "add-childObject": elif op_key == "add-childobject":
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value)) do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "remove-childObject": elif op_key == "remove-childobject":
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value)) do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "add-defaultRole": elif op_key == "add-defaultrole":
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value)) do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "remove-defaultRole": elif op_key == "remove-defaultrole":
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value)) do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "set-defaultRoles": elif op_key == "set-defaultroles":
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value)) do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
elif op_name == "set-panels": elif op_key == "set-panels":
do_set_panels(op_value) do_set_panels(op_value)
elif op_name == "set-home-page": elif op_key == "set-home-page":
do_set_home_page(op_value) 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: else:
print(f"Unknown operation: {op_name}", file=sys.stderr) print(f"Unknown operation: {op_name}", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -23,7 +23,7 @@ allowed-tools:
| `OutFile` | Записать результат в файл (UTF-8 BOM) | | `OutFile` | Записать результат в файл (UTF-8 BOM) |
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-info.ps1" -ConfigPath "<путь>" powershell.exe -NoProfile -File ".agents/skills/cf-info/scripts/cf-info.ps1" -ConfigPath "<путь>"
``` ```
## Три режима ## Три режима
@@ -1,7 +1,8 @@
# cf-info v1.4 — Compact summary of 1C configuration root # cf-info v1.8 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath, [Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
[ValidateSet("overview","brief","full")] [ValidateSet("overview","brief","full")]
[string]$Mode = "overview", [string]$Mode = "overview",
[Alias('Name')] [Alias('Name')]
@@ -85,14 +86,14 @@ function Get-PropML([string]$propName) {
return (Get-MLText $n) return (Get-MLText $n)
} }
# --- Type name maps (canonical order, 44 types) --- # --- Type name maps (canonical order, 46 types) ---
$typeOrder = @( $typeOrder = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-info v1.4 — Compact summary of 1C configuration root # cf-info v1.8 — Compact summary of 1C configuration root
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -12,6 +12,28 @@ from lxml import etree
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8")
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# --- Argument parsing --- # --- Argument parsing ---
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False) parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory") parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show") parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip") parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
parser.add_argument("-OutFile", default="", help="Write output to file") parser.add_argument("-OutFile", default="", help="Write output to file")
args = parser.parse_args() args = ci_parse_args(parser)
# --- Output helper (collect all, paginate at the end) --- # --- Output helper (collect all, paginate at the end) ---
lines_buf = [] lines_buf = []
@@ -39,11 +61,11 @@ if os.path.isdir(config_path):
if os.path.isfile(candidate): if os.path.isfile(candidate):
config_path = candidate config_path = candidate
else: else:
print(f"[ERROR] No Configuration.xml found in directory: {config_path}", file=sys.stderr) print(f"[ERROR] No Configuration.xml found in directory: {config_path}")
sys.exit(1) sys.exit(1)
if not os.path.isfile(config_path): if not os.path.isfile(config_path):
print(f"[ERROR] File not found: {config_path}", file=sys.stderr) print(f"[ERROR] File not found: {config_path}")
sys.exit(1) sys.exit(1)
# --- Load XML --- # --- Load XML ---
@@ -60,12 +82,12 @@ NS = {
md_root = xml_root # root is MetaDataObject itself md_root = xml_root # root is MetaDataObject itself
if etree.QName(md_root.tag).localname != "MetaDataObject": if etree.QName(md_root.tag).localname != "MetaDataObject":
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)", file=sys.stderr) print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)")
sys.exit(1) sys.exit(1)
cfg_node = md_root.find("md:Configuration", NS) cfg_node = md_root.find("md:Configuration", NS)
if cfg_node is None: if cfg_node is None:
print("[ERROR] No <Configuration> element found", file=sys.stderr) print("[ERROR] No <Configuration> element found")
sys.exit(1) sys.exit(1)
version = md_root.get("version", "") version = md_root.get("version", "")
@@ -91,14 +113,14 @@ def get_prop_ml(prop_name):
n = props_node.find(f"md:{prop_name}", NS) n = props_node.find(f"md:{prop_name}", NS)
return get_ml_text(n) return get_ml_text(n)
# --- Type name maps (canonical order, 44 types) --- # --- Type name maps (canonical order, 46 types) ---
type_order = [ type_order = [
"Language", "Subsystem", "StyleItem", "Style", "Language", "Subsystem", "StyleItem", "Style",
"CommonPicture", "SessionParameter", "Role", "CommonTemplate", "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan", "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
"XDTOPackage", "WebService", "HTTPService", "WSReference", "XDTOPackage", "WebService", "HTTPService", "WSReference",
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption", "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup", "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
"Constant", "CommonForm", "Catalog", "Document", "Constant", "CommonForm", "Catalog", "Document",
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum", "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister", "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
+64
View File
@@ -0,0 +1,64 @@
---
name: cf-init
description: Создать пустую конфигурацию 1С (scaffold XML-исходников). Используй когда нужно начать новую конфигурацию с нуля
argument-hint: <Name> [-Synonym <name>] [-OutputDir src]
allowed-tools:
- Bash
- Read
- Glob
---
# /cf-init — Создание пустой конфигурации 1С
Создаёт scaffold исходников пустой конфигурации 1С: `Configuration.xml`, `Languages/Русский.xml`.
## Параметры и команда
| Параметр | Описание |
|----------|----------|
| `Name` | Имя конфигурации (обязат.) |
| `Synonym` | Синоним (= Name если не указан) |
| `OutputDir` | Каталог для создания (default: `src`) |
| `Version` | Версия конфигурации |
| `Vendor` | Поставщик |
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
разным правилам.
`FormatVersion`**не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
не будет.
```powershell
powershell.exe -NoProfile -File ".agents/skills/cf-init/scripts/cf-init.ps1" -Name "МояКонфигурация"
```
## Примеры
```powershell
# Базовая конфигурация
... -Name МояКонфигурация -Synonym "Моя конфигурация" -OutputDir test-tmp/cf
# С версией и поставщиком
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
```
## Верификация
```
/cf-init TestConfig -OutputDir test-tmp/cf
/cf-info test-tmp/cf — проверить созданное
/cf-validate test-tmp/cf — валидировать
```
@@ -1,5 +1,6 @@
# cf-init v1.4 — Create empty 1C configuration scaffold # cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
@@ -9,15 +10,54 @@ param(
[string]$Vendor, [string]$Vendor,
[string]$CompatibilityMode = "Version8_3_24", [string]$CompatibilityMode = "Version8_3_24",
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима # Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
# совместимости она не зависит: 8.3.20-8.3.24 пишут 2.17, 8.3.25 — 2.18, 8.3.26 — 2.19, # совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
# 8.3.27 — 2.20. Дефолт консервативный: 2.17 читается всеми поддерживаемыми платформами.
[ValidateSet("2.17", "2.18", "2.19", "2.20", "2.21")]
[string]$FormatVersion = "2.17" [string]$FormatVersion = "2.17"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
# расхождение портов началось бы прямо здесь.
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
}
# --- Resolve output dir --- # --- Resolve output dir ---
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) { if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
$OutputDir = Join-Path (Get-Location).Path $OutputDir $OutputDir = Join-Path (Get-Location).Path $OutputDir
@@ -43,6 +83,11 @@ $co6 = [guid]::NewGuid().ToString()
$co7 = [guid]::NewGuid().ToString() $co7 = [guid]::NewGuid().ToString()
# --- Mobile functionalities --- # --- Mobile functionalities ---
# Версия формата как число — по ней ниже включаются вставки 2.21.
$is221 = ($formatRank -ge 221)
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
$is218 = ($formatRank -ge 218)
$mobileFuncs = @( $mobileFuncs = @(
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"), @("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"), @("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
@@ -59,6 +104,12 @@ $mobileFuncs = @(
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"), @("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false") @("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
) )
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
$mobileXml = "" $mobileXml = ""
foreach ($mf in $mobileFuncs) { foreach ($mf in $mobileFuncs) {
@@ -68,17 +119,43 @@ foreach ($mf in $mobileFuncs) {
# --- Synonym XML --- # --- Synonym XML ---
$synonymXml = "" $synonymXml = ""
if ($Synonym) { if ($Synonym) {
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t" $synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
} }
# --- Optional properties --- # --- Optional properties ---
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" } # Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" } # пишет <Vendor/>, а не <Vendor></Vendor>.
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
# на своё место, а не в конец.
$nl = "`r`n"
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
$palNs = ""
if ($is221) {
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
$f221AuxForms = $nl + ((@(
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
}
# --- Configuration.xml --- # --- Configuration.xml ---
$cfgXml = @" $cfgXml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$FormatVersion"> <MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$FormatVersion">
<Configuration uuid="$uuidCfg"> <Configuration uuid="$uuidCfg">
<InternalInfo> <InternalInfo>
<xr:ContainedObject> <xr:ContainedObject>
@@ -111,7 +188,7 @@ $cfgXml = @"
</xr:ContainedObject> </xr:ContainedObject>
</InternalInfo> </InternalInfo>
<Properties> <Properties>
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name> <Name>$(Esc-XmlText ($Name))</Name>
<Synonym>$synonymXml</Synonym> <Synonym>$synonymXml</Synonym>
<Comment/> <Comment/>
<NamePrefix/> <NamePrefix/>
@@ -122,8 +199,8 @@ $cfgXml = @"
</UsePurposes> </UsePurposes>
<ScriptVariant>Russian</ScriptVariant> <ScriptVariant>Russian</ScriptVariant>
<DefaultRoles/> <DefaultRoles/>
<Vendor>$vendorXml</Vendor> $vendorEl
<Version>$versionXml</Version> $versionEl
<UpdateCatalogAddress/> <UpdateCatalogAddress/>
<IncludeHelpInContents>false</IncludeHelpInContents> <IncludeHelpInContents>false</IncludeHelpInContents>
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication> <UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
@@ -145,15 +222,15 @@ $cfgXml = @"
<DefaultDataHistoryChangeHistoryForm/> <DefaultDataHistoryChangeHistoryForm/>
<DefaultDataHistoryVersionDataForm/> <DefaultDataHistoryVersionDataForm/>
<DefaultDataHistoryVersionDifferencesForm/> <DefaultDataHistoryVersionDifferencesForm/>
<DefaultCollaborationSystemUsersChoiceForm/> <DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
<RequiredMobileApplicationPermissions/> <RequiredMobileApplicationPermissions/>
<UsedMobileApplicationFunctionalities>$mobileXml <UsedMobileApplicationFunctionalities>$mobileXml
</UsedMobileApplicationFunctionalities> </UsedMobileApplicationFunctionalities>
<StandaloneConfigurationRestrictionRoles/> <StandaloneConfigurationRestrictionRoles/>
<MobileApplicationURLs/> <MobileApplicationURLs/>
<AllowedIncomingShareRequestTypes/> <AllowedIncomingShareRequestTypes/>$f221WindowVariant
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode> <MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
<DefaultInterface/> <DefaultInterface/>$f221Captions
<DefaultStyle/> <DefaultStyle/>
<DefaultLanguage>Language.Русский</DefaultLanguage> <DefaultLanguage>Language.Русский</DefaultLanguage>
<BriefInformation/> <BriefInformation/>
@@ -165,7 +242,7 @@ $cfgXml = @"
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode> <ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
<ModalityUseMode>DontUse</ModalityUseMode> <ModalityUseMode>DontUse</ModalityUseMode>
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode> <SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode> <InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode> <DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
<CompatibilityMode>$CompatibilityMode</CompatibilityMode> <CompatibilityMode>$CompatibilityMode</CompatibilityMode>
<DefaultConstantsForm/> <DefaultConstantsForm/>
@@ -180,7 +257,7 @@ $cfgXml = @"
# --- Languages/Русский.xml --- # --- Languages/Русский.xml ---
$langXml = @" $langXml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$FormatVersion"> <MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$FormatVersion">
<Language uuid="$uuidLang"> <Language uuid="$uuidLang">
<Properties> <Properties>
<Name>Русский</Name> <Name>Русский</Name>
@@ -240,11 +317,20 @@ if (-not (Test-Path $extDir)) {
# --- Write files with UTF-8 BOM --- # --- Write files with UTF-8 BOM ---
$enc = New-Object System.Text.UTF8Encoding($true) $enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc) # XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $cfgFile $cfgXml $enc
$langFile = Join-Path $langDir "Русский.xml" $langFile = Join-Path $langDir "Русский.xml"
[System.IO.File]::WriteAllText($langFile, $langXml, $enc) Write-XmlFile $langFile $langXml $enc
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml" $caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
[System.IO.File]::WriteAllText($caiFile, $caiXml, $enc) Write-XmlFile $caiFile $caiXml $enc
# --- Output --- # --- Output ---
Write-Host "[OK] Создана конфигурация: $Name" Write-Host "[OK] Создана конфигурация: $Name"
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration."""
import sys, os, argparse, re, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Create empty 1C configuration scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-OutputDir', dest='OutputDir', default='src')
parser.add_argument('-Version', dest='Version', default='')
parser.add_argument('-Vendor', dest='Vendor', default='')
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
# Дефолт 2.17 — нижняя граница проверенного диапазона.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
# расхождение портов началось бы прямо здесь.
if (args.CompatibilityMode or "").lower() == "dontuse":
print("WARNING: CompatibilityMode 'DontUse' is not \"no restrictions\" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
output_dir = args.OutputDir
version = args.Version
vendor = args.Vendor
compat = args.CompatibilityMode
# --- Resolve output dir ---
if not os.path.isabs(output_dir):
output_dir = os.path.join(os.getcwd(), output_dir)
# --- Check existing ---
cfg_file = os.path.join(output_dir, "Configuration.xml")
if os.path.exists(cfg_file):
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
sys.exit(1)
# --- Generate UUIDs ---
uuid_cfg = new_uuid()
uuid_lang = new_uuid()
co = [new_uuid() for _ in range(7)]
# --- Mobile functionalities ---
# Версия формата как число — по ней ниже включаются вставки 2.21.
is_221 = format_rank_value >= 221
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
is_218 = format_rank_value >= 218
mobile_funcs = [
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
("Calendars","false"), ("PushNotifications","false"), ("LocalNotifications","false"),
("InAppPurchases","false"), ("PersonalComputerFileExchange","false"), ("Ads","false"),
("NumberDialing","false"), ("CallProcessing","false"), ("CallLog","false"),
("AutoSendSMS","false"), ("ReceiveSMS","false"), ("SMSLog","false"),
("Camera","false"), ("Microphone","false"), ("MusicLibrary","false"),
("PictureAndVideoLibraries","false"), ("AudioPlaybackAndVibration","false"),
("BackgroundAudioPlaybackAndVibration","false"), ("InstallPackages","false"),
("OSBackup","true"), ("ApplicationUsageStatistics","false"),
("BarcodeScanning","false"), ("BackgroundAudioRecording","false"),
("AllFilesAccess","false"), ("Videoconferences","false"), ("NFC","false"),
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
]
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
if is_218:
mobile_funcs.append(("TextToSpeech", "false"))
mobile_xml = ""
for func_name, func_use in mobile_funcs:
mobile_xml += f"\r\n\t\t\t\t<app:functionality>\r\n\t\t\t\t\t<app:functionality>{func_name}</app:functionality>\r\n\t\t\t\t\t<app:use>{func_use}</app:use>\r\n\t\t\t\t</app:functionality>"
# --- Synonym XML ---
synonym_xml = ""
if synonym:
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
# пишет <Vendor/>, а не <Vendor></Vendor>.
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
class_ids = [
"9cd510cd-abfc-11d4-9434-004095e12fc7",
"9fcd25a0-4822-11d4-9414-008048da11f9",
"e3687481-0a87-462c-a166-9f34594f9bba",
"9de14907-ec23-4a07-96f0-85521cb6b53b",
"51f2d5d8-ea4d-4064-8892-82951750031e",
"e68182ea-4237-4383-967f-90c1e3370bc7",
"fb282519-d103-4dd3-bc12-cb271d631dfc",
]
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
pal_ns = ""
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
if is_221:
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
f221_aux_forms = "\r\n" + "\r\n".join(
f"\t\t\t{t}" for t in (
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
"</MainClientApplicationWindowInterfaceVariant>"
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
"</ClientApplicationWindowsOpenVariant>")
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
"</Version85InterfaceMigrationMode>")
contained_objects = ""
for i in range(7):
contained_objects += f"""\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
\t\t\t\t<xr:ObjectId>{co[i]}</xr:ObjectId>
\t\t\t</xr:ContainedObject>\n"""
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="{args.FormatVersion}">
\t<Configuration uuid="{uuid_cfg}">
\t\t<InternalInfo>
{contained_objects}\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>{synonym_xml}</Synonym>
\t\t\t<Comment/>
\t\t\t<NamePrefix/>
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
\t\t\t<UsePurposes>
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
\t\t\t</UsePurposes>
\t\t\t<ScriptVariant>Russian</ScriptVariant>
\t\t\t<DefaultRoles/>
\t\t\t{vendor_el}
\t\t\t{version_el}
\t\t\t<UpdateCatalogAddress/>
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
\t\t\t<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
\t\t\t<AdditionalFullTextSearchDictionaries/>
\t\t\t<CommonSettingsStorage/>
\t\t\t<ReportsUserSettingsStorage/>
\t\t\t<ReportsVariantsStorage/>
\t\t\t<FormDataSettingsStorage/>
\t\t\t<DynamicListsUserSettingsStorage/>
\t\t\t<URLExternalDataStorage/>
\t\t\t<Content/>
\t\t\t<DefaultReportForm/>
\t\t\t<DefaultReportVariantForm/>
\t\t\t<DefaultReportSettingsForm/>
\t\t\t<DefaultReportAppearanceTemplate/>
\t\t\t<DefaultDynamicListSettingsForm/>
\t\t\t<DefaultSearchForm/>
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
\t\t\t<DefaultDataHistoryVersionDataForm/>
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
\t\t\t<RequiredMobileApplicationPermissions/>
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
\t\t\t</UsedMobileApplicationFunctionalities>
\t\t\t<StandaloneConfigurationRestrictionRoles/>
\t\t\t<MobileApplicationURLs/>
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
\t\t\t<DefaultInterface/>{f221_captions}
\t\t\t<DefaultStyle/>
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
\t\t\t<BriefInformation/>
\t\t\t<DetailedInformation/>
\t\t\t<Copyright/>
\t\t\t<VendorInformationAddress/>
\t\t\t<ConfigurationInformationAddress/>
\t\t\t<DataLockControlMode>Managed</DataLockControlMode>
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
\t\t\t<DefaultConstantsForm/>
\t\t</Properties>
\t\t<ChildObjects>
\t\t\t<Language>Русский</Language>
\t\t</ChildObjects>
\t</Configuration>
</MetaDataObject>'''
# --- Languages/Русский.xml ---
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="{args.FormatVersion}">
\t<Language uuid="{uuid_lang}">
\t\t<Properties>
\t\t\t<Name>Русский</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>Русский</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<LanguageCode>ru</LanguageCode>
\t\t</Properties>
\t</Language>
</MetaDataObject>'''
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
# via panelDef but not placed by default. Without this file the web client renders
# section icons without labels (icon-only mode).
open_panel_inst = new_uuid()
sections_panel_inst = new_uuid()
cai_xml = f'''<?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">
\t<top>
\t\t<panel id="{open_panel_inst}">
\t\t\t<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
\t\t</panel>
\t</top>
\t<left>
\t\t<panel id="{sections_panel_inst}">
\t\t\t<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
\t\t</panel>
\t</left>
\t<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
\t<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
\t<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
\t<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
\t<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
</ClientApplicationInterface>'''
# --- Create directories ---
os.makedirs(output_dir, exist_ok=True)
lang_dir = os.path.join(output_dir, "Languages")
os.makedirs(lang_dir, exist_ok=True)
ext_dir = os.path.join(output_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
# --- Write files ---
write_xml_file(cfg_file, cfg_xml)
lang_file = os.path.join(lang_dir, "Русский.xml")
write_xml_file(lang_file, lang_xml)
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
write_xml_file(cai_file, cai_xml)
print(f"[OK] Создана конфигурация: {name}")
print(f" Каталог: {output_dir}")
print(f" Configuration.xml: {cfg_file}")
print(f" Languages: {lang_file}")
print(f" Ext/CAI: {cai_file}")
if __name__ == '__main__':
main()
@@ -24,6 +24,6 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty" powershell.exe -NoProfile -File ".agents/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml" powershell.exe -NoProfile -File ".agents/skills/cf-validate/scripts/cf-validate.ps1" -ConfigPath "upload/cfempty/Configuration.xml"
``` ```
@@ -1,7 +1,8 @@
# cf-validate v1.5 — Validate 1C configuration root structure # cf-validate v1.9 — Validate 1C configuration root structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ConfigPath, [string]$ConfigPath,
@@ -89,6 +90,19 @@ $finalize = {
} }
} }
# --- Format version ---
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Reference tables --- # --- Reference tables ---
$guidPattern = '^[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}$' $guidPattern = '^[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}$'
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$' $identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
@@ -108,10 +122,10 @@ $validClassIds = @(
$childObjectTypes = @( $childObjectTypes = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -126,6 +140,7 @@ $childTypeDirMap = @{
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules" "CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
"Bot"="Bots" "Bot"="Bots"
"PaletteColor"="PaletteColors"
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages" "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences" "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs" "EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
@@ -203,11 +218,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
} }
$version = $root.GetAttribute("version") $version = $root.GetAttribute("version")
$versionRank = Get-FormatRank $version
if (-not $version) { if (-not $version) {
Report-Warn "1. Missing version attribute on MetaDataObject" Report-Warn "1. Missing version attribute on MetaDataObject"
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) { } elseif ($versionRank -eq 0) {
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). Report-Error "1. Malformed version '$version' (expected N.N)"
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)" } elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} }
# Must have Configuration child # Must have Configuration child
@@ -1,10 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cf-validate v1.5 — Validate 1C configuration XML structure # cf-validate v1.9 — Validate 1C configuration XML structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages.""" """Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
import sys, os, argparse, re import sys, os, argparse, re
from lxml import etree from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
NS = { NS = {
'md': 'http://v8.1c.ru/8.3/MDClasses', 'md': 'http://v8.1c.ru/8.3/MDClasses',
'v8': 'http://v8.1c.ru/8.1/data/core', 'v8': 'http://v8.1c.ru/8.1/data/core',
@@ -37,10 +59,10 @@ VALID_CLASS_IDS = [
CHILD_OBJECT_TYPES = [ CHILD_OBJECT_TYPES = [
'Language', 'Subsystem', 'StyleItem', 'Style', 'Language', 'Subsystem', 'StyleItem', 'Style',
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate', 'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan', 'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference', 'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption', 'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup', 'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
'Constant', 'CommonForm', 'Catalog', 'Document', 'Constant', 'CommonForm', 'Catalog', 'Document',
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum', 'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister', 'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
@@ -54,7 +76,7 @@ CHILD_TYPE_DIR_MAP = {
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles', 'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles', 'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules', 'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
'Bot': 'Bots', 'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages', 'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences', 'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs', 'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
@@ -110,6 +132,20 @@ VALID_ENUM_VALUES = {
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses' EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
class Reporter: class Reporter:
def __init__(self, max_errors, detailed=False): def __init__(self, max_errors, detailed=False):
@@ -170,7 +206,7 @@ def main():
parser.add_argument('-Detailed', action='store_true') parser.add_argument('-Detailed', action='store_true')
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30) parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
parser.add_argument('-OutFile', dest='OutFile', default='') parser.add_argument('-OutFile', dest='OutFile', default='')
args = parser.parse_args() args = ci_parse_args(parser)
config_path = args.ConfigPath config_path = args.ConfigPath
max_errors = args.MaxErrors max_errors = args.MaxErrors
@@ -230,11 +266,17 @@ def main():
check1_ok = False check1_ok = False
version = root.get('version', '') version = root.get('version', '')
version_rank = format_rank(version)
if not version: if not version:
r.warn('1. Missing version attribute on MetaDataObject') r.warn('1. Missing version attribute on MetaDataObject')
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'): elif version_rank == 0:
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). r.error(f"1. Malformed version '{version}' (expected N.N)")
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)") elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
r.warn(f"1. Format version '{version}' is below the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
r.warn(f"1. Format version '{version}' is above the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
# Must have Configuration child # Must have Configuration child
cfg_node = None cfg_node = None
@@ -31,6 +31,7 @@ allowed-tools:
| `ExtensionPath` | Путь к каталогу расширения (обязат.) | | `ExtensionPath` | Путь к каталогу расширения (обязат.) |
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) | | `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
| `Object` | Что заимствовать (обязат.), batch через `;;` | | `Object` | Что заимствовать (обязат.), batch через `;;` |
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object | | `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
## Формат -Object ## Формат -Object
@@ -41,7 +42,6 @@ allowed-tools:
- `Enum.ВидыОплат` — перечисление - `Enum.ВидыОплат` — перечисление
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы) - `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов - `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
Поддерживаются все 44 типа объектов конфигурации.
### Заимствование форм ### Заимствование форм
@@ -66,36 +66,44 @@ allowed-tools:
2. `/meta-edit` — добавить новый реквизит в объект расширения 2. `/meta-edit` — добавить новый реквизит в объект расширения
3. `/form-edit` — вывести реквизит на заимствованную форму 3. `/form-edit` — вывести реквизит на заимствованную форму
**Защита существующих данных**: если зависимый объект уже заимствован с содержимым (реквизитами, формами) — скрипт не перезаписывает его, а добавляет только недостающее. **Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты" powershell.exe -NoProfile -File ".agents/skills/cfe-borrow/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
``` ```
## Примеры ## Примеры
```powershell ```powershell
# Заимствовать один объект # Заимствовать один объект
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты" ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
# Заимствовать справочник вместе с модулями объекта и менеджера
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты" -Module ObjectModule,ManagerModule
# Общий модуль без файла модуля
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "CommonModule.РаботаСФайлами" -Module None
# Заимствовать форму (автоматически заимствует родительский объект) # Заимствовать форму (автоматически заимствует родительский объект)
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты.Form.ФормаЭлемента" ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
# Несколько объектов за раз # Несколько объектов за раз
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат" ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы) # Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
# Заимствовать форму с ВСЕМИ реквизитами объекта # Заимствовать форму с ВСЕМИ реквизитами объекта
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
``` ```
## Верификация ## Верификация
``` ```
/cfe-validate <ExtensionPath> /cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
``` ```
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
@@ -23,7 +23,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A powershell.exe -NoProfile -File ".agents/skills/cfe-diff/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
``` ```
## Mode A — обзор расширения ## Mode A — обзор расширения
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
```powershell ```powershell
# Обзор — что изменено в расширении # Обзор — что изменено в расширении
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
# Проверка переноса — все ли #Вставка перенесены # Проверка переноса — все ли #Вставка перенесены
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
``` ```
@@ -1,7 +1,8 @@
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE) # cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[string]$ExtensionPath, [string]$ExtensionPath,
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -49,7 +50,10 @@ $childTypeDirMap = @{
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria" "SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators" "CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices" "Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
"CommonAttribute"="CommonAttributes" "CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
"Bot"="Bots"
"PaletteColor"="PaletteColors"
} }
# --- Parse extension Configuration.xml --- # --- Parse extension Configuration.xml ---
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE) # cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -8,6 +8,28 @@ import re
import sys import sys
from lxml import etree from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# --- Namespace maps --- # --- Namespace maps ---
MD_NSMAP = { MD_NSMAP = {
@@ -60,6 +82,13 @@ CHILD_TYPE_DIR_MAP = {
"Sequence": "Sequences", "Sequence": "Sequences",
"IntegrationService": "IntegrationServices", "IntegrationService": "IntegrationServices",
"CommonAttribute": "CommonAttributes", "CommonAttribute": "CommonAttributes",
"Style": "Styles",
"XDTOPackage": "XDTOPackages",
"WebService": "WebServices",
"HTTPService": "HTTPServices",
"WSReference": "WSReferences",
"Bot": "Bots",
"PaletteColor": "PaletteColors",
} }
@@ -468,7 +497,7 @@ def main():
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root") parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root") parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check") parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
args = parser.parse_args() args = ci_parse_args(parser)
extension_path = args.ExtensionPath extension_path = args.ExtensionPath
config_path = args.ConfigPath config_path = args.ConfigPath
@@ -33,39 +33,39 @@ allowed-tools:
| `Name` | Имя расширения (обязат.) | — | | `Name` | Имя расширения (обязат.) | — |
| `Synonym` | Синоним | = Name | | `Synonym` | Синоним | = Name |
| `NamePrefix` | Префикс собственных объектов | = Name + "_" | | `NamePrefix` | Префикс собственных объектов | = Name + "_" |
| `OutputDir` | Каталог для создания | `src` | | `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` | | `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
| `Version` | Версия расширения | — | | `Version` | Версия расширения | — |
| `Vendor` | Поставщик | — | | `Vendor` | Поставщик | — |
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` | | `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — | | `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
| `NoRole` | Без основной роли | false | | `NoRole` | Без основной роли | false |
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/cfe-init/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
``` ```
## Примеры ## Примеры
```powershell ```powershell
# Расширение для ERP с авто-определением совместимости из базовой конфигурации # Расширение для ERP с авто-определением совместимости из базовой конфигурации
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src ... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
# Расширение-исправление с явным режимом совместимости # Расширение-исправление с явным режимом совместимости
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src ... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
# Расширение-доработка с версией # Расширение-доработка с версией
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src ... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
# Без роли, с явным префиксом # Без роли, с явным префиксом
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src ... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
``` ```
## Верификация ## Верификация
``` ```
/cfe-validate <OutputDir> /cfe-validate <OutputDir> -ConfigPath <ConfigPath>
``` ```
@@ -1,5 +1,6 @@
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE) # cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$Name, [string]$Name,
@@ -16,6 +17,12 @@ param(
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Default NamePrefix --- # --- Default NamePrefix ---
@@ -121,20 +128,23 @@ $co7 = [guid]::NewGuid().ToString()
# --- Synonym XML --- # --- Synonym XML ---
$synonymXml = "" $synonymXml = ""
if ($Synonym) { if ($Synonym) {
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t" $synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
} }
# --- Optional properties --- # --- Optional properties ---
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" } # Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" } # пишет <Vendor/>, а не <Vendor></Vendor>.
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
# --- Role name --- # --- Role name ---
$roleName = "${NamePrefix}ОсновнаяРоль" $roleName = "${NamePrefix}ОсновнаяРоль"
# --- DefaultRoles XML --- # --- DefaultRoles XML ---
$defaultRolesXml = "" # Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
$defaultRolesEl = "<DefaultRoles/>"
if (-not $NoRole) { if (-not $NoRole) {
$defaultRolesXml = "`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t" $defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
} }
# --- ChildObjects --- # --- ChildObjects ---
@@ -144,10 +154,32 @@ if (-not $NoRole) {
} }
$childObjectsXml += "`r`n`t`t" $childObjectsXml += "`r`n`t`t"
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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"'
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
$f221Captions = ""
if ((Get-FormatRank $formatVersion) -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
}
# --- Configuration.xml --- # --- Configuration.xml ---
$cfgXml = @" $cfgXml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$formatVersion"> <MetaDataObject $xmlnsDecl version="$formatVersion">
<Configuration uuid="$uuidCfg"> <Configuration uuid="$uuidCfg">
<InternalInfo> <InternalInfo>
<xr:ContainedObject> <xr:ContainedObject>
@@ -181,21 +213,21 @@ $cfgXml = @"
</InternalInfo> </InternalInfo>
<Properties> <Properties>
<ObjectBelonging>Adopted</ObjectBelonging> <ObjectBelonging>Adopted</ObjectBelonging>
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name> <Name>$(Esc-XmlText ($Name))</Name>
<Synonym>$synonymXml</Synonym> <Synonym>$synonymXml</Synonym>
<Comment/> <Comment/>
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose> <ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs> <KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
<NamePrefix>$([System.Security.SecurityElement]::Escape($NamePrefix))</NamePrefix> <NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode> <ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
<DefaultRunMode>ManagedApplication</DefaultRunMode> <DefaultRunMode>ManagedApplication</DefaultRunMode>
<UsePurposes> <UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value> <v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
</UsePurposes> </UsePurposes>
<ScriptVariant>Russian</ScriptVariant> <ScriptVariant>Russian</ScriptVariant>
<DefaultRoles>$defaultRolesXml</DefaultRoles> $defaultRolesEl
<Vendor>$vendorXml</Vendor> $vendorEl
<Version>$versionXml</Version> $versionEl$f221Captions
<DefaultLanguage>Language.Русский</DefaultLanguage> <DefaultLanguage>Language.Русский</DefaultLanguage>
<BriefInformation/> <BriefInformation/>
<DetailedInformation/> <DetailedInformation/>
@@ -212,7 +244,7 @@ $cfgXml = @"
# --- Languages/Русский.xml (adopted format) --- # --- Languages/Русский.xml (adopted format) ---
$langXml = @" $langXml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$formatVersion"> <MetaDataObject $xmlnsDecl version="$formatVersion">
<Language uuid="$uuidLang"> <Language uuid="$uuidLang">
<InternalInfo/> <InternalInfo/>
<Properties> <Properties>
@@ -229,10 +261,10 @@ $langXml = @"
# --- Role XML --- # --- Role XML ---
$roleXml = @" $roleXml = @"
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="$formatVersion"> <MetaDataObject $xmlnsDecl version="$formatVersion">
<Role uuid="$uuidRole"> <Role uuid="$uuidRole">
<Properties> <Properties>
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name> <Name>$(Esc-XmlText ($roleName))</Name>
<Synonym/> <Synonym/>
<Comment/> <Comment/>
</Properties> </Properties>
@@ -252,9 +284,18 @@ if (-not (Test-Path $langDir)) {
# --- Write files with UTF-8 BOM --- # --- Write files with UTF-8 BOM ---
$enc = New-Object System.Text.UTF8Encoding($true) $enc = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc) # XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $cfgFile $cfgXml $enc
$langFile = Join-Path $langDir "Русский.xml" $langFile = Join-Path $langDir "Русский.xml"
[System.IO.File]::WriteAllText($langFile, $langXml, $enc) Write-XmlFile $langFile $langXml $enc
# --- Role --- # --- Role ---
if (-not $NoRole) { if (-not $NoRole) {
@@ -263,7 +304,7 @@ if (-not $NoRole) {
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
} }
$roleFile = Join-Path $roleDir "$roleName.xml" $roleFile = Join-Path $roleDir "$roleName.xml"
[System.IO.File]::WriteAllText($roleFile, $roleXml, $enc) Write-XmlFile $roleFile $roleXml $enc
} }
# --- Output --- # --- Output ---
@@ -1,20 +1,62 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE) # cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C configuration extension.""" """Generates minimal XML source files for a 1C configuration extension."""
import sys, os, argparse, uuid import sys, os, re, argparse, uuid
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
def esc_xml(s): # Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
return s.replace('&','&amp;').replace('<','&lt;').replace('>','&gt;').replace('"','&quot;') # регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid(): def new_uuid():
return str(uuid.uuid4()) return str(uuid.uuid4())
def write_utf8_bom(path, content): def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f: with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content) f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def main(): def main():
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8")
@@ -29,7 +71,7 @@ def main():
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24') parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None) parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
parser.add_argument('-NoRole', dest='NoRole', action='store_true') parser.add_argument('-NoRole', dest='NoRole', action='store_true')
args = parser.parse_args() args = ci_parse_args(parser)
name = args.Name name = args.Name
synonym = args.Synonym if args.Synonym else name synonym = args.Synonym if args.Synonym else name
@@ -126,18 +168,23 @@ def main():
# --- Synonym XML --- # --- Synonym XML ---
synonym_xml = "" synonym_xml = ""
if synonym: if synonym:
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t" synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
vendor_xml = esc_xml(vendor) if vendor else "" # Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
version_xml = esc_xml(version) if version else "" # пишет <Vendor/>, а не <Vendor></Vendor>.
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
# --- Role name --- # --- Role name ---
role_name = f"{name_prefix}ОсновнаяРоль" role_name = f"{name_prefix}ОсновнаяРоль"
# --- DefaultRoles XML --- # --- DefaultRoles XML ---
default_roles_xml = "" # Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
default_roles_el = "<DefaultRoles/>"
if not args.NoRole: if not args.NoRole:
default_roles_xml = f'\r\n\t\t\t\t<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>\r\n\t\t\t' default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
'\r\n\t\t\t</DefaultRoles>')
# --- ChildObjects --- # --- ChildObjects ---
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>" child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
@@ -156,6 +203,40 @@ def main():
] ]
contained_objects = "" contained_objects = ""
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' 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"'
)
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
f221_captions = ""
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
for i in range(7): for i in range(7):
contained_objects += f"""\t\t\t<xr:ContainedObject> contained_objects += f"""\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId> \t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
@@ -163,27 +244,27 @@ def main():
\t\t\t</xr:ContainedObject>\n""" \t\t\t</xr:ContainedObject>\n"""
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?> cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="{format_version}"> <MetaDataObject {xmlns_decl} version="{format_version}">
\t<Configuration uuid="{uuid_cfg}"> \t<Configuration uuid="{uuid_cfg}">
\t\t<InternalInfo> \t\t<InternalInfo>
{contained_objects}\t\t</InternalInfo> {contained_objects}\t\t</InternalInfo>
\t\t<Properties> \t\t<Properties>
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging> \t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
\t\t\t<Name>{esc_xml(name)}</Name> \t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>{synonym_xml}</Synonym> \t\t\t<Synonym>{synonym_xml}</Synonym>
\t\t\t<Comment/> \t\t\t<Comment/>
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose> \t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs> \t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
\t\t\t<NamePrefix>{esc_xml(name_prefix)}</NamePrefix> \t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode> \t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode> \t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
\t\t\t<UsePurposes> \t\t\t<UsePurposes>
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value> \t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
\t\t\t</UsePurposes> \t\t\t</UsePurposes>
\t\t\t<ScriptVariant>Russian</ScriptVariant> \t\t\t<ScriptVariant>Russian</ScriptVariant>
\t\t\t<DefaultRoles>{default_roles_xml}</DefaultRoles> \t\t\t{default_roles_el}
\t\t\t<Vendor>{vendor_xml}</Vendor> \t\t\t{vendor_el}
\t\t\t<Version>{version_xml}</Version> \t\t\t{version_el}{f221_captions}
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage> \t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
\t\t\t<BriefInformation/> \t\t\t<BriefInformation/>
\t\t\t<DetailedInformation/> \t\t\t<DetailedInformation/>
@@ -198,7 +279,7 @@ def main():
# --- Languages/Русский.xml (adopted format) --- # --- Languages/Русский.xml (adopted format) ---
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?> lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="{format_version}"> <MetaDataObject {xmlns_decl} version="{format_version}">
\t<Language uuid="{uuid_lang}"> \t<Language uuid="{uuid_lang}">
\t\t<InternalInfo/> \t\t<InternalInfo/>
\t\t<Properties> \t\t<Properties>
@@ -213,10 +294,10 @@ def main():
# --- Role XML --- # --- Role XML ---
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?> role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="{format_version}"> <MetaDataObject {xmlns_decl} version="{format_version}">
\t<Role uuid="{uuid_role}"> \t<Role uuid="{uuid_role}">
\t\t<Properties> \t\t<Properties>
\t\t\t<Name>{esc_xml(role_name)}</Name> \t\t\t<Name>{esc_xml_text(role_name)}</Name>
\t\t\t<Synonym/> \t\t\t<Synonym/>
\t\t\t<Comment/> \t\t\t<Comment/>
\t\t</Properties> \t\t</Properties>
@@ -229,9 +310,9 @@ def main():
os.makedirs(lang_dir, exist_ok=True) os.makedirs(lang_dir, exist_ok=True)
# --- Write files --- # --- Write files ---
write_utf8_bom(cfg_file, cfg_xml) write_xml_file(cfg_file, cfg_xml)
lang_file = os.path.join(lang_dir, "Русский.xml") lang_file = os.path.join(lang_dir, "Русский.xml")
write_utf8_bom(lang_file, lang_xml) write_xml_file(lang_file, lang_xml)
# --- Role --- # --- Role ---
role_file = None role_file = None
@@ -239,7 +320,7 @@ def main():
role_dir = os.path.join(output_dir, "Roles") role_dir = os.path.join(output_dir, "Roles")
os.makedirs(role_dir, exist_ok=True) os.makedirs(role_dir, exist_ok=True)
role_file = os.path.join(role_dir, f"{role_name}.xml") role_file = os.path.join(role_dir, f"{role_name}.xml")
write_utf8_bom(role_file, role_xml) write_xml_file(role_file, role_xml)
# --- Output --- # --- Output ---
print(f"[OK] Создано расширение: {name}") print(f"[OK] Создано расширение: {name}")
@@ -88,7 +88,7 @@ allowed-tools:
Правила: Правила:
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`). - Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`). - **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Дословно — включая комментарии, регистр и пробелы внутри строки (`Х = Х + 1``Х=Х+1`); свободны только отступ и пустые строки. Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай. - Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
## Актуализация ## Актуализация
@@ -110,36 +110,36 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before powershell.exe -NoProfile -File ".agents/skills/cfe-patch-method/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
``` ```
## Примеры ## Примеры
```powershell ```powershell
# Код перед записью # Код перед записью
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
# Перехват После на форме # Перехват После на форме
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
# Замена функции (ПродолжитьВызов) # Замена функции (ПродолжитьВызов)
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
# ИзменениеИКонтроль — копия тела для правки маркерами # ИзменениеИКонтроль — копия тела для правки маркерами
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
# ModulePath как путь к файлу модуля-источника (без -ConfigPath) # ModulePath как путь к файлу модуля-источника (без -ConfigPath)
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead ... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
# Проверить все контролируемые методы расширения на дрейф # Проверить все контролируемые методы расширения на дрейф
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
# Актуализировать дрейфнувшие контролируемые методы пачкой # Актуализировать дрейфнувшие контролируемые методы пачкой
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize ... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
``` ```
## Верификация ## Верификация
``` ```
/cfe-validate <ExtensionPath> /cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
``` ```
@@ -1,5 +1,6 @@
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE) # cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$ExtensionPath, [string]$ExtensionPath,
@@ -361,6 +362,22 @@ function Get-Normalized {
return (($line -replace '\s+', ' ').Trim()) return (($line -replace '\s+', ' ').Trim())
} }
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
function Get-ControlKey {
param($lines)
return (@($lines | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) -join "`n")
}
# Parameter count of a signature params text. The platform compares only the number of
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
function Get-ParamCount {
param([string]$paramsText)
if ([string]::IsNullOrWhiteSpace($paramsText)) { return 0 }
return @(Split-TopLevel $paramsText | Where-Object { $_.Trim() -ne '' }).Count
}
# Reconstruct v1 body and edit ops from a marked body # Reconstruct v1 body and edit ops from a marked body
function Parse-MarkedBody { function Parse-MarkedBody {
param($bodyLines) param($bodyLines)
@@ -650,7 +667,14 @@ function Invoke-Resync {
$v1norm = @($v1 | ForEach-Object { Get-Normalized $_ }) $v1norm = @($v1 | ForEach-Object { Get-Normalized $_ })
$v2norm = @($v2 | ForEach-Object { Get-Normalized $_ }) $v2norm = @($v2 | ForEach-Object { Get-Normalized $_ })
if (($v1norm -join "`n") -eq ($v2norm -join "`n")) { # Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
$extParamCount = Get-ParamCount $sig.ParamsText
$srcParamCount = Get-ParamCount $method.ParamsText
$paramsDrift = ($extParamCount -ne $srcParamCount)
$paramsReason = if ($paramsDrift) { "список параметров: в оригинале $srcParamCount, в перехватчике $extParamCount" } else { '' }
if (-not $paramsDrift -and [string]::Equals((Get-ControlKey $v1), (Get-ControlKey $v2), 'Ordinal')) {
return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl } return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl }
} }
@@ -700,7 +724,9 @@ function Invoke-Resync {
if ($ReportOnly) { if ($ReportOnly) {
$st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' } $st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' }
if ($paramsDrift -and $st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { $st = 'ДРЕЙФ' }
$rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' } $rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' }
if ($paramsDrift) { $rsn = if ($rsn) { "$paramsReason; $rsn" } else { $paramsReason } }
return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes } return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes }
} }
@@ -788,6 +814,100 @@ if (Test-Path $ExtensionPath -PathType Leaf) { $ExtensionPath = Split-Path $Exte
$cfgFile = Join-Path $ExtensionPath "Configuration.xml" $cfgFile = Join-Path $ExtensionPath "Configuration.xml"
if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 } if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 }
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
function Detect-FormatVersion([string]$dir) {
$d = $dir
while ($d) {
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
$extPath = "$d.xml"
if (Test-Path $extPath) {
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$cfgPath = Join-Path $d "Configuration.xml"
if (Test-Path $cfgPath) {
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$parent = Split-Path $d -Parent
if ($parent -eq $d) { break }
$d = $parent
}
return "2.17"
}
function Build-PropertyStateXml {
param([string]$propertyName, [string]$indent)
$sb = New-Object System.Text.StringBuilder
$sb.AppendLine("${indent}<xr:PropertyState>") | Out-Null
$sb.AppendLine("${indent}`t<xr:Property>${propertyName}</xr:Property>") | Out-Null
$sb.AppendLine("${indent}`t<xr:State>Extended</xr:State>") | Out-Null
$sb.Append("${indent}</xr:PropertyState>") | Out-Null
return $sb.ToString()
}
function Set-PropertyStateFlag {
param([string]$objFile, [string]$propertyName, [string]$formatVersion)
if ((Get-FormatRank $formatVersion) -lt 219) { return }
if (-not (Test-Path $objFile)) { return }
$enc = New-Object System.Text.UTF8Encoding($true)
$text = [System.IO.File]::ReadAllText($objFile, $enc)
$nl = if ($text -match "`r`n") { "`r`n" } else { "`n" }
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
$empty = [regex]::Match($text, '([ \t]*)<InternalInfo\s*/>')
$open = [regex]::Match($text, '(?s)([ \t]*)<InternalInfo>(.*?)</InternalInfo>')
if ($empty.Success -and (-not $open.Success -or $empty.Index -lt $open.Index)) {
$ind = $empty.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
$replacement = "${ind}<InternalInfo>${nl}${block}${nl}${ind}</InternalInfo>"
$text = $text.Remove($empty.Index, $empty.Length).Insert($empty.Index, $replacement)
} elseif ($open.Success) {
if ($open.Groups[2].Value -match "<xr:Property>$([regex]::Escape($propertyName))</xr:Property>") { return }
$ind = $open.Groups[1].Value
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
$closeAt = $open.Index + $open.Length - "</InternalInfo>".Length - $ind.Length
$text = $text.Insert($closeAt, "${block}${nl}")
} else {
return
}
[System.IO.File]::WriteAllText($objFile, $text, $enc)
}
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
function Get-ModuleFlagTarget {
param([string[]]$relParts, [string]$extRoot)
if ($relParts.Count -ne 4 -or $relParts[2] -ne "Ext") { return $null }
$prop = [System.IO.Path]::GetFileNameWithoutExtension($relParts[3])
return @{
File = (Join-Path (Join-Path $extRoot $relParts[0]) "$($relParts[1]).xml")
Property = $prop
}
}
# --- Read NamePrefix --- # --- Read NamePrefix ---
$cfgDoc = New-Object System.Xml.XmlDocument $cfgDoc = New-Object System.Xml.XmlDocument
$cfgDoc.PreserveWhitespace = $false $cfgDoc.PreserveWhitespace = $false
@@ -1084,6 +1204,12 @@ if ($reuseRegionIdx -ge 0) {
} }
} }
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
$flagTarget = Get-ModuleFlagTarget $relParts $ExtensionPath
if ($flagTarget) {
Set-PropertyStateFlag $flagTarget.File $flagTarget.Property (Detect-FormatVersion $ExtensionPath)
}
Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement" Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement"
Write-Host " Файл: $extBsl" Write-Host " Файл: $extBsl"
Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))" Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))"
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE) # cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -10,6 +10,28 @@ import sys
import tempfile import tempfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
TYPE_DIR_MAP = { TYPE_DIR_MAP = {
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums", "Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors", "CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
@@ -19,6 +41,24 @@ TYPE_DIR_MAP = {
"BusinessProcess": "BusinessProcesses", "Task": "Tasks", "BusinessProcess": "BusinessProcesses", "Task": "Tasks",
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters", "InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters", "AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
# Прощающий ввод: имя каталога принимается наравне с именем типа (Catalogs.X ≡ Catalog.X) —
# PS1-порт так умел с самого начала, PY отставал.
"Catalogs": "Catalogs",
"Documents": "Documents",
"Enums": "Enums",
"CommonModules": "CommonModules",
"Reports": "Reports",
"DataProcessors": "DataProcessors",
"ExchangePlans": "ExchangePlans",
"ChartsOfAccounts": "ChartsOfAccounts",
"ChartsOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
"ChartsOfCalculationTypes": "ChartsOfCalculationTypes",
"BusinessProcesses": "BusinessProcesses",
"Tasks": "Tasks",
"InformationRegisters": "InformationRegisters",
"AccumulationRegisters": "AccumulationRegisters",
"AccountingRegisters": "AccountingRegisters",
"CalculationRegisters": "CalculationRegisters",
} }
# accept plural forms too # accept plural forms too
for _v in list(TYPE_DIR_MAP.values()): for _v in list(TYPE_DIR_MAP.values()):
@@ -35,6 +75,99 @@ CONTEXT_RE = re.compile(
) )
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
def detect_format_version(d):
while d:
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
ext_path = d + ".xml"
if os.path.isfile(ext_path):
with open(ext_path, "r", encoding="utf-8-sig") as f:
ext_head = f.read(2000)
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
if m:
return m.group(1)
cfg_path = os.path.join(d, "Configuration.xml")
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8-sig") as f:
head = f.read(2000)
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
if m:
return m.group(1)
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return "2.17"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def build_property_state_xml(property_name, indent):
return "\n".join([
f"{indent}<xr:PropertyState>",
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
f"{indent}\t<xr:State>Extended</xr:State>",
f"{indent}</xr:PropertyState>",
])
def set_property_state_flag(obj_file, property_name, format_version):
if format_rank(format_version) < 219:
return
if not os.path.isfile(obj_file):
return
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
text = fh.read()
nl = "\r\n" if "\r\n" in text else "\n"
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
# но они лежат ниже, внутри <ChildObjects>.
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
if empty and (not opened or empty.start() < opened.start()):
ind = empty.group(1)
block = build_property_state_xml(property_name, ind + "\t")
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
text = text[:empty.start()] + replacement + text[empty.end():]
elif opened:
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
return
ind = opened.group(1)
block = build_property_state_xml(property_name, ind + "\t")
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
close_at = opened.end() - len("</InternalInfo>") - len(ind)
text = text[:close_at] + block + nl + text[close_at:]
else:
return
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
fh.write(text)
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
def get_module_flag_target(rel_parts, ext_root):
if len(rel_parts) != 4 or rel_parts[2] != "Ext":
return None
prop = os.path.splitext(rel_parts[3])[0]
return {
"file": os.path.join(ext_root, rel_parts[0], f"{rel_parts[1]}.xml"),
"property": prop,
}
def get_module_rel_path(module_path): def get_module_rel_path(module_path):
parts = module_path.split(".") parts = module_path.split(".")
if len(parts) < 2: if len(parts) < 2:
@@ -358,6 +491,21 @@ def normalize(line):
return re.sub(r'\s+', ' ', line).strip() return re.sub(r'\s+', ' ', line).strip()
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
def control_key(lines):
return "\n".join([k for k in (x.strip() for x in lines) if k != ""])
# Parameter count of a signature params text. The platform compares only the number of
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
def param_count(params_text):
if not params_text or not params_text.strip():
return 0
return len([p for p in split_top_level(params_text) if p.strip()])
def parse_marked_body(body_lines): def parse_marked_body(body_lines):
v1 = [] v1 = []
ops = [] ops = []
@@ -539,7 +687,7 @@ def main():
choices=["", "Before", "After", "Instead", "ModificationAndControl"]) choices=["", "Before", "After", "Instead", "ModificationAndControl"])
parser.add_argument("-Check", action="store_true") parser.add_argument("-Check", action="store_true")
parser.add_argument("-Actualize", action="store_true") parser.add_argument("-Actualize", action="store_true")
args = parser.parse_args() args = ci_parse_args(parser)
extension_path = args.ExtensionPath extension_path = args.ExtensionPath
config_path = args.ConfigPath config_path = args.ConfigPath
@@ -801,6 +949,12 @@ def main():
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core) place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core)
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
flag_target = get_module_flag_target(rel_parts, extension_path)
if flag_target:
set_property_state_flag(flag_target["file"], flag_target["property"],
detect_format_version(extension_path))
# emit summary # emit summary
placement = place_new.placement placement = place_new.placement
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement)) print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement))
@@ -990,7 +1144,7 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
sig = read_signature(ext_lines, sig_line_idx) sig = read_signature(ext_lines, sig_line_idx)
if not sig: if not sig:
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"} return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
_params, sig_end = sig ext_params_text, sig_end = sig
is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE)) is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE))
end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE) end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE)
block_end = -1 block_end = -1
@@ -1007,7 +1161,15 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
v1norm = [normalize(x) for x in v1] v1norm = [normalize(x) for x in v1]
v2norm = [normalize(x) for x in v2] v2norm = [normalize(x) for x in v2]
if "\n".join(v1norm) == "\n".join(v2norm): # Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
ext_param_count = param_count(ext_params_text)
src_param_count = param_count(method["params_text"])
params_drift = ext_param_count != src_param_count
params_reason = ("список параметров: в оригинале %d, в перехватчике %d"
% (src_param_count, ext_param_count)) if params_drift else ""
if not params_drift and control_key(v1) == control_key(v2):
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl} return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = [] insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = []
@@ -1069,7 +1231,11 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ" st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
else: else:
st = "ДРЕЙФ" st = "ДРЕЙФ"
if params_drift and st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ":
st = "ДРЕЙФ"
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "") rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
if params_drift:
rsn = ("%s; %s" % (params_reason, rsn)) if rsn else params_reason
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred, return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
"absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes} "absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes}
+40
View File
@@ -0,0 +1,40 @@
---
name: cfe-validate
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
allowed-tools:
- Bash
- Read
- Glob
---
# /cfe-validate — валидация расширения конфигурации (CFE)
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
## Параметры
| Параметр | Обяз. | Умолч. | Описание |
|---------------|:-----:|---------|-------------------------------------------------|
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
| MaxErrors | нет | 30 | Остановиться после N ошибок |
| OutFile | нет | — | Записать результат в файл |
### ConfigPath
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
Если пользователь не указал путь — определи сам:
1. Прочитай `.v8-project.json` из корня проекта
2. Разреши целевую базу (по имени, ветке или `default`)
3. Возьми её поле `configSrc`
## Команда
```powershell
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
```
@@ -1,7 +1,8 @@
# cfe-validate v1.5 — Validate 1C configuration extension structure (CFE) # cfe-validate v1.15 — Validate 1C configuration extension structure (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ExtensionPath, [string]$ExtensionPath,
@@ -9,7 +10,11 @@ param(
[int]$MaxErrors = 30, [int]$MaxErrors = 30,
[string]$OutFile [string]$OutFile,
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
[string]$ConfigPath
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -89,8 +94,42 @@ $finalize = {
} }
} }
# --- Format version ---
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Reference tables --- # --- Reference tables ---
$guidPattern = '^[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}$' # Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
$moduleKindsByType = @{
"CommonModule"=@("Module"); "HTTPService"=@("Module"); "WebService"=@("Module")
"Catalog"=@("ObjectModule","ManagerModule"); "Document"=@("ObjectModule","ManagerModule")
"Report"=@("ObjectModule","ManagerModule"); "DataProcessor"=@("ObjectModule","ManagerModule")
"ExchangePlan"=@("ObjectModule","ManagerModule")
"ChartOfCharacteristicTypes"=@("ObjectModule","ManagerModule")
"ChartOfAccounts"=@("ObjectModule","ManagerModule")
"ChartOfCalculationTypes"=@("ObjectModule","ManagerModule")
"BusinessProcess"=@("ObjectModule","ManagerModule"); "Task"=@("ObjectModule","ManagerModule")
"InformationRegister"=@("RecordSetModule","ManagerModule")
"AccumulationRegister"=@("RecordSetModule","ManagerModule")
"AccountingRegister"=@("RecordSetModule","ManagerModule")
"CalculationRegister"=@("RecordSetModule","ManagerModule")
"Sequence"=@("RecordSetModule","ManagerModule")
"Constant"=@("ValueManagerModule","ManagerModule")
"Enum"=@("ManagerModule"); "DocumentJournal"=@("ManagerModule")
"FilterCriterion"=@("ManagerModule")
}
$guidPattern ='^[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}$'
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$' $identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
# 7 fixed ClassIds for Configuration # 7 fixed ClassIds for Configuration
@@ -104,14 +143,14 @@ $validClassIds = @(
"fb282519-d103-4dd3-bc12-cb271d631dfc" "fb282519-d103-4dd3-bc12-cb271d631dfc"
) )
# 44 types in canonical order # 46 types in canonical order
$childObjectTypes = @( $childObjectTypes = @(
"Language","Subsystem","StyleItem","Style", "Language","Subsystem","StyleItem","Style",
"CommonPicture","SessionParameter","Role","CommonTemplate", "CommonPicture","SessionParameter","Role","CommonTemplate",
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan", "FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
"XDTOPackage","WebService","HTTPService","WSReference", "XDTOPackage","WebService","HTTPService","WSReference",
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption", "EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup", "FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
"Constant","CommonForm","Catalog","Document", "Constant","CommonForm","Catalog","Document",
"DocumentNumerator","Sequence","DocumentJournal","Enum", "DocumentNumerator","Sequence","DocumentJournal","Enum",
"Report","DataProcessor","InformationRegister","AccumulationRegister", "Report","DataProcessor","InformationRegister","AccumulationRegister",
@@ -122,7 +161,7 @@ $childObjectTypes = @(
# Type -> directory mapping # Type -> directory mapping
$childTypeDirMap = @{ $childTypeDirMap = @{
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles" "Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"; "PaletteColor"="PaletteColors"
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles" "CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules" "CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages" "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
@@ -144,6 +183,46 @@ $childTypeDirMap = @{
"IntegrationService"="IntegrationServices" "IntegrationService"="IntegrationServices"
} }
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
$generatedTypeCategories = @{
"Catalog" = @("Object","Ref","Selection","List","Manager")
"Document" = @("Object","Ref","Selection","List","Manager")
"Enum" = @("Ref","Manager","List")
"Constant" = @("Manager","ValueManager","ValueKey")
"Report" = @("Object","Manager")
"DataProcessor" = @("Object","Manager")
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
"Task" = @("Object","Ref","Selection","List","Manager")
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
"DocumentJournal" = @("Selection","List","Manager")
"Sequence" = @("Record","Manager","RecordSet")
"FilterCriterion" = @("Manager","List")
"SettingsStorage" = @("Manager")
"IntegrationService" = @("Manager")
"WSReference" = @("Manager")
"DefinedType" = @("DefinedType")
}
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
$script:standardObjectFields = @(
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
"Движения","ВерсияДанных","КоличествоСтрок"
)
# Valid enum values for extension properties # Valid enum values for extension properties
$validEnumValues = @{ $validEnumValues = @{
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1") "ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
@@ -195,11 +274,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
} }
$version = $root.GetAttribute("version") $version = $root.GetAttribute("version")
$versionRank = Get-FormatRank $version
if (-not $version) { if (-not $version) {
Report-Warn "1. Missing version attribute on MetaDataObject" Report-Warn "1. Missing version attribute on MetaDataObject"
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) { } elseif ($versionRank -eq 0) {
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). Report-Error "1. Malformed version '$version' (expected N.N)"
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)" } elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} }
# Must have Configuration child # Must have Configuration child
@@ -537,6 +620,7 @@ if ($script:stopped) { & $finalize; exit 1 }
# --- Check 9: Borrowed objects validation + Check 10: Sub-items --- # --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
$script:enumValuesIndex = @{} $script:enumValuesIndex = @{}
$script:borrowedTSIndex = @{}
$script:formList = @() $script:formList = @()
# Helper: check if sub-item has explicit borrowed metadata # Helper: check if sub-item has explicit borrowed metadata
@@ -640,6 +724,25 @@ if ($childObjNode) {
} else { } else {
$borrowedOk++ $borrowedOk++
} }
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
$expectedCats = $generatedTypeCategories[$typeName]
if ($expectedCats) {
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
$foundCats = @{}
if ($objInfo) {
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
$cat = $gt.GetAttribute("category")
if ($cat) { $foundCats[$cat] = $true }
}
}
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
if ($missingCats.Count -gt 0) {
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
$check9Ok = $false
}
}
} }
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) --- # --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
@@ -667,6 +770,12 @@ if ($childObjNode) {
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs) $tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs) $tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" } $tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
if ($tsName) {
$tsKey = "${typeName}.${childName}"
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
}
if (-not $tsInfo) { if (-not $tsInfo) {
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo" Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
$check10Ok = $false $check10Ok = $false
@@ -896,6 +1005,38 @@ foreach ($bf in $script:borrowedFormsWithTree) {
} }
} }
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки ниже на
# таких формах молча не срабатывали. Ищем сначала в <Attributes> самой формы, потом в <BaseForm>.
$rootName = ""
$rootMatch = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
if ($rootMatch.Success) { $rootName = $rootMatch.Groups[1].Value }
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
$acTables = @{}
if ($rootName) {
$rootPat = [regex]::Escape($rootName)
foreach ($m in [regex]::Matches($raw, "<AdditionalColumns table=`"${rootPat}\.(\w+)`"")) {
$acTables[$m.Groups[1].Value] = $true
}
}
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
# поэтому ошибка.
if ($acTables.Count -gt 0) {
$ownerKey = ($ctx -split '\.Form\.')[0]
$ownerTS = $script:borrowedTSIndex[$ownerKey]
foreach ($tblName in $acTables.Keys) {
$depCheckCount++
if (-not $ownerTS -or -not $ownerTS.ContainsKey($tblName)) {
Report-Error "12. ${ctx}: <AdditionalColumns table=`"${rootName}.${tblName}`"> — TabularSection.${tblName} not borrowed in extension"
$check12Ok = $false
}
}
}
foreach ($mi in $missingItems) { foreach ($mi in $missingItems) {
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension" Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
$check12Ok = $false $check12Ok = $false
@@ -931,6 +1072,232 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
Report-OK "13. TypeLink: clean" Report-OK "13. TypeLink: clean"
} }
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
if (-not $ConfigPath) {
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
} else {
$cfgRoot = $ConfigPath
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
} else {
$check14Ok = $true
$pathCheckCount = 0
foreach ($bf in $script:borrowedFormsWithTree) {
$raw = $bf.RawText
$ctx = $bf.Context
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
if (-not $rootMatch14.Success) { continue }
$rootName = $rootMatch14.Groups[1].Value
# У динамического списка набор полей — результат его запроса, а не состав объекта:
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
$ownerKey = ($ctx -split '\.Form\.')[0]
$ownerParts = $ownerKey -split '\.', 2
if ($ownerParts.Count -lt 2) { continue }
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
$ownerDir = $childTypeDirMap[$ownerType]
if (-not $ownerDir) { continue }
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
if (-not (Test-Path $srcObjFile)) {
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
continue
}
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
$srcNames = @{}
$srcTSColumns = @{}
$srcDoc = New-Object System.Xml.XmlDocument
$srcDoc.PreserveWhitespace = $false
$srcDoc.Load($srcObjFile)
$srcObjEl = $null
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
}
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
if ($srcChildObjects) {
foreach ($sub in $srcChildObjects.ChildNodes) {
if ($sub.NodeType -ne 'Element') { continue }
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
if (-not $nameNode) { continue }
$subName = $nameNode.InnerText.Trim()
$srcNames[$subName] = $true
if ($sub.LocalName -ne 'TabularSection') { continue }
$cols = @{}
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
$cols[$colName.InnerText.Trim()] = $true
}
$srcTSColumns[$subName] = $cols
}
}
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
$rootPat14 = [regex]::Escape($rootName)
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
$tbl = $acm.Groups[1].Value
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
}
}
$badPaths = @{}
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
$segments = $m.Groups[1].Value -split '\.'
$seg0 = $segments[0]
$pathCheckCount++
if ($script:standardObjectFields -contains $seg0) { continue }
if (-not $srcNames.ContainsKey($seg0)) {
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
continue
}
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
# он ведёт в чужой объект, и это уже другая проверка.
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
$seg1 = $segments[1]
if ($script:standardObjectFields -contains $seg1) { continue }
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
}
}
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
$check14Ok = $false
}
}
if ($check14Ok) {
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
}
}
}
}
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
$defaultRoleNodes = @($cfgNode.SelectNodes("md:Properties/md:DefaultRoles/xr:Item", $ns))
if ($defaultRoleNodes.Count -gt 0) {
$adoptedCache = @{}
function Test-ObjectAdopted {
param([string]$typeName, [string]$objName)
$key = "$typeName.$objName"
if ($adoptedCache.ContainsKey($key)) { return $adoptedCache[$key] }
$adoptedCache[$key] = $false
if ($childTypeDirMap.ContainsKey($typeName)) {
$objPath = Join-Path (Join-Path $configDir $childTypeDirMap[$typeName]) "$objName.xml"
if (Test-Path $objPath) {
try {
$objDoc = New-Object System.Xml.XmlDocument
$objDoc.Load($objPath)
$objNs = New-Object System.Xml.XmlNamespaceManager($objDoc.NameTable)
$objNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$ob = $objDoc.SelectSingleNode("/md:MetaDataObject/md:$typeName/md:Properties/md:ObjectBelonging", $objNs)
if ($ob -and $ob.InnerText -eq "Adopted") { $adoptedCache[$key] = $true }
} catch {}
}
}
return $adoptedCache[$key]
}
$check15Ok = $true
$check15Count = 0
foreach ($rn in $defaultRoleNodes) {
$roleRef = $rn.InnerText
if ($roleRef -notmatch '^Role\.(.+)$') { continue }
$defRoleName = $Matches[1]
$rightsPath = Join-Path (Join-Path (Join-Path $configDir "Roles") $defRoleName) "Ext\Rights.xml"
if (-not (Test-Path $rightsPath)) { continue }
try {
$rDoc = New-Object System.Xml.XmlDocument
$rDoc.Load($rightsPath)
} catch {
continue
}
$rNs = New-Object System.Xml.XmlNamespaceManager($rDoc.NameTable)
$rNs.AddNamespace("r", "http://v8.1c.ru/8.2/roles")
foreach ($nameNode in $rDoc.SelectNodes("/r:Rights/r:object/r:name", $rNs)) {
$fullName = $nameNode.InnerText
$segs = $fullName.Split(".")
# Configuration.* — права самого расширения, не объект; заимствования там нет.
if ($segs.Count -lt 2 -or $segs[0] -eq "Configuration") { continue }
$check15Count++
if (Test-ObjectAdopted $segs[0] $segs[1]) {
Report-Error ("15. Роль '$defRoleName' входит в DefaultRoles и даёт права на заимствованный $($segs[0]).$($segs[1]) " +
"($fullName): платформа это запрещает. Вынесите такие права в отдельную роль вне DefaultRoles.")
$check15Ok = $false
}
}
}
if ($check15Ok -and $check15Count -gt 0) {
Report-OK "15. Основные роли: прав на заимствованные объекты нет ($check15Count checked)"
}
}
if ($script:stopped) { & $finalize; exit 1 }
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на стенде),
# но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
if ($versionRank -ge 219 -and $childObjNode) {
$stateIssues = @()
$stateChecked = 0
foreach ($child in $childObjNode.ChildNodes) {
if ($child.NodeType -ne 'Element') { continue }
$typeName = $child.LocalName
if (-not $moduleKindsByType.ContainsKey($typeName)) { continue }
if (-not $childTypeDirMap.ContainsKey($typeName)) { continue }
$stateObjName = $child.InnerText.Trim()
if (-not $stateObjName) { continue }
$typeDir = Join-Path $configDir $childTypeDirMap[$typeName]
$objFile = Join-Path $typeDir "$stateObjName.xml"
if (-not (Test-Path $objFile)) { continue }
$objText = [System.IO.File]::ReadAllText($objFile, [System.Text.Encoding]::UTF8)
if ($objText -notmatch '<ObjectBelonging>Adopted</ObjectBelonging>') { continue }
foreach ($kind in $moduleKindsByType[$typeName]) {
$stateChecked++
$hasFile = Test-Path (Join-Path (Join-Path (Join-Path $typeDir $stateObjName) "Ext") "$kind.bsl")
$hasFlag = $objText -match "<xr:Property>$kind</xr:Property>"
if ($hasFile -and -not $hasFlag) {
$stateIssues += "$typeName.$stateObjName — есть $kind.bsl, но нет <xr:PropertyState> для $kind"
} elseif ($hasFlag -and -not $hasFile) {
$stateIssues += "$typeName.$stateObjName — есть <xr:PropertyState> для $kind, но нет $kind.bsl"
}
}
}
if ($stateChecked -gt 0) {
if ($stateIssues.Count -eq 0) {
Report-OK "16. Модули заимствованных объектов: пометки расширенных свойств согласованы ($stateChecked)"
} else {
foreach ($issue in $stateIssues) { Report-Warn "16. $issue" }
}
}
}
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here --- # --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
$extRootDir = Split-Path $resolvedPath -Parent $extRootDir = Split-Path $resolvedPath -Parent
$ctrlCount = 0 $ctrlCount = 0
@@ -1,10 +1,32 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# cfe-validate v1.5 — Validate 1C configuration extension XML structure (CFE) # cfe-validate v1.15 — Validate 1C configuration extension XML structure (CFE)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects.""" """Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
import sys, os, argparse, re import sys, os, argparse, re
from lxml import etree from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
NS = { NS = {
'md': 'http://v8.1c.ru/8.3/MDClasses', 'md': 'http://v8.1c.ru/8.3/MDClasses',
'v8': 'http://v8.1c.ru/8.1/data/core', 'v8': 'http://v8.1c.ru/8.1/data/core',
@@ -33,14 +55,14 @@ VALID_CLASS_IDS = [
'fb282519-d103-4dd3-bc12-cb271d631dfc', 'fb282519-d103-4dd3-bc12-cb271d631dfc',
] ]
# 44 types in canonical order # 46 types in canonical order
CHILD_OBJECT_TYPES = [ CHILD_OBJECT_TYPES = [
'Language', 'Subsystem', 'StyleItem', 'Style', 'Language', 'Subsystem', 'StyleItem', 'Style',
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate', 'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan', 'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference', 'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption', 'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup', 'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
'Constant', 'CommonForm', 'Catalog', 'Document', 'Constant', 'CommonForm', 'Catalog', 'Document',
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum', 'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister', 'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
@@ -49,11 +71,33 @@ CHILD_OBJECT_TYPES = [
'BusinessProcess', 'Task', 'IntegrationService', 'BusinessProcess', 'Task', 'IntegrationService',
] ]
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
MODULE_KINDS_BY_TYPE = {
"CommonModule": ["Module"], "HTTPService": ["Module"], "WebService": ["Module"],
"Catalog": ["ObjectModule", "ManagerModule"], "Document": ["ObjectModule", "ManagerModule"],
"Report": ["ObjectModule", "ManagerModule"], "DataProcessor": ["ObjectModule", "ManagerModule"],
"ExchangePlan": ["ObjectModule", "ManagerModule"],
"ChartOfCharacteristicTypes": ["ObjectModule", "ManagerModule"],
"ChartOfAccounts": ["ObjectModule", "ManagerModule"],
"ChartOfCalculationTypes": ["ObjectModule", "ManagerModule"],
"BusinessProcess": ["ObjectModule", "ManagerModule"], "Task": ["ObjectModule", "ManagerModule"],
"InformationRegister": ["RecordSetModule", "ManagerModule"],
"AccumulationRegister": ["RecordSetModule", "ManagerModule"],
"AccountingRegister": ["RecordSetModule", "ManagerModule"],
"CalculationRegister": ["RecordSetModule", "ManagerModule"],
"Sequence": ["RecordSetModule", "ManagerModule"],
"Constant": ["ValueManagerModule", "ManagerModule"],
"Enum": ["ManagerModule"], "DocumentJournal": ["ManagerModule"],
"FilterCriterion": ["ManagerModule"],
}
# Type -> directory mapping # Type -> directory mapping
CHILD_TYPE_DIR_MAP = { CHILD_TYPE_DIR_MAP = {
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles', 'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles', 'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules', 'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages', 'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences', 'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs', 'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
@@ -73,6 +117,50 @@ CHILD_TYPE_DIR_MAP = {
'IntegrationService': 'IntegrationServices', 'IntegrationService': 'IntegrationServices',
} }
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
GENERATED_TYPE_CATEGORIES = {
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
'Enum': ['Ref', 'Manager', 'List'],
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
'Report': ['Object', 'Manager'],
'DataProcessor': ['Object', 'Manager'],
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
'DocumentJournal': ['Selection', 'List', 'Manager'],
'Sequence': ['Record', 'Manager', 'RecordSet'],
'FilterCriterion': ['Manager', 'List'],
'SettingsStorage': ['Manager'],
'IntegrationService': ['Manager'],
'WSReference': ['Manager'],
'DefinedType': ['DefinedType'],
}
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
MAIN_ATTR_RE = re.compile(
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
STANDARD_OBJECT_FIELDS = {
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
}
# Valid enum values for extension properties # Valid enum values for extension properties
VALID_ENUM_VALUES = { VALID_ENUM_VALUES = {
'ConfigurationExtensionCompatibilityMode': [ 'ConfigurationExtensionCompatibilityMode': [
@@ -94,6 +182,20 @@ VALID_ENUM_VALUES = {
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses' EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
class Reporter: class Reporter:
def __init__(self, max_errors, detailed=False): def __init__(self, max_errors, detailed=False):
@@ -154,11 +256,15 @@ def main():
parser.add_argument('-Detailed', action='store_true') parser.add_argument('-Detailed', action='store_true')
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30) parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
parser.add_argument('-OutFile', dest='OutFile', default='') parser.add_argument('-OutFile', dest='OutFile', default='')
args = parser.parse_args() # Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
args = ci_parse_args(parser)
extension_path = args.ExtensionPath extension_path = args.ExtensionPath
max_errors = args.MaxErrors max_errors = args.MaxErrors
out_file = args.OutFile out_file = args.OutFile
config_path_arg = args.ConfigPath
# --- Resolve path --- # --- Resolve path ---
if not os.path.isabs(extension_path): if not os.path.isabs(extension_path):
@@ -214,11 +320,17 @@ def main():
check1_ok = False check1_ok = False
version = root.get('version', '') version = root.get('version', '')
version_rank = format_rank(version)
if not version: if not version:
r.warn('1. Missing version attribute on MetaDataObject') r.warn('1. Missing version attribute on MetaDataObject')
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'): elif version_rank == 0:
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). r.error(f"1. Malformed version '{version}' (expected N.N)")
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)") elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
r.warn(f"1. Format version '{version}' is below the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
r.warn(f"1. Format version '{version}' is above the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
# Must have Configuration child # Must have Configuration child
cfg_node = None cfg_node = None
@@ -537,6 +649,7 @@ def main():
MD = NS['md'] MD = NS['md']
XR = NS['xr'] XR = NS['xr']
enum_values_index = {} enum_values_index = {}
borrowed_ts_index = {}
form_list = [] form_list = []
def is_borrowed_sub_item(sub_item): def is_borrowed_sub_item(sub_item):
@@ -636,6 +749,23 @@ def main():
else: else:
borrowed_ok_count += 1 borrowed_ok_count += 1
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
if expected_cats:
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
found_cats = set()
if obj_info is not None:
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
cat = gt.get('category')
if cat:
found_cats.add(cat)
missing_cats = [c for c in expected_cats if c not in found_cats]
if missing_cats:
word = 'category' if len(missing_cats) == 1 else 'categories'
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
check9_ok = False
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) --- # --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects') obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
if obj_child_objects is not None: if obj_child_objects is not None:
@@ -663,6 +793,9 @@ def main():
ts_info = sub_item.find(f'{{{MD}}}InternalInfo') ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name') ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?' ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
if ts_name_el is not None and ts_name_el.text:
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
if ts_info is None: if ts_info is None:
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo') r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
check10_ok = False check10_ok = False
@@ -855,6 +988,29 @@ def main():
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}): elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}") missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
# поэтому ошибка.
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
root_match = MAIN_ATTR_RE.search(raw)
root_name = root_match.group(1) if root_match else ""
ac_tables = set()
if root_name:
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
if ac_tables:
owner_key = ctx.split('.Form.')[0]
owner_ts = borrowed_ts_index.get(owner_key, {})
for tbl_name in sorted(ac_tables):
dep_check_count += 1
if tbl_name not in owner_ts:
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
check12_ok = False
for mi in missing_items: for mi in missing_items:
r.warn(f'12. {ctx}: references {mi} not borrowed in extension') r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
check12_ok = False check12_ok = False
@@ -886,6 +1042,228 @@ def main():
elif check13_ok: elif check13_ok:
r.ok('13. TypeLink: clean') r.ok('13. TypeLink: clean')
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
if not r.stopped and borrowed_forms_with_tree:
if not config_path_arg:
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
else:
cfg_root = config_path_arg
if not os.path.isabs(cfg_root):
cfg_root = os.path.join(os.getcwd(), cfg_root)
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
cfg_root = os.path.dirname(cfg_root)
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
else:
check14_ok = True
path_check_count = 0
for bf in borrowed_forms_with_tree:
raw = bf['RawText']
ctx = bf['Context']
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
root_match14 = MAIN_ATTR_RE.search(raw)
if root_match14 is None:
continue
root_name14 = root_match14.group(1)
# У динамического списка набор полей — результат его запроса, а не состав объекта:
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
if '>cfg:DynamicList<' in root_match14.group(0):
continue
owner_key = ctx.split('.Form.')[0]
owner_parts = owner_key.split('.', 1)
if len(owner_parts) < 2:
continue
owner_type, owner_name = owner_parts
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
if not owner_dir:
continue
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
if not os.path.isfile(src_obj_file):
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
continue
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
src_names = set()
src_ts_columns = {}
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
src_obj_el = None
for c in src_tree.getroot():
if isinstance(c.tag, str):
src_obj_el = c
break
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
if src_child_objects is not None:
for sub in src_child_objects:
if not isinstance(sub.tag, str):
continue
sub_ln = etree.QName(sub.tag).localname
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
continue
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
if name_el is None or not name_el.text:
continue
sub_name = name_el.text.strip()
src_names.add(sub_name)
if sub_ln != 'TabularSection':
continue
cols = set()
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
if col_name.text:
cols.add(col_name.text.strip())
src_ts_columns[sub_name] = cols
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
root_pat14 = re.escape(root_name14)
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
tbl = acm.group(1)
cols = src_ts_columns.setdefault(tbl, set())
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
cols.add(cm.group(1))
bad_paths = {}
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
segments = m.group(1).split('.')
seg0 = segments[0]
path_check_count += 1
if seg0 in STANDARD_OBJECT_FIELDS:
continue
if seg0 not in src_names:
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
continue
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
# он ведёт в чужой объект, и это уже другая проверка.
if len(segments) < 2 or seg0 not in src_ts_columns:
continue
seg1 = segments[1]
if seg1 in STANDARD_OBJECT_FIELDS:
continue
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
continue
if seg1 not in src_ts_columns[seg0]:
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
for bad in sorted(bad_paths):
r.error(f"14. {ctx}: путь '{bad}'{bad_paths[bad]}")
check14_ok = False
if check14_ok:
r.ok(f'14. Object paths vs source config: {path_check_count} checked')
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
default_role_nodes = cfg_node.findall('md:Properties/md:DefaultRoles/xr:Item', NS)
if default_role_nodes:
adopted_cache = {}
def is_object_adopted(type_name, obj_name):
key = f"{type_name}.{obj_name}"
if key in adopted_cache:
return adopted_cache[key]
adopted_cache[key] = False
dir_name = CHILD_TYPE_DIR_MAP.get(type_name)
if dir_name:
obj_path = os.path.join(config_dir, dir_name, obj_name + '.xml')
if os.path.isfile(obj_path):
try:
obj_root = etree.parse(obj_path).getroot()
ob = obj_root.find(f'md:{type_name}/md:Properties/md:ObjectBelonging', NS)
if ob is not None and (ob.text or '') == 'Adopted':
adopted_cache[key] = True
except Exception:
pass
return adopted_cache[key]
check15_ok = True
check15_count = 0
roles_ns = {'r': 'http://v8.1c.ru/8.2/roles'}
for rn in default_role_nodes:
m = re.match(r'^Role\.(.+)$', rn.text or '')
if not m:
continue
def_role_name = m.group(1)
rights_path = os.path.join(config_dir, 'Roles', def_role_name, 'Ext', 'Rights.xml')
if not os.path.isfile(rights_path):
continue
try:
rights_root = etree.parse(rights_path).getroot()
except Exception:
continue
for name_node in rights_root.findall('r:object/r:name', roles_ns):
full_name = name_node.text or ''
segs = full_name.split('.')
# Configuration.* — права самого расширения, не объект; заимствования там нет.
if len(segs) < 2 or segs[0] == 'Configuration':
continue
check15_count += 1
if is_object_adopted(segs[0], segs[1]):
r.error(f"15. Роль '{def_role_name}' входит в DefaultRoles и даёт права на заимствованный "
f"{segs[0]}.{segs[1]} ({full_name}): платформа это запрещает. "
"Вынесите такие права в отдельную роль вне DefaultRoles.")
check15_ok = False
if check15_ok and check15_count > 0:
r.ok(f'15. Основные роли: прав на заимствованные объекты нет ({check15_count} checked)')
if r.stopped:
r.finalize(out_file)
sys.exit(1)
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на
# стенде), но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
if version_rank >= 219 and child_obj_node is not None:
state_issues = []
state_checked = 0
for child in child_obj_node:
if not isinstance(child.tag, str):
continue
type_name = etree.QName(child.tag).localname
if type_name not in MODULE_KINDS_BY_TYPE or type_name not in CHILD_TYPE_DIR_MAP:
continue
obj_name_val = (child.text or '').strip()
if not obj_name_val:
continue
type_dir = os.path.join(config_dir, CHILD_TYPE_DIR_MAP[type_name])
obj_file = os.path.join(type_dir, f'{obj_name_val}.xml')
if not os.path.isfile(obj_file):
continue
with open(obj_file, 'r', encoding='utf-8-sig') as f:
obj_text = f.read()
if '<ObjectBelonging>Adopted</ObjectBelonging>' not in obj_text:
continue
for kind in MODULE_KINDS_BY_TYPE[type_name]:
state_checked += 1
has_file = os.path.isfile(os.path.join(type_dir, obj_name_val, 'Ext', f'{kind}.bsl'))
has_flag = f'<xr:Property>{kind}</xr:Property>' in obj_text
if has_file and not has_flag:
state_issues.append(f'{type_name}.{obj_name_val} — есть {kind}.bsl, но нет <xr:PropertyState> для {kind}')
elif has_flag and not has_file:
state_issues.append(f'{type_name}.{obj_name_val} — есть <xr:PropertyState> для {kind}, но нет {kind}.bsl')
if state_checked > 0:
if not state_issues:
r.ok(f'16. Модули заимствованных объектов: пометки расширенных свойств согласованы ({state_checked})')
else:
for issue in state_issues:
r.warn(f'16. {issue}')
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here --- # --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
ctrl_count = 0 ctrl_count = 0
for dp, _dn, files in os.walk(config_dir): for dp, _dn, files in os.walk(config_dir):
@@ -31,7 +31,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-create/scripts/db-create.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -59,14 +59,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
```powershell ```powershell
# Создать файловую базу # Создать файловую базу
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" powershell.exe -NoProfile -File ".agents/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB"
# Создать серверную базу # Создать серверную базу
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" powershell.exe -NoProfile -File ".agents/skills/db-create/scripts/db-create.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
# Создать из шаблона CF # Создать из шаблона CF
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" powershell.exe -NoProfile -File ".agents/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf"
# Создать и добавить в список баз # Создать и добавить в список баз
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база" powershell.exe -NoProfile -File ".agents/skills/db-create/scripts/db-create.ps1" -InfoBasePath "C:\Bases\NewDB" -AddToList -ListName "Новая база"
``` ```
@@ -1,4 +1,4 @@
# db-create v1.10 — Create 1C information base # db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база" .\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-create v1.10 — Create 1C information base # db-create v1.14 — Create 1C information base
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -270,7 +288,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -291,7 +309,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -310,11 +328,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -355,7 +393,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -379,15 +417,15 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate template --- # --- Validate template ---
if args.UseTemplate and not os.path.exists(args.UseTemplate): if args.UseTemplate and not os.path.exists(args.UseTemplate):
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr) print(f"Error: template file not found: {args.UseTemplate}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
@@ -414,10 +452,9 @@ def main():
print( print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} " f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created", "— information base was not created",
file=sys.stderr,
) )
else: else:
print(f"Error creating information base (code: {exit_code})", file=sys.stderr) print(f"Error creating information base (code: {exit_code})")
print_platform_output(result) print_platform_output(result)
sys.exit(exit_code) sys.exit(exit_code)
@@ -474,10 +511,9 @@ def main():
print( print(
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} " f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
"— information base was not created", "— information base was not created",
file=sys.stderr,
) )
else: else:
print(f"Error creating information base (code: {exit_code})", file=sys.stderr) print(f"Error creating information base (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -35,7 +35,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-dump-cf/scripts/db-dump-cf.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -60,11 +60,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
```powershell ```powershell
# Выгрузка конфигурации (файловая база) # Выгрузка конфигурации (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf" powershell.exe -NoProfile -File ".agents/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\config.cf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "config.cf" powershell.exe -NoProfile -File ".agents/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "config.cf"
# Выгрузка расширения # Выгрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/db-dump-cf/scripts/db-dump-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "ext.cfe" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-dump-cf v1.12 — Dump 1C configuration to CF file # db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение" .\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-cf v1.12 — Dump 1C configuration to CF file # db-dump-cf v1.16 — Dump 1C configuration to CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -375,7 +413,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -400,10 +438,10 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -414,7 +452,7 @@ def main():
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config save does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
if args.Extension: if args.Extension:
@@ -437,9 +475,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}") print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -487,9 +525,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration dumped successfully to: {args.OutputFile}") print(f"Configuration dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -38,7 +38,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -61,10 +61,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
```powershell ```powershell
# Выгрузка ИБ (файловая база) # Выгрузка ИБ (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt" powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt" powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
``` ```
## Связанные навыки ## Связанные навыки
@@ -1,4 +1,4 @@
# db-dump-dt v1.11 — Dump 1C information base to DT file # db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -39,7 +39,7 @@
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt" .\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-dt v1.11 — Dump 1C information base to DT file # db-dump-dt v1.15 — Dump 1C information base to DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -373,7 +411,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -398,10 +436,10 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -430,9 +468,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}") print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else: else:
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr) print(f"Error dumping information base (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -474,9 +512,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base dumped successfully to: {args.OutputFile}") print(f"Information base dumped successfully to: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
else: else:
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr) print(f"Error dumping information base (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -33,11 +33,12 @@ allowed-tools:
Если файла нет — предложи `/db-list add`. Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`. Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию. Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -76,17 +77,17 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
```powershell ```powershell
# Полная выгрузка (файловая база) # Полная выгрузка (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Инкрементальная выгрузка # Инкрементальная выгрузка
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Changes powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Changes
# Частичная выгрузка # Частичная выгрузка
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ" powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -ConfigDir "C:\WS\cfsrc" -Mode Full powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Выгрузка расширения # Выгрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/db-dump-xml/scripts/db-dump-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-dump-xml v1.14 — Dump 1C configuration to XML files # db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -61,7 +61,7 @@
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ" .\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -85,8 +85,10 @@ param(
[string]$ConfigDir, [string]$ConfigDir,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")] # Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
[string]$Mode = "Changes", # того как станет видно, перечислены ли объекты.
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
[string]$Mode = "",
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Objects, [string]$Objects,
@@ -101,6 +103,18 @@ param(
[ValidateSet("Hierarchical", "Plain")] [ValidateSet("Hierarchical", "Plain")]
[string]$Format = "Hierarchical", [string]$Format = "Hierarchical",
[Parameter(Mandatory=$false)]
[string]$ObjectsFile,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -111,6 +125,90 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
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 Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
function Protect-Secrets { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -132,7 +230,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -415,8 +513,33 @@ if ($engine -eq "ibcmd") {
} }
# --- Validate Partial mode --- # --- Validate Partial mode ---
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
if ($ObjectsFile) {
if (-not (Test-Path $ObjectsFile)) {
Write-Host "Error: -ObjectsFile not found: $ObjectsFile" -ForegroundColor Red
exit 1
}
$fromFile = @([System.IO.File]::ReadAllLines($ObjectsFile, [System.Text.Encoding]::UTF8) |
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
$Objects = (@(@($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $fromFile) -join ',')
}
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
if ($Objects) {
if ($Mode -eq "UpdateInfo") {
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
Write-Host "Error: -Mode UpdateInfo does not take an object list — it only refreshes ConfigDumpInfo.xml" -ForegroundColor Red
exit 1
}
if ($Mode -eq "Full" -or $Mode -eq "Changes") {
Write-Host "[note] перечислены объекты — выгружаются только они; -Mode $Mode не применён" -ForegroundColor Yellow
}
$Mode = "Partial"
}
if (-not $Mode) { $Mode = "Changes" }
if ($Mode -eq "Partial" -and -not $Objects) { if ($Mode -eq "Partial" -and -not $Objects) {
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
exit 1 exit 1
} }
@@ -486,6 +609,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`"" $arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
$arguments += "-Format", $Format $arguments += "-Format", $Format
@@ -530,7 +658,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-dump-xml v1.14 — Dump 1C configuration to XML files # db-dump-xml v1.21 — Dump 1C configuration to XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -50,10 +72,95 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
def arg_key_match(token, key): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping is not a letter catches glued /N"user" and --password=x, while keeping
@@ -98,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +326,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +364,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +381,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +400,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +453,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -366,14 +489,18 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump") parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
parser.add_argument( parser.add_argument(
"-Mode", "-Mode",
default="Changes", default="",
choices=["Full", "Changes", "Partial", "UpdateInfo"], choices=["", "Full", "Changes", "Partial", "UpdateInfo"],
help="Dump mode (default: Changes)", help="Dump mode (default: Changes)",
) )
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)") parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
parser.add_argument("-ObjectsFile", default="")
parser.add_argument("-Extension", default="", help="Extension name to dump") parser.add_argument("-Extension", default="", help="Extension name to dump")
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions") parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
parser.add_argument( parser.add_argument(
@@ -388,7 +515,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -414,15 +541,40 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate Partial mode --- # --- Validate Partial mode ---
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
if args.ObjectsFile:
if not os.path.exists(args.ObjectsFile):
print("Error: -ObjectsFile not found: %s" % args.ObjectsFile)
sys.exit(1)
with open(args.ObjectsFile, encoding="utf-8-sig") as f:
from_file = [s.strip() for s in f.read().splitlines()
if s.strip() and not s.strip().startswith("#")]
inline = [s.strip() for s in args.Objects.split(",") if s.strip()]
args.Objects = ",".join(inline + from_file)
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
if args.Objects:
if args.Mode == "UpdateInfo":
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
print("Error: -Mode UpdateInfo does not take an object list — it only refreshes "
"ConfigDumpInfo.xml")
sys.exit(1)
if args.Mode in ("Full", "Changes"):
print("[note] перечислены объекты — выгружаются только они; -Mode %s не применён"
% args.Mode)
args.Mode = "Partial"
if not args.Mode:
args.Mode = "Changes"
if args.Mode == "Partial" and not args.Objects: if args.Mode == "Partial" and not args.Objects:
print("Error: -Objects required for Partial mode", file=sys.stderr) print("Error: -Objects or -ObjectsFile required for Partial mode")
sys.exit(1) sys.exit(1)
# --- Create output dir if needed --- # --- Create output dir if needed ---
@@ -433,12 +585,12 @@ def main():
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) --- # --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "UpdateInfo": elif args.Mode == "UpdateInfo":
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr) print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
sys.exit(1) sys.exit(1)
elif args.Mode == "Partial": elif args.Mode == "Partial":
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()] obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
@@ -468,9 +620,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration exported successfully to: {args.ConfigDir}") print(f"Configuration exported successfully to: {args.ConfigDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
else: else:
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr) print(f"Error exporting configuration (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Temp dir --- # --- Temp dir ---
@@ -491,6 +643,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"'] arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
arguments += ["-Format", args.Format] arguments += ["-Format", args.Format]
@@ -529,7 +686,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -542,9 +699,9 @@ def main():
print("Dump completed successfully") print("Dump completed successfully")
print(f"Configuration dumped to: {args.ConfigDir}") print(f"Configuration dumped to: {args.ConfigDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
else: else:
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr) print(f"Error dumping configuration (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -40,7 +40,19 @@ allowed-tools:
"password": "", "password": "",
"aliases": ["dev", "разработка"], "aliases": ["dev", "разработка"],
"branches": ["dev", "develop", "feature/*"], "branches": ["dev", "develop", "feature/*"],
"configSrc": "C:\\WS\\myapp\\cfsrc" "configSrc": "C:\\WS\\myapp\\cfsrc",
"repository": {
"path": "\\\\srv01\\repo\\MyApp",
"user": "Ivanov",
"password": ""
},
"extensions": [
{
"name": "МоёРасширение",
"src": "src\\cfe\\МоёРасширение",
"repository": { "path": "\\\\srv01\\repo\\MyApp_Ext", "user": "Ivanov", "password": "" }
}
]
}, },
{ {
"id": "test", "id": "test",
@@ -64,6 +76,7 @@ allowed-tools:
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение | | `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` | | `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) | | `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
| `newObjectPosition` | `"end"` / `"byName"` | Куда попадает новый объект в составе конфигурации: в конец своего вида (по умолчанию) или на место по имени. Переопределяется в `databases[]` |
| `databases` | array | Массив баз данных | | `databases` | array | Массив баз данных |
| `default` | string | id базы по умолчанию | | `default` | string | id базы по умолчанию |
@@ -82,6 +95,35 @@ allowed-tools:
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа | | `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе | | `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | | `configSrc` | string | нет | Каталог XML-выгрузки конфигурации |
| `repository` | object | нет | Хранилище конфигурации: `path`, `user`, `password` (см. ниже) |
| `extensions` | array | нет | Расширения: `name`, `src`, необязательное `repository` (см. ниже) |
### Хранилище конфигурации
База, подключённая к хранилищу конфигурации 1С, **не принимает ни одной операции конфигуратора**
без реквизитов доступа к хранилищу — это касается не только `/db-repo`, но и `/db-load-xml`,
`/db-dump-xml`, `/db-update`, `/db-load-git`. Реквизиты берутся из `repository` записи базы,
передавать их в каждом вызове не нужно.
| Поле | Тип | Обязательное | Описание |
|------|-----|:------------:|----------|
| `repository.path` | string | да | Каталог хранилища или `tcp://<хост>[:<порт>]/<имя>` |
| `repository.user` | string | нет | Пользователь **хранилища**. Не наследуется от `user` базы |
| `repository.password` | string | нет | Пароль пользователя хранилища |
У расширения **своё хранилище** со своим путём, поэтому одного `repository` мало:
| Поле | Тип | Обязательное | Описание |
|------|-----|:------------:|----------|
| `extensions[].name` | string | да | Имя расширения, как в конфигурации |
| `extensions[].src` | string | нет | Каталог XML-исходников расширения |
| `extensions[].repository` | object | нет | Хранилище расширения. Расширение без хранилища — обычный случай |
Пароль хранилища — такой же секрет, как `password` базы; `.v8-project.json` в `.gitignore`.
> **Сетевое хранилище.** Адрес — `tcp://<хост>[:<порт>]/<имя>`, порт по умолчанию 1542.
> Обслуживается сервером хранилища. Если он недоступен, платформа отвечает «Соединение с
> хранилищем конфигурации не установлено» — тем же сообщением, что и при отсутствии реквизитов.
## Алгоритм разрешения базы данных ## Алгоритм разрешения базы данных
@@ -128,6 +170,7 @@ test Тестовая server srv01/MyApp_Test
- path (для file) или server + ref (для server) - path (для file) или server + ref (для server)
- user, password (необязательно) - user, password (необязательно)
- aliases, branches (необязательно) - aliases, branches (необязательно)
- если база под хранилищем конфигурации — `repository`: путь, пользователь, пароль
Добавь в массив `databases`. Если это первая база — установи как `default`. Добавь в массив `databases`. Если это первая база — установи как `default`.
@@ -159,3 +202,10 @@ test Тестовая server srv01/MyApp_Test
``` ```
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком. > **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
**Хранилище конфигурации** (если у базы задан `repository`) — скрипты навыков подставляют
сами, сопоставляя параметры соединения с записью реестра:
```
/ConfigurationRepositoryF"<path>" /ConfigurationRepositoryN"<user>" /ConfigurationRepositoryP"<password>"
```
@@ -36,7 +36,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-load-cf/scripts/db-load-cf.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -65,11 +65,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
```powershell ```powershell
# Файловая база # Файловая база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf" powershell.exe -NoProfile -File ".agents/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\config.cf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "config.cf" powershell.exe -NoProfile -File ".agents/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "config.cf"
# Загрузка расширения # Загрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/db-load-cf/scripts/db-load-cf.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "ext.cfe" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-load-cf v1.13 — Load 1C configuration from CF file # db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение" .\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-cf v1.13 — Load 1C configuration from CF file # db-load-cf v1.17 — Load 1C configuration from CF file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -393,7 +431,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -418,21 +456,21 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config load does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
if args.Extension: if args.Extension:
@@ -451,7 +489,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print(f"Configuration loaded successfully from: {args.InputFile}") print(f"Configuration loaded successfully from: {args.InputFile}")
else: else:
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -495,7 +533,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Configuration loaded successfully from: {args.InputFile}") print(f"Configuration loaded successfully from: {args.InputFile}")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -52,7 +52,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -82,10 +82,10 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
```powershell ```powershell
# Файловая база # Файловая база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt" powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
# Серверная база с ускорением загрузки # Серверная база с ускорением загрузки
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4 powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
``` ```
## Связанные навыки ## Связанные навыки
@@ -1,4 +1,4 @@
# db-load-dt v1.12 — Load 1C information base from DT file # db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt" .\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-dt v1.12 — Load 1C information base from DT file # db-load-dt v1.16 — Load 1C information base from DT file
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -393,7 +431,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -418,15 +456,15 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
@@ -448,7 +486,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print(f"Information base restored successfully from: {args.InputFile}") print(f"Information base restored successfully from: {args.InputFile}")
else: else:
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -490,7 +528,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Information base restored successfully from: {args.InputFile}") print(f"Information base restored successfully from: {args.InputFile}")
else: else:
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -38,7 +38,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-load-git/scripts/db-load-git.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -72,8 +72,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
```powershell ```powershell
# Все незафиксированные изменения # Все незафиксированные изменения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source All -UpdateDB powershell.exe -NoProfile -File ".agents/skills/db-load-git/scripts/db-load-git.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source All -UpdateDB
# Из диапазона коммитов # Из диапазона коммитов
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD" powershell.exe -NoProfile -File ".agents/skills/db-load-git/scripts/db-load-git.ps1" -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\WS\cfsrc" -Source Commit -CommitRange "HEAD~3..HEAD"
``` ```
@@ -1,4 +1,4 @@
# db-load-git v1.18 — Load Git changes into 1C database # db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -64,7 +64,7 @@
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun .\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -110,6 +110,21 @@ param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[switch]$UpdateDB, [switch]$UpdateDB,
[Parameter(Mandatory=$false)]
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
[switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -120,6 +135,115 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
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 Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
function Write-RepositoryHints {
param([string]$LogText)
if (-not $LogText) { return }
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
}
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
$obj = $m.Groups[1].Value
if ($obj -eq 'Configuration') {
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
} else {
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
}
}
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
}
}
function Protect-Secrets { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -141,7 +265,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -394,6 +518,41 @@ function Write-PlatformOutput {
Write-Host "--- End ---" Write-Host "--- End ---"
} }
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
if ($engine -eq "ibcmd") { if ($engine -eq "ibcmd") {
@@ -627,6 +786,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`"" $arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
$arguments += "-listFile", "`"$listFile`"" $arguments += "-listFile", "`"$listFile`""
$arguments += "-Format", $Format $arguments += "-Format", $Format
@@ -654,7 +818,7 @@ try {
# --- Execute --- # --- Execute ---
Write-Host "" Write-Host ""
Write-Host "Executing partial configuration load..." Write-Host "Executing partial configuration load..."
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -667,6 +831,7 @@ try {
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
} }
$logContent = $null
if (Test-Path $outFile) { if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue $logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) { if ($logContent) {
@@ -676,6 +841,17 @@ try {
} }
} }
Write-PlatformOutput $__v8.Output Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
$silentFailures = @(Find-SilentRejections $logContent)
if ($silentFailures.Count -gt 0) {
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
}
exit $exitCode exit $exitCode
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-git v1.18 — Load Git changes into 1C database # db-load-git v1.26 — Load Git changes into 1C database
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
def write_repository_hints(log_text):
if not log_text:
return
if "текущая конфигурация помещена в хранилище" in log_text:
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
obj = m.group(1)
if obj == "Configuration":
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
print(' /db-repo lock <база> -Objects "Конфигурация"')
else:
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
if "Соединение с хранилищем конфигурации не установлено" in log_text:
print("[hint] соединение с хранилищем не установлено. Две причины:")
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
def arg_key_match(token, key): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping is not a letter catches glued /N"user" and --password=x, while keeping
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -322,6 +466,38 @@ def print_platform_output(result):
print("--- End ---") print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True): def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively. """Run an ibcmd command non-interactively.
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -406,6 +582,9 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)") parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
parser.add_argument( parser.add_argument(
"-Source", "-Source",
@@ -424,13 +603,17 @@ def main():
) )
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)") parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load") parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
parser.add_argument("-StrictLog", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[], parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+") help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[], parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -448,10 +631,10 @@ def main():
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8" engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Resolve additional arguments for the selected engine --- # --- Resolve additional arguments for the selected engine ---
@@ -468,19 +651,19 @@ def main():
# --- Validate config dir --- # --- Validate config dir ---
if not os.path.exists(args.ConfigDir): if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr) print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1) sys.exit(1)
# --- Validate Commit mode --- # --- Validate Commit mode ---
if args.Source == "Commit" and not args.CommitRange: if args.Source == "Commit" and not args.CommitRange:
print("Error: -CommitRange required for Source=Commit", file=sys.stderr) print("Error: -CommitRange required for Source=Commit")
sys.exit(1) sys.exit(1)
# --- Check git --- # --- Check git ---
try: try:
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True) subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: git not found in PATH", file=sys.stderr) print("Error: git not found in PATH")
sys.exit(1) sys.exit(1)
# --- Get changed files from Git --- # --- Get changed files from Git ---
@@ -559,10 +742,10 @@ def main():
config_files.append(rel_path) config_files.append(rel_path)
if support_skipped: if support_skipped:
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr) print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):")
for sf in support_skipped: for sf in support_skipped:
print(f" - {sf}", file=sys.stderr) print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr) print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).")
if len(config_files) == 0: if len(config_files) == 0:
print("No configuration files found in changes") print("No configuration files found in changes")
@@ -586,10 +769,10 @@ def main():
if engine == "ibcmd": if engine == "ibcmd":
# --- ibcmd branch (file infobase only; import specific files) --- # --- ibcmd branch (file infobase only; import specific files) ---
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr) print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + config_files arguments = ["infobase", "config", "import", "files"] + config_files
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
@@ -606,7 +789,7 @@ def main():
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName)) result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0: if result.returncode != 0:
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
print(f"Changes loaded successfully ({len(config_files)} files)") print(f"Changes loaded successfully ({len(config_files)} files)")
exit_code = 0 exit_code = 0
@@ -624,7 +807,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar) print_platform_output(ar)
sys.exit(exit_code) sys.exit(exit_code)
@@ -646,6 +829,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
arguments += ["-listFile", f'"{list_file}"'] arguments += ["-listFile", f'"{list_file}"']
arguments += ["-Format", args.Format] arguments += ["-Format", args.Format]
@@ -671,7 +859,7 @@ def main():
# --- Execute --- # --- Execute ---
print("") print("")
print("Executing partial configuration load...") print("Executing partial configuration load...")
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -681,8 +869,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Load completed successfully") print("Load completed successfully")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = ""
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
with open(out_file, "r", encoding="utf-8-sig") as f: with open(out_file, "r", encoding="utf-8-sig") as f:
@@ -695,6 +884,22 @@ def main():
pass pass
print_platform_output(result) print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
silent_failures = find_silent_rejections(log_content)
if silent_failures:
print(
f"[warning] platform reported success, but the log contains "
f"{len(silent_failures)} problem(s):"
)
for line in silent_failures:
print(f" {line}")
if args.StrictLog and exit_code == 0:
exit_code = 1
sys.exit(exit_code) sys.exit(exit_code)
finally: finally:
@@ -34,11 +34,12 @@ allowed-tools:
Если файла нет — предложи `/db-list add`. Если файла нет — предложи `/db-list add`.
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`. Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию. Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-load-xml/scripts/db-load-xml.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -90,14 +91,14 @@ Documents/Заказ/Forms/ФормаДокумента.xml
```powershell ```powershell
# Полная загрузка # Полная загрузка
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full powershell.exe -NoProfile -File ".agents/skills/db-load-xml/scripts/db-load-xml.ps1" -V8Path "C:\Program Files\1cv8\8.3.25.1257\bin" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full
# Частичная загрузка конкретных файлов # Частичная загрузка конкретных файлов
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl" powershell.exe -NoProfile -File ".agents/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
# Загрузка расширения # Загрузка расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\ext_src" -Mode Full -Extension "МоёРасширение"
# Загрузка + обновление БД в одном запуске # Загрузка + обновление БД в одном запуске
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB powershell.exe -NoProfile -File ".agents/skills/db-load-xml/scripts/db-load-xml.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -ConfigDir "C:\WS\cfsrc" -Mode Full -UpdateDB
``` ```
@@ -1,4 +1,4 @@
# db-load-xml v1.19 — Load 1C configuration from XML files # db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -61,7 +61,7 @@
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl" .\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -85,8 +85,10 @@ param(
[string]$ConfigDir, [string]$ConfigDir,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[ValidateSet("Full", "Partial")] # Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
[string]$Mode = "Full", # как станет видно, перечислены ли файлы.
[ValidateSet("", "Full", "Partial")]
[string]$Mode = "",
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$Files, [string]$Files,
@@ -110,6 +112,15 @@ param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[switch]$StrictLog, [switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -120,6 +131,115 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
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 Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
function Write-RepositoryHints {
param([string]$LogText)
if (-not $LogText) { return }
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
}
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
$obj = $m.Groups[1].Value
if ($obj -eq 'Configuration') {
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
} else {
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
}
}
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
}
}
function Protect-Secrets { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -158,7 +278,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -416,6 +536,41 @@ function Write-PlatformOutput {
Write-Host "--- End ---" Write-Host "--- End ---"
} }
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
@@ -440,6 +595,16 @@ if (-not (Test-Path $ConfigDir)) {
exit 1 exit 1
} }
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание Full
# заменило бы всю конфигурацию базы.
if ($Files -or $ListFile) {
if ($Mode -eq "Full") {
Write-Host "[note] перечислены файлы — загружаются только они; -Mode Full не применён" -ForegroundColor Yellow
}
$Mode = "Partial"
}
if (-not $Mode) { $Mode = "Full" }
# --- Validate Partial mode --- # --- Validate Partial mode ---
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) { if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
@@ -459,7 +624,7 @@ try {
} }
if ($AllExtensions) { if ($AllExtensions) {
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath") $arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) { } elseif ($Mode -eq "Partial") {
# partial: import specific files (relative to ConfigDir) # partial: import specific files (relative to ConfigDir)
$fileList = @() $fileList = @()
if ($ListFile) { if ($ListFile) {
@@ -532,6 +697,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`"" $arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
if ($Mode -eq "Full") { if ($Mode -eq "Full") {
@@ -596,7 +766,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -607,28 +777,7 @@ try {
} }
# --- Scan log for silent rejections --- # --- Scan log for silent rejections ---
# Platform often writes load-time rejections into /Out but exits with code 0. $silentFailures = @(Find-SilentRejections $logContent)
# These patterns flag cases where metadata was dropped or rejected silently.
$fatalLogPatterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу'
)
$silentFailures = @()
if ($logContent) {
foreach ($line in ($logContent -split "`r?`n")) {
foreach ($pat in $fatalLogPatterns) {
if ($line -match [regex]::Escape($pat)) {
$silentFailures += $line.Trim()
break
}
}
}
}
# --- Result --- # --- Result ---
# Default: mirror platform's verdict via exit code. Log content (including any # Default: mirror platform's verdict via exit code. Log content (including any
@@ -646,11 +795,13 @@ try {
Write-Host "--- End ---" Write-Host "--- End ---"
} }
Write-PlatformOutput $__v8.Output Write-PlatformOutput $__v8.Output
Write-RepositoryHints $logContent
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
if ($silentFailures.Count -gt 0) { if ($silentFailures.Count -gt 0) {
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs" Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
Write-Host $msg -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow } foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 } if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
} }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-load-xml v1.19 — Load 1C configuration from XML files # db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
def write_repository_hints(log_text):
if not log_text:
return
if "текущая конфигурация помещена в хранилище" in log_text:
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
obj = m.group(1)
if obj == "Configuration":
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
print(' /db-repo lock <база> -Objects "Конфигурация"')
else:
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
if "Соединение с хранилищем конфигурации не установлено" in log_text:
print("[hint] соединение с хранилищем не установлено. Две причины:")
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
def arg_key_match(token, key): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping is not a letter catches glued /N"user" and --password=x, while keeping
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -322,6 +466,38 @@ def print_platform_output(result):
print("--- End ---") print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True): def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively. """Run an ibcmd command non-interactively.
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -384,11 +560,14 @@ def main():
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server") parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
parser.add_argument("-UserName", default="", help="1C user name") parser.add_argument("-UserName", default="", help="1C user name")
parser.add_argument("-Password", default="", help="1C user password") parser.add_argument("-Password", default="", help="1C user password")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources") parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
parser.add_argument( parser.add_argument(
"-Mode", "-Mode",
default="Full", default="",
choices=["Full", "Partial"], choices=["", "Full", "Partial"],
help="Load mode (default: Full)", help="Load mode (default: Full)",
) )
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)") parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
@@ -413,7 +592,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -441,34 +620,42 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Validate config dir --- # --- Validate config dir ---
if not os.path.exists(args.ConfigDir): if not os.path.exists(args.ConfigDir):
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr) print(f"Error: config directory not found: {args.ConfigDir}")
sys.exit(1) sys.exit(1)
# --- Validate Partial mode --- # --- Validate Partial mode ---
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
# Full заменило бы всю конфигурацию базы.
if args.Files or args.ListFile:
if args.Mode == "Full":
print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
args.Mode = "Partial"
if not args.Mode:
args.Mode = "Full"
if args.Mode == "Partial" and not args.Files and not args.ListFile: if args.Mode == "Partial" and not args.Files and not args.ListFile:
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr) print("Error: -Files or -ListFile required for Partial mode")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only; hierarchical full-directory import) --- # --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
if args.AllExtensions: if args.AllExtensions:
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"] arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
elif args.Mode == "Partial" or args.Files or args.ListFile: elif args.Mode == "Partial":
# partial: import specific files (relative to ConfigDir) # partial: import specific files (relative to ConfigDir)
if args.ListFile: if args.ListFile:
if not os.path.isfile(args.ListFile): if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr) print(f"Error: list file not found: {args.ListFile}")
sys.exit(1) sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f: with open(args.ListFile, encoding="utf-8-sig") as f:
file_list = [ln.strip() for ln in f if ln.strip()] file_list = [ln.strip() for ln in f if ln.strip()]
@@ -477,7 +664,7 @@ def main():
else: else:
file_list = [] file_list = []
if not file_list: if not file_list:
print("Error: -Files or -ListFile required for partial import", file=sys.stderr) print("Error: -Files or -ListFile required for partial import")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "import", "files"] + file_list arguments = ["infobase", "config", "import", "files"] + file_list
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"] arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
@@ -499,7 +686,7 @@ def main():
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
result = run_ibcmd([v8path] + arguments, bool(args.UserName)) result = run_ibcmd([v8path] + arguments, bool(args.UserName))
if result.returncode != 0: if result.returncode != 0:
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
print(f"Configuration loaded successfully from: {args.ConfigDir}") print(f"Configuration loaded successfully from: {args.ConfigDir}")
exit_code = 0 exit_code = 0
@@ -517,7 +704,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
print_platform_output(ar) print_platform_output(ar)
sys.exit(exit_code) sys.exit(exit_code)
@@ -539,6 +726,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"'] arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
if args.Mode == "Full": if args.Mode == "Full":
@@ -549,7 +741,7 @@ def main():
# Build list file # Build list file
if args.ListFile: if args.ListFile:
if not os.path.isfile(args.ListFile): if not os.path.isfile(args.ListFile):
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr) print(f"Error: list file not found: {args.ListFile}")
sys.exit(1) sys.exit(1)
with open(args.ListFile, encoding="utf-8-sig") as f: with open(args.ListFile, encoding="utf-8-sig") as f:
raw_list = [ln.strip() for ln in f if ln.strip()] raw_list = [ln.strip() for ln in f if ln.strip()]
@@ -561,12 +753,12 @@ def main():
support_files = [x for x in raw_list if support_re.search(x)] support_files = [x for x in raw_list if support_re.search(x)]
file_list = [x for x in raw_list if not support_re.search(x)] file_list = [x for x in raw_list if not support_re.search(x)]
if support_files: if support_files:
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr) print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
for sf in support_files: for sf in support_files:
print(f" - {sf}", file=sys.stderr) print(f" - {sf}")
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr) print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
if not file_list: if not file_list:
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr) print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
sys.exit(1) sys.exit(1)
generated_list_file = os.path.join(temp_dir, "load_list.txt") generated_list_file = os.path.join(temp_dir, "load_list.txt")
with open(generated_list_file, "w", encoding="utf-8-sig") as f: with open(generated_list_file, "w", encoding="utf-8-sig") as f:
@@ -598,7 +790,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -614,22 +806,7 @@ def main():
# --- Scan log for silent rejections --- # --- Scan log for silent rejections ---
# Platform often writes load-time rejections into /Out but exits with code 0. # Platform often writes load-time rejections into /Out but exits with code 0.
# These patterns flag cases where metadata was dropped or rejected silently. # These patterns flag cases where metadata was dropped or rejected silently.
fatal_log_patterns = [ silent_failures = find_silent_rejections(log_content)
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
]
silent_failures = []
if log_content:
for line in log_content.splitlines():
for pat in fatal_log_patterns:
if pat in line:
silent_failures.append(line.strip())
break
# --- Result --- # --- Result ---
# Default: mirror platform's verdict via exit code. Log content (including any # Default: mirror platform's verdict via exit code. Log content (including any
@@ -638,7 +815,7 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Load completed successfully") print("Load completed successfully")
else: else:
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
if log_content: if log_content:
print("--- Log ---") print("--- Log ---")
@@ -646,15 +823,20 @@ def main():
print("--- End ---") print("--- End ---")
print_platform_output(result) print_platform_output(result)
write_repository_hints(log_content)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
if silent_failures: if silent_failures:
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
print( print(
f"[warning] log contains {len(silent_failures)} rejection(s) — " f"[warning] platform reported success, but the log contains "
f"platform loaded config but dropped properties/refs{suffix}", f"{len(silent_failures)} problem(s):"
file=sys.stderr,
) )
for f in silent_failures: for f in silent_failures:
print(f" {f}", file=sys.stderr) print(f" {f}")
if args.StrictLog and exit_code == 0: if args.StrictLog and exit_code == 0:
exit_code = 1 exit_code = 1
+200
View File
@@ -0,0 +1,200 @@
---
name: db-repo
description: Работа с хранилищем конфигурации 1С. Используй когда нужно захватить объекты, поместить изменения в хранилище конфигурации, получить изменения из него, подключить базу к хранилищу
argument-hint: <lock|unlock|commit|update> [database] -Objects "<объекты>"
allowed-tools:
- Bash
- Read
- Glob
- AskUserQuestion
---
# /db-repo — Хранилище конфигурации 1С
Захват и помещение объектов, получение изменений, подключение базы, история версий,
администрирование хранилища.
> Хранилище конфигурации 1С, а не Git-репозиторий.
## Usage
```
/db-repo lock [database] -Objects "Справочник.Номенклатура"
/db-repo commit [database] -Objects "Справочник.Номенклатура" -Comment "Добавлен Артикул"
/db-repo unlock [database] -Objects "Справочник.Номенклатура"
/db-repo update [database]
```
## Порядок работы
В базу, подключённую к хранилищу, исходники грузятся **только частично** и **только по захваченным**
объектам. Выполняй строго по шагам:
```
0. /db-repo update <база> — начать с актуального состояния
1. /db-repo lock <база> -Objects "Справочник.Номенклатура"
2. если шаг 0 или 1 напечатал «локальная конфигурация изменена, получено объектов из хранилища: N» —
выгрузи названные объекты: /db-dump-xml <база> <каталог> -Mode Partial -ObjectsFile "<файл из вывода>"
3. правки в исходниках: /meta-edit, /form-edit, /skd-edit, /meta-compile и т. д.
4. /db-load-xml <каталог> <база> -Mode Partial -Files "Catalogs/Номенклатура.xml,…" -UpdateDB
5. /db-repo commit <база> -Objects "Справочник.Номенклатура" -Comment "…"
```
Шаг 0 стоит делать всегда, когда работа не продолжается сразу после предыдущего цикла: правки
должны опираться на актуальное состояние — в том числе тех объектов, которые ты не меняешь, но
используешь.
Шаг 2 пропускать нельзя: захват и обновление подтягивают из хранилища свежие версии, и загрузка
исходников, снятых раньше, откатит чужие изменения — молча, без ошибки.
**Что вообще захватывается.** Отдельные объекты хранилища — сам объект, а также его **формы,
макеты и команды**. Реквизиты, табличные части, измерения и ресурсы отдельными объектами **не
являются**: они правятся в составе владельца.
| Что правишь | Что захватывать |
|-------------|-----------------|
| Реквизит, табличную часть, измерение, ресурс, модуль объекта | сам объект: `Справочник.Контрагенты` |
| Существующую форму, макет, команду | её саму: `Справочник.Контрагенты.Форма.ФормаЭлемента` |
| Добавляешь новую форму, макет, команду | объект-владельца; при помещении назови и новый объект |
| Добавляешь новый объект конфигурации | только корень: `Конфигурация`. Самого объекта ещё нет — захватить его нельзя; при помещении назови и его |
Захватывай минимум того, что правишь: чем шире захват, тем больше конфликтов с коллегами.
Захват объекта его формы и макеты не захватывает — для этого есть `-WithChildren`.
## Параметры подключения
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
1. Если пользователь указал параметры подключения — используй напрямую
2. Если указал базу по имени — ищи по id / alias / name
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
4. Если ветка не совпала — используй `default`
Реквизиты хранилища передавать не нужно: запись базы находится по переданным параметрам
соединения (`-InfoBasePath` либо `-InfoBaseServer` + `-InfoBaseRef`), реквизиты берутся из её
`repository`. Задать их явно можно параметрами `-Repository*`.
## Команда
```powershell
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command <подкоманда> <параметры>
```
### Рабочий цикл
| Подкоманда | Что делает |
|------------|------------|
| `lock` | Захватить объекты |
| `unlock` | Отменить захват |
| `commit` | Поместить изменения в хранилище |
| `update` | Получить изменения из хранилища |
### Параметры
| Параметр | Обязательный | Описание |
|----------|:------------:|----------|
| `-InfoBasePath <путь>` | * | Файловая база |
| `-InfoBaseServer <сервер>` | * | Сервер 1С |
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
| `-UserName <имя>` | нет | Пользователь базы |
| `-Password <пароль>` | нет | Пароль пользователя базы |
| `-Objects <список>` | усл. | Объекты через запятую. Для `lock`, `unlock`, `commit` обязателен, если не задан `-All` |
| `-ObjectsFile <путь>` | нет | Файл со списком объектов, одно имя на строку |
| `-All` | нет | Операция над всей конфигурацией — вместо `-Objects`, а не вместе с ним |
| `-WithChildren` | нет | Вместе с подчинёнными объектами на полную глубину |
| `-Comment <текст>` | нет | Комментарий к помещению (`commit`). Многострочный — как есть, с переводами строк |
| `-KeepLocked` | нет | Оставить объекты захваченными после помещения |
| `-Revised` | нет | Получать захваченные объекты, если потребуется |
| `-Force` | нет | Разное по подкомандам — см. ниже |
| `-Extension <имя>` | нет | Работать с хранилищем расширения |
| `-RepositoryPath <путь>` | нет | Хранилище явно, вместо реестра |
| `-RepositoryUser <имя>` | нет | Пользователь хранилища явно |
| `-RepositoryPassword <пароль>` | нет | Пароль пользователя хранилища явно |
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
### `-Force`
| Подкоманда | Что делает |
|------------|------------|
| `unlock` | **Теряет локальные правки**: объекты перезаписываются версией из хранилища |
| `commit` | Пытается очистить ссылки на удалённые объекты вместо ошибки |
| `update` | Подтверждает добавление и удаление объектов конфигурации |
### Имена объектов
Объект — `Справочник.Номенклатура`. Форма, макет, команда — полным путём:
`Документ.ЗаказПокупателя.Форма.ФормаДокумента`, `Справочник.Номенклатура.Макет.Печать`.
Корень конфигурации — `Конфигурация`.
Если объект «не найден», это не всегда опечатка: он мог появиться в хранилище позже, чем
обновлялась база (`/db-repo update`), либо это вовсе не объект хранилища — реквизит или
табличная часть.
## Результат
Нулевой код не означает, что что-то изменилось. Под нулём приходят «уже захвачено», «обновлять
нечего», «помещать нечего» и частичный захват — когда часть объектов занята другими, а остальное
захвачено и его можно править.
**Читай текст вывода, а не только код.** Там же приходит список полученных из хранилища объектов,
который требует перевыгрузки перед правкой.
## Требуют подтверждения пользователя
Перед этими операциями **спроси подтверждение**:
| Операция | Почему |
|----------|--------|
| `lock -All` | Захватывает **всю конфигурацию**: на большой базе идёт долго и блокирует работу всей команде |
| `unlock -Force` | Теряются локальные правки захваченных объектов |
| `disconnect` | Теряется подключение базы к хранилищу, в том числе на стороне хранилища |
| `connect -ForceReplaceCfg` | Конфигурация базы заменяется конфигурацией из хранилища |
`update` не выполнится, если у базы в реестре не объявлено `repository`, а реквизиты не заданы
явно: на неподключённой к хранилищу базе эта команда заменяет всю конфигурацию его содержимым и
рапортует успех.
## Расширения
У расширения своё хранилище со своим путём. Укажи `-Extension "<Имя>"` — реквизиты возьмутся из
`extensions[].repository` записи базы. Подкоманды работают одинаково для основной конфигурации и
для расширения.
## Остальные задачи
| Файл | Про что |
|------|---------|
| [connect.md](references/connect.md) | Подключение и отключение базы от хранилища |
| [history.md](references/history.md) | История версий, отчёт, выгрузка версии в CF |
| [admin.md](references/admin.md) | Создание хранилища, пользователи и права |
| [service.md](references/service.md) | Метки версий, оптимизация, очистка кеша |
## Примеры
```powershell
# Захватить справочник вместе с подчинёнными объектами
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
# Поместить с комментарием, оставив захват
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
# Получить изменения из хранилища
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
# Серверная база, расширение
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Extension "МоёРасширение" -Objects "Справочник.Номенклатура"
```
## После выполнения
- `lock` или `update` сообщил о полученных объектах — выполни `/db-dump-xml -Mode Partial` с
указанным в выводе файлом, и только потом правь исходники
- после `lock` правки идут через `/db-load-xml -Mode Partial` и `/db-update`
- изменения готовы — предложи `/db-repo commit` с комментарием
@@ -0,0 +1,45 @@
# Администрирование хранилища
## create — создать хранилище
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command create -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "C:\Repo\MyApp" -RepositoryUser "Admin" -RepositoryPassword ""
```
| Параметр | Описание |
|----------|----------|
| `-NoBind` | Не подключать базу к созданному хранилищу |
| `-AllowConfigurationChanges` | Включить возможность изменения, если конфигурация на поддержке без неё |
| `-ChangesAllowedRule <правило>` | Правило для объектов, изменения которых разрешены поставщиком |
| `-ChangesNotRecommendedRule <правило>` | То же для «изменения не рекомендуются» |
Правила: `ObjectNotEditable`, `ObjectIsEditableSupportEnabled`, `ObjectNotSupported`.
Без `-NoBind` база сразу подключается к созданному хранилищу. Создание — это версия 1.
Для расширения: `-Extension "<Имя>"` и отдельный путь — у расширения своё хранилище.
## add-user — создать пользователя
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command add-user -InfoBasePath "C:\Bases\MyDB" -NewUser "Ivanov" -NewUserPassword "" -Rights LockObjects
```
| Право | Что даёт |
|-------|----------|
| `ReadOnly` | Просмотр |
| `LockObjects` | Захват объектов |
| `ManageConfigurationVersions` | Изменение состава версий |
| `Administration` | Административные функции |
`-RestoreDeletedUser` — восстановить одноимённого удалённого. Если пользователь с таким именем
существует, он **не** будет добавлен. Выполняющий должен иметь административные права.
## copy-users — скопировать пользователей из другого хранилища
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command copy-users -InfoBasePath "C:\Bases\MyDB" -SourcePath "\\srv01\repo\Other" -SourceUser "Admin" -SourcePassword ""
```
`-SourcePath`, `-SourceUser`, `-SourcePassword` описывают хранилище-**источник**. Удалённые пользователи
не копируются; существующие не перезаписываются.
@@ -0,0 +1,39 @@
# Подключение базы к хранилищу
## connect — подключить
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command connect -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "\\srv01\repo\MyApp" -RepositoryUser "Ivanov" -RepositoryPassword ""
```
| Параметр | Описание |
|----------|----------|
| `-ForceReplaceCfg` | Конфигурация базы непустая — подтвердить замену её конфигурацией из хранилища. **Спроси подтверждение у пользователя** |
| `-ForceBindAlreadyBindedUser` | Подключить, даже если у этого пользователя уже есть конфигурация, связанная с хранилищем |
На пустой базе `-ForceReplaceCfg` не нужен.
**Переподключение** базы, которая уже была подключена, требует обоих флагов: конфигурация в базе
не пустая (`-ForceReplaceCfg`), а за пользователем хранилища всё ещё числится эта база
(`-ForceBindAlreadyBindedUser`).
После подключения добавь `repository` в запись базы в `.v8-project.json` — иначе остальные
подкоманды придётся каждый раз звать с явными реквизитами, а `update` откажется работать.
## disconnect — отключить
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command disconnect -InfoBasePath "C:\Bases\MyDB"
```
**Спроси подтверждение у пользователя.** Отключение снимает связь и на стороне самого хранилища:
запись о подключении удаляется. Подключить базу обратно можно, но это уже не рядовая операция —
понадобятся оба флага `connect` из раздела выше.
Если в базе есть захваченные и изменённые объекты, операция не выполнится. `-Force` выполняет её
всё равно, и эти изменения теряются.
## Расширения
У расширения своё хранилище: `-Extension "<Имя>"` указывай вместе с путём именно к нему, а не
к хранилищу основной конфигурации.
@@ -0,0 +1,31 @@
# История версий хранилища
## report — отчёт по версиям
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command report -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\repo.txt"
```
| Параметр | Описание |
|----------|----------|
| `-OutputFile <путь>` | Куда сохранить отчёт. Необязателен |
| `-NBegin <номер>` | С какой версии. `-1` — только последняя |
| `-NEnd <номер>` | По какую версию |
| `-DateBegin` / `-DateEnd` | Границы по датам |
| `-GroupByObject` | Группировать по объектам |
| `-GroupByComment` | Группировать по комментарию |
| `-ReportFormat <txt\|mxl>` | По умолчанию `txt` |
`txt` — с разделителем-табуляцией, разбирается построчно.
> На боевом хранилище полный отчёт строить не надо — тысячи версий. Нужна головная
> версия — `-NBegin -1`. Длинный отчёт в вывод не печатается: сузьте выборку
> параметрами ниже.
## dump-cfg — выгрузить версию в CF
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command dump-cfg -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\v120.cf" -Version 120
```
Без `-Version` (или при `-1`) выгружается последняя версия.
@@ -0,0 +1,31 @@
# Сервисные операции
## set-label — метка на версию
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command set-label -InfoBasePath "C:\Bases\MyDB" -Label "Релиз 1.2" -Version 120 -Comment "Передано в тест"
```
Без `-Version` метка ставится на последнюю версию. Несуществующая версия — ошибка.
## optimize — оптимизация хранения
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command optimize -InfoBasePath "C:\Bases\MyDB"
```
Оптимизирует хранение данных в хранилище. Операция долгая.
## clear-cache — очистка кеша
```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command clear-cache -InfoBasePath "C:\Bases\MyDB" -CacheScope local
```
| `-CacheScope` | Что чистит |
|---------------|------------|
| `local` (по умолчанию) | Локальный кеш версий конфигурации |
| `global` | Глобальный кеш версий |
| `db` | Локальную базу данных хранилища |
Пригождается, когда хранилище ведёт себя странно после сбоя сети или отката версии.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -36,7 +36,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-run/scripts/db-run.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,14 +64,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
```powershell ```powershell
# Простой запуск # Простой запуск
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" powershell.exe -NoProfile -File ".agents/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Запуск с обработкой # Запуск с обработкой
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Execute "C:\epf\МояОбработка.epf" powershell.exe -NoProfile -File ".agents/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Execute "C:\epf\МояОбработка.epf"
# Открыть по навигационной ссылке # Открыть по навигационной ссылке
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -URL "e1cib/data/Справочник.Номенклатура" powershell.exe -NoProfile -File ".agents/skills/db-run/scripts/db-run.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -URL "e1cib/data/Справочник.Номенклатура"
# Серверная база с параметром запуска # Серверная база с параметром запуска
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление" powershell.exe -NoProfile -File ".agents/skills/db-run/scripts/db-run.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -CParam "ЗапуститьОбновление"
``` ```
@@ -1,4 +1,4 @@
# db-run v1.7 — Launch 1C:Enterprise # db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -52,7 +52,7 @@
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление" .\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-run v1.7 — Launch 1C:Enterprise # db-run v1.10 — Launch 1C:Enterprise
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -11,6 +11,28 @@ import subprocess
import sys import sys
import time import time
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -95,7 +117,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -103,7 +124,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -171,14 +191,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -203,7 +221,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -238,14 +256,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -281,7 +299,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -305,7 +323,7 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- Build arguments --- # --- Build arguments ---
@@ -355,7 +373,7 @@ def main():
time.sleep(0.2) time.sleep(0.2)
rc = proc.poll() rc = proc.poll()
if rc is not None: if rc is not None:
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr) print(f"Error: 1C:Enterprise exited immediately (code: {rc})")
sys.exit(rc if rc and rc > 0 else 1) sys.exit(rc if rc and rc > 0 else 1)
print(f"PID: {proc.pid}") print(f"PID: {proc.pid}")
print("1C:Enterprise launched") print("1C:Enterprise launched")
@@ -35,7 +35,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/db-update/scripts/db-update.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -78,11 +78,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
```powershell ```powershell
# Обычное обновление (файловая база) # Обычное обновление (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" powershell.exe -NoProfile -File ".agents/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
# Динамическое обновление (серверная база) # Динамическое обновление (серверная база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic "+" powershell.exe -NoProfile -File ".agents/skills/db-update/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic "+"
# Обновление расширения # Обновление расширения
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение" powershell.exe -NoProfile -File ".agents/skills/db-update/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
``` ```
@@ -1,4 +1,4 @@
# db-update v1.13 — Update 1C database configuration # db-update v1.19 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -55,7 +55,7 @@
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение" .\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -91,6 +91,21 @@ param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[switch]$WarningsAsErrors, [switch]$WarningsAsErrors,
[Parameter(Mandatory=$false)]
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
[switch]$StrictLog,
[Parameter(Mandatory=$false)]
[string]$RepositoryPath,
[Parameter(Mandatory=$false)]
[string]$RepositoryUser,
[Parameter(Mandatory=$false)]
[string]$RepositoryPassword,
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string[]]$AdditionalV8Arguments = @(), [string[]]$AdditionalV8Arguments = @(),
@@ -101,6 +116,90 @@ param(
$OutputEncoding = [System.Text.Encoding]::UTF8 $OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
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 Test-SamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
} catch { return $false }
}
function Find-ProjectDatabase {
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
$pf = Find-V8Project (Get-Location).Path
if (-not $pf) { return $null }
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
if (-not $proj.databases) { return $null }
foreach ($db in $proj.databases) {
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
}
}
return $null
}
function Resolve-RepositorySettings {
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
$dbRec = Find-ProjectDatabase
$rec = $null
if ($dbRec) {
if ($Extension) {
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
if ($dbRec.extensions) {
foreach ($ext in $dbRec.extensions) {
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
$rec = $ext.repository
break
}
}
}
} else {
$rec = $dbRec.repository
}
}
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
return @{
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
User = $user
Password = $pwd
FromRegistry = [bool]($rec -and $rec.path)
DbRecord = $dbRec
}
}
function Get-RepositoryArgs {
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
param([hashtable]$Repo)
$a = @()
if (-not $Repo -or -not $Repo.Path) { return $a }
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
return $a
}
function Protect-Secrets { function Protect-Secrets {
# Redact literal secret values from a display string (String.Replace is literal, not regex). # Redact literal secret values from a display string (String.Replace is literal, not regex).
param([string]$Text, [string[]]$Secrets) param([string]$Text, [string[]]$Secrets)
@@ -139,7 +238,7 @@ $script:IbcmdOwnedKeys = @(
'--import', '--export', '--apply', '--force', '--create-database', '--import', '--export', '--apply', '--force', '--create-database',
'--user', '--password' '--user', '--password'
) )
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP') $script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd') $script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
function Test-ArgKeyMatch { function Test-ArgKeyMatch {
@@ -395,6 +494,41 @@ function Write-PlatformOutput {
Write-Host "--- End ---" Write-Host "--- End ---"
} }
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
#
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Find-SilentRejections {
param([string]$LogText)
$patterns = @(
'Неверное свойство объекта метаданных',
'не входит в состав объекта метаданных',
'Неизвестное имя типа',
'Неизвестный объект метаданных',
'Ни один из документов не является регистратором для регистра',
'Неверное значение перечисления',
'не может быть приведен к типу',
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
'Для работы с конфигурацией необходима версия платформы не меньше'
)
$found = @()
if ($LogText) {
foreach ($line in ($LogText -split "`r?`n")) {
foreach ($pat in $patterns) {
if ($line -match [regex]::Escape($pat)) {
$found += $line.Trim()
break
}
}
}
}
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
return $found
}
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" } $engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
@@ -458,6 +592,11 @@ try {
if ($UserName) { $arguments += "/N`"$UserName`"" } if ($UserName) { $arguments += "/N`"$UserName`"" }
if ($Password) { $arguments += "/P`"$Password`"" } if ($Password) { $arguments += "/P`"$Password`"" }
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
$__repo = Resolve-RepositorySettings
$arguments += Get-RepositoryArgs $__repo
$arguments += "/UpdateDBCfg" $arguments += "/UpdateDBCfg"
# --- Options --- # --- Options ---
@@ -485,7 +624,7 @@ try {
$arguments += $extraArgs $arguments += $extraArgs
# --- Execute --- # --- Execute ---
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))" Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted $__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
$exitCode = $__v8.ExitCode $exitCode = $__v8.ExitCode
@@ -496,6 +635,7 @@ try {
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
} }
$logContent = $null
if (Test-Path $outFile) { if (Test-Path $outFile) {
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue $logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
if ($logContent) { if ($logContent) {
@@ -506,6 +646,16 @@ try {
} }
Write-PlatformOutput $__v8.Output Write-PlatformOutput $__v8.Output
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
$silentFailures = @(Find-SilentRejections $logContent)
if ($silentFailures.Count -gt 0) {
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
}
exit $exitCode exit $exitCode
} finally { } finally {
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# db-update v1.13 — Update 1C database configuration # db-update v1.19 — Update 1C database configuration
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -50,10 +72,95 @@ IBCMD_OWNED_KEYS = [
"--import", "--export", "--apply", "--force", "--create-database", "--import", "--export", "--apply", "--force", "--create-database",
"--user", "--password", "--user", "--password",
] ]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"] V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"] IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def same_path(a, b):
if not a or not b:
return False
try:
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
except Exception:
return False
def find_project_database(args):
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
pf = _sg_find_v8project(os.getcwd())
if not pf:
return None
try:
with open(pf, encoding="utf-8-sig") as f:
proj = json.load(f)
except Exception:
return None
for db in proj.get("databases") or []:
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
return db
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
if (db["server"].lower() == args.InfoBaseServer.lower()
and db["ref"].lower() == args.InfoBaseRef.lower()):
return db
return None
def resolve_repository_settings(args):
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
db_rec = find_project_database(args)
rec = None
if db_rec:
if args.Extension:
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
for ext in db_rec.get("extensions") or []:
if (ext.get("name") or "").lower() == args.Extension.lower():
rec = ext.get("repository")
break
else:
rec = db_rec.get("repository")
path = args.RepositoryPath or ((rec or {}).get("path") or None)
user = args.RepositoryUser or ((rec or {}).get("user") or None)
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
return {
"path": path.strip().strip('"') if path else None,
"user": user,
"password": pwd,
"from_registry": bool(rec and rec.get("path")),
}
def repository_args(repo):
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
a = []
if not repo or not repo.get("path"):
return a
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
if repo.get("user"):
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
if repo.get("password"):
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
return a
def arg_key_match(token, key): def arg_key_match(token, key):
"""Token matches a key when it equals it, or starts with it and the next character """Token matches a key when it equals it, or starts with it and the next character
is not a letter catches glued /N"user" and --password=x, while keeping is not a letter catches glued /N"user" and --password=x, while keeping
@@ -98,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +326,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +364,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +381,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +400,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -322,6 +445,38 @@ def print_platform_output(result):
print("--- End ---") print("--- End ---")
def find_silent_rejections(log_text):
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
Возвращает подошедшие строки.
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
автономны). Держать копии одинаковыми сознательно: разошедшиеся копии сводят на нет
весь смысл.
"""
patterns = [
"Неверное свойство объекта метаданных",
"не входит в состав объекта метаданных",
"Неизвестное имя типа",
"Неизвестный объект метаданных",
"Ни один из документов не является регистратором для регистра",
"Неверное значение перечисления",
"не может быть приведен к типу",
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
"Для работы с конфигурацией необходима версия платформы не меньше",
]
found = []
if log_text:
for line in log_text.splitlines():
for pat in patterns:
if pat in line:
found.append(line.strip())
break
return found
def run_ibcmd(cmd, has_username=False, warn_no_user=True): def run_ibcmd(cmd, has_username=False, warn_no_user=True):
"""Run an ibcmd command non-interactively. """Run an ibcmd command non-interactively.
@@ -330,7 +485,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -384,18 +539,25 @@ def main():
parser.add_argument("-InfoBaseRef", default="") parser.add_argument("-InfoBaseRef", default="")
parser.add_argument("-UserName", default="") parser.add_argument("-UserName", default="")
parser.add_argument("-Password", default="") parser.add_argument("-Password", default="")
parser.add_argument("-RepositoryPath", default="")
parser.add_argument("-RepositoryUser", default="")
parser.add_argument("-RepositoryPassword", default="")
parser.add_argument("-Extension", default="") parser.add_argument("-Extension", default="")
parser.add_argument("-AllExtensions", action="store_true") parser.add_argument("-AllExtensions", action="store_true")
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"]) parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
parser.add_argument("-Server", action="store_true") parser.add_argument("-Server", action="store_true")
parser.add_argument("-WarningsAsErrors", action="store_true") parser.add_argument("-WarningsAsErrors", action="store_true")
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
# но в логе есть отбраковка.
parser.add_argument("-StrictLog", action="store_true")
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[], parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+") help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[], parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -420,16 +582,16 @@ def main():
# --- Validate connection --- # --- Validate connection ---
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr) print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
sys.exit(1) sys.exit(1)
# --- ibcmd branch (file infobase only) --- # --- ibcmd branch (file infobase only) ---
if engine == "ibcmd": if engine == "ibcmd":
if args.AllExtensions: if args.AllExtensions:
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr) print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)")
sys.exit(1) sys.exit(1)
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"] arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
if args.Dynamic == "+": if args.Dynamic == "+":
@@ -451,7 +613,7 @@ def main():
if result.returncode == 0: if result.returncode == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr) print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}")
sys.exit(result.returncode) sys.exit(result.returncode)
# --- Temp dir --- # --- Temp dir ---
@@ -472,6 +634,11 @@ def main():
if args.Password: if args.Password:
arguments.append(f'/P"{args.Password}"') arguments.append(f'/P"{args.Password}"')
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
repo = resolve_repository_settings(args)
arguments.extend(repository_args(repo))
arguments.append("/UpdateDBCfg") arguments.append("/UpdateDBCfg")
# --- Options --- # --- Options ---
@@ -495,7 +662,7 @@ def main():
arguments.extend(quote_if_needed(a) for a in extra_args) arguments.extend(quote_if_needed(a) for a in extra_args)
# --- Execute --- # --- Execute ---
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}") print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
result = run_v8(v8path, arguments) result = run_v8(v8path, arguments)
exit_code = result.returncode exit_code = result.returncode
@@ -503,8 +670,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print("Database configuration updated successfully") print("Database configuration updated successfully")
else: else:
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr) print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
log_content = ""
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
with open(out_file, "r", encoding="utf-8-sig") as f: with open(out_file, "r", encoding="utf-8-sig") as f:
@@ -517,6 +685,21 @@ def main():
pass pass
print_platform_output(result) print_platform_output(result)
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
silent_failures = find_silent_rejections(log_content)
if silent_failures:
print(
f"[warning] platform reported success, but the log contains "
f"{len(silent_failures)} problem(s):"
)
for line in silent_failures:
print(f" {line}")
if args.StrictLog and exit_code == 0:
exit_code = 1
sys.exit(exit_code) sys.exit(exit_code)
finally: finally:
@@ -40,7 +40,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
```powershell ```powershell
# Сборка обработки (файловая база) # Сборка обработки (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf" powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf" powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МояОбработка.xml" -OutputFile "build/МояОбработка.epf"
``` ```
@@ -1,4 +1,4 @@
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources # epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -46,7 +46,7 @@
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf" .\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources # epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -374,7 +412,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -398,7 +436,7 @@ def main():
} }
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints) extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef: if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
sys.exit(1) sys.exit(1)
# --- Auto-create stub database if no connection specified --- # --- Auto-create stub database if no connection specified ---
@@ -419,14 +457,14 @@ def main():
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra) stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
result = subprocess.run(stub_cmd, capture_output=False) result = subprocess.run(stub_cmd, capture_output=False)
if result.returncode != 0: if result.returncode != 0:
print("Error: failed to create stub database", file=sys.stderr) print("Error: failed to create stub database")
sys.exit(1) sys.exit(1)
args.InfoBasePath = auto_base_path args.InfoBasePath = auto_base_path
auto_created_base = auto_base_path auto_created_base = auto_base_path
# --- Validate source file --- # --- Validate source file ---
if not os.path.isfile(args.SourceFile): if not os.path.isfile(args.SourceFile):
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr) print(f"Error: source file not found: {args.SourceFile}")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -460,9 +498,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"External data processor/report built successfully: {args.OutputFile}") print(f"External data processor/report built successfully: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else: else:
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr) print(f"Error building external data processor/report (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Build arguments --- # --- Build arguments ---
@@ -499,9 +537,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Build completed successfully: {args.OutputFile}") print(f"Build completed successfully: {args.OutputFile}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr) print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
else: else:
print(f"Error building (code: {exit_code})", file=sys.stderr) print(f"Error building (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
@@ -1,4 +1,4 @@
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build # stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -163,14 +163,35 @@ function Format-ArgsForDisplay {
} }
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- 1. Scan XML files for reference types --- # --- 1. Scan XML files for reference types ---
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...) $typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
# запасной вариант, если корень почему-то не попался.
$srcRootVersion = ""
$srcAnyVersion = ""
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File $xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
foreach ($f in $xmlFiles) { foreach ($f in $xmlFiles) {
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8) $content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
$ver = $Matches[1]
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
$srcRootVersion = $ver
}
}
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.) # Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)' $refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
foreach ($m in [regex]::Matches($content, $refPattern)) { foreach ($m in [regex]::Matches($content, $refPattern)) {
@@ -337,7 +358,24 @@ if ($hasRefTypes) {
$cfgDir = Join-Path $TempBasePath "cfg" $cfgDir = Join-Path $TempBasePath "cfg"
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="2.17"' # Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
# формата 2.17 загружаемого файла», замерено на 8.3.20).
#
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
$srcRank = Get-FormatRank $srcVersion
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
# формата из docs/1c-configuration-spec.md.
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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="' + $stubFormatVersion + '"'
# GeneratedType definitions per metadata type # GeneratedType definitions per metadata type
$gtDefs = @{ $gtDefs = @{
@@ -521,7 +559,7 @@ if ($hasRefTypes) {
<Synonym/> <Synonym/>
<Comment/> <Comment/>
<NamePrefix/> <NamePrefix/>
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode> <ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
<DefaultRunMode>ManagedApplication</DefaultRunMode> <DefaultRunMode>ManagedApplication</DefaultRunMode>
<UsePurposes> <UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value> <v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
@@ -572,7 +610,7 @@ if ($hasRefTypes) {
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode> <SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode> <InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode> <DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
<CompatibilityMode>Version8_3_24</CompatibilityMode> <CompatibilityMode>$stubCompatMode</CompatibilityMode>
<DefaultConstantsForm/> <DefaultConstantsForm/>
</Properties> </Properties>
<ChildObjects>$childXml <ChildObjects>$childXml
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build # stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -339,6 +339,66 @@ def scan_ref_types(source_dir):
return type_map return type_map
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def detect_stub_format_version(source_dir):
"""Версия формата заглушечной конфигурации.
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
одностороннее она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
формата 2.17 загружаемого файла», замерено на 8.3.20).
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
вложенные файлы запасной вариант, если корень почему-то не попался.
"""
root_version = ""
any_version = ""
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
for dirpath, _, filenames in os.walk(source_dir):
for fn in filenames:
if not fn.endswith('.xml'):
continue
try:
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
content = f.read()
except Exception:
continue
m = ver_pattern.search(content)
if not m:
continue
if not any_version:
any_version = m.group(1)
if not root_version and root_pattern.search(content):
root_version = m.group(1)
src_version = root_version or any_version or "2.17"
src_rank = format_rank(src_version)
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
# из docs/1c-configuration-spec.md.
COMPAT_BY_FORMAT = {
"2.13": "Version8_3_20",
"2.14": "Version8_3_21",
"2.15": "Version8_3_22",
"2.16": "Version8_3_23",
}
def stub_compatibility_mode(format_version):
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
def scan_register_columns(source_dir): def scan_register_columns(source_dir):
"""Scan Form.xml for register record set columns referenced via DataPath. """Scan Form.xml for register record set columns referenced via DataPath.
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}.""" Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
@@ -417,7 +477,7 @@ NS = (
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" ' 'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" ' 'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
'xmlns:xs="http://www.w3.org/2001/XMLSchema" ' 'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"' 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
) )
CLASS_IDS = [ CLASS_IDS = [
@@ -1046,6 +1106,9 @@ def main():
type_map = scan_ref_types(args.SourceDir) type_map = scan_ref_types(args.SourceDir)
register_columns = scan_register_columns(args.SourceDir) register_columns = scan_register_columns(args.SourceDir)
has_ref_types = len(type_map) > 0 has_ref_types = len(type_map) > 0
stub_format_version = detect_stub_format_version(args.SourceDir)
stub_compat = stub_compatibility_mode(stub_format_version)
ns_decl = f'{NS} version="{stub_format_version}"'
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}') temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
@@ -1077,7 +1140,7 @@ def main():
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>' child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?> cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {NS}> <MetaDataObject {ns_decl}>
\t<Configuration uuid="{uuid_cfg}"> \t<Configuration uuid="{uuid_cfg}">
\t\t<InternalInfo>{co_xml} \t\t<InternalInfo>{co_xml}
\t\t</InternalInfo> \t\t</InternalInfo>
@@ -1086,7 +1149,7 @@ def main():
\t\t\t<Synonym/> \t\t\t<Synonym/>
\t\t\t<Comment/> \t\t\t<Comment/>
\t\t\t<NamePrefix/> \t\t\t<NamePrefix/>
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode> \t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode> \t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
\t\t\t<UsePurposes> \t\t\t<UsePurposes>
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value> \t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
@@ -1137,7 +1200,7 @@ def main():
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode> \t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode> \t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode> \t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode> \t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
\t\t\t<DefaultConstantsForm/> \t\t\t<DefaultConstantsForm/>
\t\t</Properties> \t\t</Properties>
\t\t<ChildObjects>{child_xml} \t\t<ChildObjects>{child_xml}
@@ -1151,7 +1214,7 @@ def main():
lang_dir = os.path.join(cfg_dir, 'Languages') lang_dir = os.path.join(cfg_dir, 'Languages')
os.makedirs(lang_dir, exist_ok=True) os.makedirs(lang_dir, exist_ok=True)
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?> lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {NS}> <MetaDataObject {ns_decl}>
\t<Language uuid="{uuid_lang}"> \t<Language uuid="{uuid_lang}">
\t\t<Properties> \t\t<Properties>
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name> \t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
@@ -1280,7 +1343,7 @@ def main():
child_obj_xml = '\n\t\t<ChildObjects/>' child_obj_xml = '\n\t\t<ChildObjects/>'
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?> obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {NS}> <MetaDataObject {ns_decl}>
\t<{tag} uuid="{obj_uuid}">{internal_xml} \t<{tag} uuid="{obj_uuid}">{internal_xml}
\t\t<Properties> \t\t<Properties>
{props_xml} {props_xml}
@@ -39,7 +39,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -64,8 +64,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
```powershell ```powershell
# Разборка обработки (файловая база) # Разборка обработки (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src" powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МояОбработка.epf" -OutputDir "src"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src" powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МояОбработка.epf" -OutputDir "src"
``` ```
@@ -1,4 +1,4 @@
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources # epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется. # NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
<# <#
@@ -49,7 +49,7 @@
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src" .\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
#> #>
[CmdletBinding()] [CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory=$false)] [Parameter(Mandatory=$false)]
[string]$V8Path, [string]$V8Path,
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources # epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse import argparse
@@ -14,6 +14,28 @@ import subprocess
import sys import sys
import tempfile import tempfile
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def _find_project_v8path(): def _find_project_v8path():
"""Walk up from CWD to find .v8-project.json and read its v8path.""" """Walk up from CWD to find .v8-project.json and read its v8path."""
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
print( print(
f"Error: '{tok}' is a positional token — pass values as --key=value " f"Error: '{tok}' is a positional token — pass values as --key=value "
f"({param} cannot extend the ibcmd command)", f"({param} cannot extend the ibcmd command)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
for k in owned: for k in owned:
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
hint = f" (use {hints[k]})" if hints and k in hints else "" hint = f" (use {hints[k]})" if hints and k in hints else ""
print( print(
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}", f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
print( print(
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd " "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
"(use -AdditionalIbcmdArguments)", "(use -AdditionalIbcmdArguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine != "ibcmd" and ibcmd_extra: if engine != "ibcmd" and ibcmd_extra:
print( print(
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 " "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
"(use -AdditionalV8Arguments)", "(use -AdditionalV8Arguments)",
file=sys.stderr,
) )
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
v8path = max(candidates, key=_version_key) v8path = max(candidates, key=_version_key)
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}") print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
else: else:
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr) print("Error: 1C executable not found. Specify -V8Path")
sys.exit(1) sys.exit(1)
if os.path.isdir(v8path): if os.path.isdir(v8path):
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём. # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
exe = "1cv8.exe" if os.name == "nt" else "1cv8" exe = "1cv8.exe" if os.name == "nt" else "1cv8"
v8path = os.path.join(v8path, exe) v8path = os.path.join(v8path, exe)
if not os.path.isfile(v8path): if not os.path.isfile(v8path):
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr) print(f"Error: 1C executable not found at {v8path}")
sys.exit(1) sys.exit(1)
return v8path return v8path
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
if not path: if not path:
return return
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")): if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr) print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
sys.exit(1) sys.exit(1)
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
if len(v) > 3 and v[-1] in "\\/": if len(v) > 3 and v[-1] in "\\/":
v = v[:-1] v = v[:-1]
if '"' in v: if '"' in v:
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr) print(f"Error: {param or 'path'} contains a quote character: {value}")
sys.exit(1) sys.exit(1)
return v return v
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
The arguments carry their own quotes inside the value (File="C:\\a b") that is where The arguments carry their own quotes inside the value (File="C:\\a b") that is where
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would 1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
escape those quotes, so there the command line is handed over ready-made. escape those quotes, so there the command line is handed over ready-made.
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
частью значения: путь с пробелом платформа не находит («Неопределена информационная
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
File="") не задеты: у них кавычки внутри токена, а не по краям.
""" """
if os.name == "nt": if os.name == "nt":
cmd = '"' + v8path + '" ' + " ".join(arguments) cmd = '"' + v8path + '" ' + " ".join(arguments)
else: else:
cmd = [v8path] + arguments def strip_framing_quotes(a):
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
# токеном даёт «Неопределена информационная база», а склеенный
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
# без кавычек обе формы работают.
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
return a[1:-1] # "значение" отдельным токеном
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
i = a.index('"')
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
return a # File="…" не трогаем: там кавычки —
# часть синтаксиса строки соединения,
# и с ними на POSIX всё работает
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
r.stderr = decode_platform_bytes(r.stderr) r.stderr = decode_platform_bytes(r.stderr)
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing). that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
""" """
if warn_no_user and os.name == "nt" and not has_username: if warn_no_user and os.name == "nt" and not has_username:
sys.stderr.write(IBCMD_NOUSER_HINT) sys.stdout.write(IBCMD_NOUSER_HINT)
sys.stderr.flush() sys.stderr.flush()
r = subprocess.run(cmd, input=b"", capture_output=True) r = subprocess.run(cmd, input=b"", capture_output=True)
r.stdout = decode_platform_bytes(r.stdout) r.stdout = decode_platform_bytes(r.stdout)
@@ -380,7 +418,7 @@ def main():
help="Extra ibcmd arguments in --key=value form") help="Extra ibcmd arguments in --key=value form")
known_opts = {s.lower() for a in parser._actions for s in a.option_strings} known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts) argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
args = parser.parse_args(argv) args = ci_parse_args(parser, argv)
args.V8Path = clean_path(args.V8Path, "-V8Path") args.V8Path = clean_path(args.V8Path, "-V8Path")
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath") args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
@@ -406,20 +444,20 @@ def main():
# --- Validate database connection --- # --- Validate database connection ---
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef): if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr) print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.") print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
sys.exit(1) sys.exit(1)
if engine == "ibcmd": if engine == "ibcmd":
if not args.InfoBasePath: if not args.InfoBasePath:
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr) print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
sys.exit(1) sys.exit(1)
if args.Format == "Plain": if args.Format == "Plain":
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr) print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
sys.exit(1) sys.exit(1)
# --- Validate input file --- # --- Validate input file ---
if not os.path.isfile(args.InputFile): if not os.path.isfile(args.InputFile):
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr) print(f"Error: input file not found: {args.InputFile}")
sys.exit(1) sys.exit(1)
# --- Ensure output directory exists --- # --- Ensure output directory exists ---
@@ -451,9 +489,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"External data processor/report dumped successfully to: {args.OutputDir}") print(f"External data processor/report dumped successfully to: {args.OutputDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else: else:
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr) print(f"Error dumping external data processor/report (code: {exit_code})")
sys.exit(exit_code) sys.exit(exit_code)
# --- Build arguments --- # --- Build arguments ---
@@ -491,9 +529,9 @@ def main():
if exit_code == 0: if exit_code == 0:
print(f"Dump completed successfully to: {args.OutputDir}") print(f"Dump completed successfully to: {args.OutputDir}")
elif out_missing: elif out_missing:
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr) print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
else: else:
print(f"Error dumping (code: {exit_code})", file=sys.stderr) print(f"Error dumping (code: {exit_code})")
if os.path.isfile(out_file): if os.path.isfile(out_file):
try: try:
+48
View File
@@ -0,0 +1,48 @@
---
name: epf-init
description: Создать пустую внешнюю обработку 1С (scaffold XML-исходников). Используй когда нужно создать новую внешнюю обработку с нуля
argument-hint: <Name> [Synonym]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /epf-init — Создание новой обработки
Генерирует минимальный набор XML-исходников для внешней обработки 1С: корневой файл метаданных и каталог обработки.
## Usage
```
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|------------------------------------------------|
| Name | да | — | Имя обработки (латиница/кириллица) |
| Synonym | нет | = Name | Синоним (отображаемое имя) |
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
`FormatVersion`**не выше** версии формата платформы, на которой объект будут собирать и открывать:
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
но на них навыки не проверялись — такое значение принимается с предупреждением.
## Команда
```powershell
powershell.exe -NoProfile -File ".agents/skills/epf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
```
## Дальнейшие шаги
- Добавить форму: `/form-add`
- Добавить макет: `/template-add`
- Добавить справку: `/help-add`
- Собрать EPF: `/epf-build`
+148
View File
@@ -0,0 +1,148 @@
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$SrcDir = "src",
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
[string]$FormatVersion = "2.17"
)
$ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
$uuid1 = [guid]::NewGuid().ToString()
$uuid2 = [guid]::NewGuid().ToString()
$uuid3 = [guid]::NewGuid().ToString()
$uuid4 = [guid]::NewGuid().ToString()
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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"'
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
# платформа держит объявления по алфавиту, дописать в конец нельзя.
if ($formatRank -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
$xml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<ExternalDataProcessor uuid="$uuid1">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
<xr:ObjectId>$uuid2</xr:ObjectId>
</xr:ContainedObject>
<xr:GeneratedType name="ExternalDataProcessorObject.$Name" category="Object">
<xr:TypeId>$uuid3</xr:TypeId>
<xr:ValueId>$uuid4</xr:ValueId>
</xr:GeneratedType>
</InternalInfo>
<Properties>
<Name>$(Esc-XmlText $Name)</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
</v8:item>
</Synonym>
<Comment/>
<DefaultForm/>
<AuxiliaryForm/>
</Properties>
<ChildObjects/>
</ExternalDataProcessor>
</MetaDataObject>
"@
$rootFile = Join-Path $SrcDir "$Name.xml"
$processorDir = Join-Path $SrcDir $Name
if (Test-Path $rootFile) {
Write-Error "Файл уже существует: $rootFile"
exit 1
}
if (-not (Test-Path $SrcDir)) {
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
}
$extDir = Join-Path $processorDir "Ext"
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
# --- Модуль объекта ---
$moduleBsl = @"
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
$modulePath = Join-Path $extDir "ObjectModule.bsl"
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
Write-Host "[OK] Создана обработка: $rootFile"
Write-Host " Каталог: $processorDir"
Write-Host " Модуль: $modulePath"
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C external data processor."""
import sys, os, re, argparse, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Init 1C external data processor scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
src_dir = args.SrcDir
uuid1 = new_uuid()
uuid2 = new_uuid()
uuid3 = new_uuid()
uuid4 = new_uuid()
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' 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"'
)
format_version = args.FormatVersion
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<ExternalDataProcessor uuid="{uuid1}">
\t\t<InternalInfo>
\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
\t\t\t</xr:ContainedObject>
\t\t\t<xr:GeneratedType name="ExternalDataProcessorObject.{name}" category="Object">
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
\t\t\t</xr:GeneratedType>
\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<DefaultForm/>
\t\t\t<AuxiliaryForm/>
\t\t</Properties>
\t\t<ChildObjects/>
\t</ExternalDataProcessor>
</MetaDataObject>'''
root_file = os.path.join(src_dir, f"{name}.xml")
processor_dir = os.path.join(src_dir, name)
if os.path.exists(root_file):
print(f"Файл уже существует: {root_file}", file=sys.stderr)
sys.exit(1)
os.makedirs(src_dir, exist_ok=True)
ext_dir = os.path.join(processor_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
# --- Модуль объекта ---
module_bsl = """\
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти"""
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
print(f"[OK] Создана обработка: {root_file}")
print(f" Каталог: {processor_dir}")
print(f" Модуль: {module_path}")
if __name__ == '__main__':
main()
@@ -24,7 +24,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка" powershell.exe -NoProfile -File ".agents/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml" powershell.exe -NoProfile -File ".agents/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МояОбработка/МояОбработка.xml"
``` ```
@@ -1,8 +1,9 @@
# epf-validate v1.3 — Validate 1C external data processor / report structure # epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects # Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
[CmdletBinding(PositionalBinding=$false)]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory, Position=0)]
[Alias('Path')] [Alias('Path')]
[string]$ObjectPath, [string]$ObjectPath,
@@ -111,6 +112,19 @@ $finalize = {
} }
} }
# --- Format version ---
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Reference tables --- # --- Reference tables ---
$guidPattern = '^[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}$' $guidPattern = '^[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}$'
@@ -183,11 +197,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
} }
$version = $root.GetAttribute("version") $version = $root.GetAttribute("version")
$versionRank = Get-FormatRank $version
if (-not $version) { if (-not $version) {
Report-Warn "1. Missing version attribute on MetaDataObject" Report-Warn "1. Missing version attribute on MetaDataObject"
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) { } elseif ($versionRank -eq 0) {
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). Report-Error "1. Malformed version '$version' (expected N.N)"
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)" } elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
} }
# Detect type: ExternalDataProcessor or ExternalReport # Detect type: ExternalDataProcessor or ExternalReport
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# epf-validate v1.3 — Validate 1C external data processor / report structure # epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects # Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
@@ -10,6 +10,28 @@ import sys
from io import StringIO from io import StringIO
from lxml import etree from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
MD_NS = "http://v8.1c.ru/8.3/MDClasses" MD_NS = "http://v8.1c.ru/8.3/MDClasses"
V8_NS = "http://v8.1c.ru/8.1/data/core" V8_NS = "http://v8.1c.ru/8.1/data/core"
XR_NS = "http://v8.1c.ru/8.3/xcf/readable" XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
} }
# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def localname(el): def localname(el):
return etree.QName(el.tag).localname return etree.QName(el.tag).localname
@@ -50,7 +87,7 @@ def main():
parser.add_argument("-Detailed", action="store_true") parser.add_argument("-Detailed", action="store_true")
parser.add_argument("-MaxErrors", type=int, default=30) parser.add_argument("-MaxErrors", type=int, default=30)
parser.add_argument("-OutFile", default=None) parser.add_argument("-OutFile", default=None)
args = parser.parse_args() args = ci_parse_args(parser)
max_errors = args.MaxErrors max_errors = args.MaxErrors
@@ -163,11 +200,17 @@ def main():
check1_ok = False check1_ok = False
version = root.get("version", "") version = root.get("version", "")
version_rank = format_rank(version)
if not version: if not version:
report_warn("1. Missing version attribute on MetaDataObject") report_warn("1. Missing version attribute on MetaDataObject")
elif version not in ("2.17", "2.18", "2.19", "2.20", "2.21"): elif version_rank == 0:
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27). report_error(f"1. Malformed version '{version}' (expected N.N)")
report_warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)") elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
report_warn(f"1. Format version '{version}' is below the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
report_warn(f"1. Format version '{version}' is above the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
# Detect type # Detect type
child_elements = [] child_elements = []
@@ -42,7 +42,7 @@ allowed-tools:
Используй общий скрипт из epf-build: Используй общий скрипт из epf-build:
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -66,8 +66,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
```powershell ```powershell
# Сборка отчёта (файловая база) # Сборка отчёта (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf" powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" -InfoBasePath "C:\Bases\MyDB" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf" powershell.exe -NoProfile -File ".agents/skills/epf-build/scripts/epf-build.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -SourceFile "src/МойОтчёт.xml" -OutputFile "build/МойОтчёт.erf"
``` ```
@@ -41,7 +41,7 @@ allowed-tools:
Используй общий скрипт из epf-dump: Используй общий скрипт из epf-dump:
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" <параметры> powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" <параметры>
``` ```
### Параметры скрипта ### Параметры скрипта
@@ -66,8 +66,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dum
```powershell ```powershell
# Разборка отчёта (файловая база) # Разборка отчёта (файловая база)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МойОтчёт.erf" -OutputDir "src" powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" -InfoBasePath "C:\Bases\MyDB" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
# Серверная база # Серверная база
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МойОтчёт.erf" -OutputDir "src" powershell.exe -NoProfile -File ".agents/skills/epf-dump/scripts/epf-dump.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -InputFile "build/МойОтчёт.erf" -OutputDir "src"
``` ```
+49
View File
@@ -0,0 +1,49 @@
---
name: erf-init
description: Создать пустой внешний отчёт 1С (scaffold XML-исходников). Используй когда нужно создать новый внешний отчёт с нуля
argument-hint: <Name> [Synonym] [--with-skd]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /erf-init — Создание нового отчёта
Генерирует минимальный набор XML-исходников для внешнего отчёта 1С: корневой файл метаданных и каталог отчёта.
## Usage
```
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
```
| Параметр | Обязательный | По умолчанию | Описание |
|---------------|:------------:|--------------|---------------------------------------|
| Name | да | — | Имя отчёта (латиница/кириллица) |
| Synonym | нет | = Name | Синоним (отображаемое имя) |
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
`FormatVersion`**не выше** версии формата платформы, на которой объект будут собирать и открывать:
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
но на них навыки не проверялись — такое значение принимается с предупреждением.
## Команда
```powershell
powershell.exe -NoProfile -File ".agents/skills/erf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
```
## Дальнейшие шаги
- Добавить форму: `/form-add`
- Добавить макет: `/template-add`
- Добавить справку: `/help-add`
- Собрать ERF: `/erf-build`
+238
View File
@@ -0,0 +1,238 @@
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[Parameter(Mandatory)]
[string]$Name,
[string]$Synonym = $Name,
[string]$SrcDir = "src",
[switch]$WithSKD,
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
[string]$FormatVersion = "2.17"
)
$ErrorActionPreference = "Stop"
# --- Format version ---
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
# на нечисловое значение: это опечатка, а не версия.
$formatVerifiedMin = "2.17"
$formatVerifiedMax = "2.21"
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
$formatRank = Get-FormatRank $FormatVersion
function Esc-XmlText {
param([string]$s)
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
if ($formatRank -eq 0) {
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
exit 1
}
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
}
$uuid1 = [guid]::NewGuid().ToString()
$uuid2 = [guid]::NewGuid().ToString()
$uuid3 = [guid]::NewGuid().ToString()
$uuid4 = [guid]::NewGuid().ToString()
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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"'
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
# платформа держит объявления по алфавиту, дописать в конец нельзя.
if ($formatRank -ge 221) {
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
# --- Формируем Properties ---
$mainDCSValue = ""
$childObjectsContent = ""
if ($WithSKD) {
$mainDCSValue = "ExternalReport.$Name.Template.ОсновнаяСхемаКомпоновкиДанных"
$childObjectsContent = @"
<Template>ОсновнаяСхемаКомпоновкиДанных</Template>
"@
}
$mainDCSElement = if ($mainDCSValue) {
"<MainDataCompositionSchema>$mainDCSValue</MainDataCompositionSchema>"
} else {
"<MainDataCompositionSchema/>"
}
$childObjectsXml = if ($childObjectsContent) {
"<ChildObjects>$childObjectsContent</ChildObjects>"
} else {
"<ChildObjects/>"
}
$xml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<ExternalReport uuid="$uuid1">
<InternalInfo>
<xr:ContainedObject>
<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
<xr:ObjectId>$uuid2</xr:ObjectId>
</xr:ContainedObject>
<xr:GeneratedType name="ExternalReportObject.$Name" category="Object">
<xr:TypeId>$uuid3</xr:TypeId>
<xr:ValueId>$uuid4</xr:ValueId>
</xr:GeneratedType>
</InternalInfo>
<Properties>
<Name>$(Esc-XmlText $Name)</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
</v8:item>
</Synonym>
<Comment/>
<DefaultForm/>
<AuxiliaryForm/>
$mainDCSElement
<DefaultSettingsForm/>
<AuxiliarySettingsForm/>
<DefaultVariantForm/>
<VariantsStorage/>
<SettingsStorage/>
</Properties>
$childObjectsXml
</ExternalReport>
</MetaDataObject>
"@
$rootFile = Join-Path $SrcDir "$Name.xml"
$reportDir = Join-Path $SrcDir $Name
if (Test-Path $rootFile) {
Write-Error "Файл уже существует: $rootFile"
exit 1
}
if (-not (Test-Path $SrcDir)) {
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
}
$extDir = Join-Path $reportDir "Ext"
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
$enc = New-Object System.Text.UTF8Encoding($true)
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
# --- Модуль объекта ---
$moduleBsl = @"
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
$modulePath = Join-Path $extDir "ObjectModule.bsl"
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
Write-Host "[OK] Создан отчёт: $rootFile"
Write-Host " Каталог: $reportDir"
Write-Host " Модуль: $modulePath"
# --- СКД-макет (если --WithSKD) ---
if ($WithSKD) {
$templatesDir = Join-Path $reportDir "Templates"
$skdName = "ОсновнаяСхемаКомпоновкиДанных"
$skdMetaPath = Join-Path $templatesDir "$skdName.xml"
$skdExtDir = Join-Path (Join-Path $templatesDir $skdName) "Ext"
New-Item -ItemType Directory -Path $skdExtDir -Force | Out-Null
$skdUuid = [guid]::NewGuid().ToString()
$skdMetaXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $xmlnsDecl version="$FormatVersion">
<Template uuid="$skdUuid">
<Properties>
<Name>$skdName</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>Основная схема компоновки данных</v8:content>
</v8:item>
</Synonym>
<Comment/>
<TemplateType>DataCompositionSchema</TemplateType>
</Properties>
</Template>
</MetaDataObject>
"@
Write-XmlFile $skdMetaPath $skdMetaXml $enc
$skdContent = @"
<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
xmlns:v8="http://v8.1c.ru/8.1/data/core"
xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dataSource>
<name>ИсточникДанных1</name>
<dataSourceType>Local</dataSourceType>
</dataSource>
</DataCompositionSchema>
"@
$skdFilePath = Join-Path $skdExtDir "Template.xml"
Write-XmlFile $skdFilePath $skdContent $enc
Write-Host " СКД: $skdMetaPath"
Write-Host " Тело: $skdFilePath"
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Generates minimal XML source files for a 1C external report."""
import sys, os, re, argparse, uuid
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
def esc_xml_text(s):
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def new_uuid():
return str(uuid.uuid4())
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description='Init 1C external report scaffold', allow_abbrev=False)
parser.add_argument('-Name', dest='Name', required=True)
parser.add_argument('-Synonym', dest='Synonym', default=None)
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
args = ci_parse_args(parser)
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
format_rank_value = format_rank(args.FormatVersion)
if format_rank_value == 0:
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
sys.exit(1)
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
f"but was not verified on that platform", file=sys.stderr)
name = args.Name
synonym = args.Synonym if args.Synonym else name
src_dir = args.SrcDir
uuid1 = new_uuid()
uuid2 = new_uuid()
uuid3 = new_uuid()
uuid4 = new_uuid()
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' 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"'
)
format_version = args.FormatVersion
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
if format_rank(format_version) >= 221:
xmlns_decl = xmlns_decl.replace(
' xmlns:style=',
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
# --- Properties ---
main_dcs_value = ""
child_objects_content = ""
if args.WithSKD:
main_dcs_value = f"ExternalReport.{name}.Template.ОсновнаяСхемаКомпоновкиДанных"
child_objects_content = f"\n\t\t\t<Template>ОсновнаяСхемаКомпоновкиДанных</Template>\n"
main_dcs_element = f"<MainDataCompositionSchema>{main_dcs_value}</MainDataCompositionSchema>" if main_dcs_value else "<MainDataCompositionSchema/>"
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<ExternalReport uuid="{uuid1}">
\t\t<InternalInfo>
\t\t\t<xr:ContainedObject>
\t\t\t\t<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
\t\t\t</xr:ContainedObject>
\t\t\t<xr:GeneratedType name="ExternalReportObject.{name}" category="Object">
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
\t\t\t</xr:GeneratedType>
\t\t</InternalInfo>
\t\t<Properties>
\t\t\t<Name>{esc_xml_text(name)}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<DefaultForm/>
\t\t\t<AuxiliaryForm/>
\t\t\t{main_dcs_element}
\t\t\t<DefaultSettingsForm/>
\t\t\t<AuxiliarySettingsForm/>
\t\t\t<DefaultVariantForm/>
\t\t\t<VariantsStorage/>
\t\t\t<SettingsStorage/>
\t\t</Properties>
\t\t{child_objects_xml}
\t</ExternalReport>
</MetaDataObject>'''
root_file = os.path.join(src_dir, f"{name}.xml")
report_dir = os.path.join(src_dir, name)
if os.path.exists(root_file):
print(f"Файл уже существует: {root_file}", file=sys.stderr)
sys.exit(1)
os.makedirs(src_dir, exist_ok=True)
ext_dir = os.path.join(report_dir, "Ext")
os.makedirs(ext_dir, exist_ok=True)
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
# --- Модуль объекта ---
module_bsl = """\
#Область ОписаниеПеременных
#КонецОбласти
#Область ПрограммныйИнтерфейс
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти"""
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
print(f"[OK] Создан отчёт: {root_file}")
print(f" Каталог: {report_dir}")
print(f" Модуль: {module_path}")
# --- СКД-макет ---
if args.WithSKD:
templates_dir = os.path.join(report_dir, "Templates")
skd_name = "ОсновнаяСхемаКомпоновкиДанных"
skd_meta_path = os.path.join(templates_dir, f"{skd_name}.xml")
skd_ext_dir = os.path.join(templates_dir, skd_name, "Ext")
os.makedirs(skd_ext_dir, exist_ok=True)
skd_uuid = new_uuid()
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject {xmlns_decl} version="{format_version}">
\t<Template uuid="{skd_uuid}">
\t\t<Properties>
\t\t\t<Name>{skd_name}</Name>
\t\t\t<Synonym>
\t\t\t\t<v8:item>
\t\t\t\t\t<v8:lang>ru</v8:lang>
\t\t\t\t\t<v8:content>Основная схема компоновки данных</v8:content>
\t\t\t\t</v8:item>
\t\t\t</Synonym>
\t\t\t<Comment/>
\t\t\t<TemplateType>DataCompositionSchema</TemplateType>
\t\t</Properties>
\t</Template>
</MetaDataObject>'''
write_xml_file(skd_meta_path, skd_meta_xml)
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
\t\txmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
\t\txmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
\t\txmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
\t\txmlns:v8="http://v8.1c.ru/8.1/data/core"
\t\txmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
\t\txmlns:xs="http://www.w3.org/2001/XMLSchema"
\t\txmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
\t<dataSource>
\t\t<name>ИсточникДанных1</name>
\t\t<dataSourceType>Local</dataSourceType>
\t</dataSource>
</DataCompositionSchema>'''
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
write_xml_file(skd_file_path, skd_content)
print(f" СКД: {skd_meta_path}")
print(f" Тело: {skd_file_path}")
if __name__ == '__main__':
main()
@@ -26,7 +26,7 @@ allowed-tools:
## Команда ## Команда
```powershell ```powershell
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт" powershell.exe -NoProfile -File ".agents/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт"
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт/МойОтчёт.xml" powershell.exe -NoProfile -File ".agents/skills/epf-validate/scripts/epf-validate.ps1" -ObjectPath "src/МойОтчёт/МойОтчёт.xml"
``` ```
+93
View File
@@ -0,0 +1,93 @@
---
name: form-add
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
argument-hint: <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
allowed-tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
---
# /form-add — Добавление формы к объекту конфигурации
Создаёт управляемую форму (metadata XML + Form.xml + Module.bsl) и регистрирует её в корневом XML объекта конфигурации (Document, Catalog, InformationRegister и др.).
## Usage
```
/form-add <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
```
| Параметр | Обязательный | По умолчанию | Описание |
|-------------|:------------:|--------------|----------------------------------------------|
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
| FormName | да | — | Имя формы (ФормаДокумента) |
| Purpose | нет | основная форма вида | Назначение формы — см. таблицу ниже: у справочника это форма объекта, у регистра сведений — форма записи, у журнала — форма списка |
| Synonym | нет | = FormName | Синоним формы |
| -SetDefault | нет | авто | Сделать основной. Без флага основной становится первая форма каждого назначения |
## Команда
```powershell
powershell.exe -NoProfile -File ".agents/skills/form-add/scripts/form-add.ps1" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
```
## Purpose — назначение формы
| Purpose | Какая форма | Становится основной |
|---------|-------------|---------------------|
| Object | форма объекта (элемента, документа, обработки) | да |
| List | форма списка | да |
| Choice | форма выбора | да |
| Folder | форма группы | да |
| FolderChoice | форма выбора группы | да |
| Record | форма записи | да |
| RecordSet | форма набора записей | нет — в платформе нет такого свойства |
| Save | форма сохранения настроек | да |
| Load | форма загрузки настроек | да |
| Custom | произвольная форма, без привязки к объекту | нет |
### Что доступно типу объекта
| Тип объекта | Назначения |
|-------------|------------|
| Catalog, ChartOfCharacteristicTypes | Object, Folder, List, Choice, FolderChoice, Custom |
| Document, ChartOfAccounts, ChartOfCalculationTypes, ExchangePlan, BusinessProcess, Task | Object, List, Choice, Custom |
| DataProcessor, Report, ExternalDataProcessor, ExternalReport | Object, Custom |
| InformationRegister | Record, List, RecordSet, Custom |
| AccumulationRegister, AccountingRegister, CalculationRegister | List, RecordSet, Custom |
| DocumentJournal, FilterCriterion | List, Custom |
| Enum | List, Choice, Custom |
| SettingsStorage | Save, Load, Custom |
Недопустимое сочетание отклоняется со списком доступных для этого типа. У константы собственных
форм нет — для неё используется общая форма (`CommonForm`).
## Примеры
```
# Форма документа
/form-add Documents/АвансовыйОтчет.xml ФормаДокумента -Purpose Object
# Форма списка каталога
/form-add Catalogs/Контрагенты.xml ФормаСписка -Purpose List
# Форма записи регистра сведений
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи -Purpose Record
# Форма выбора с синонимом
/form-add Catalogs/Номенклатура.xml ФормаВыбора -Purpose Choice -Synonym "Выбор номенклатуры"
# Установить как форму по умолчанию
/form-add Documents/Заказ.xml ФормаДокументаНовая -Purpose Object -SetDefault
```
## Workflow
1. `/form-add` — создать каркас формы
2. `/form-compile` или `/form-edit` — наполнить Form.xml элементами
3. `/form-validate` — проверить корректность
4. `/form-info` — проанализировать результат
@@ -0,0 +1,795 @@
# form-add v1.28 — Add managed form to 1C config object
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
[Parameter(Mandatory)]
[string]$ObjectPath,
[Parameter(Mandatory)]
[string]$FormName,
[string]$Synonym = $FormName,
# Пусто = основная форма вида (Primary в таблице): у справочника это форма объекта,
# у регистра сведений — форма записи, у журнала — форма списка. Жёсткое "Object"
# по умолчанию было бы неверным для видов, у которых формы объекта не бывает.
[string]$Purpose = "",
# Алиас с дефисом внутри имени: вызов вида --set-default PowerShell разбирает как имя
# параметра "set-default" и без алиаса отвечает отказом биндинга. Написания -SetDefault,
# --SetDefault и --setdefault совпадают с именем параметра и так.
[Alias('set-default')]
[switch]$SetDefault
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
# --- 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 }
}
# --- Detect XML format version ---
function Detect-FormatVersion([string]$dir) {
$d = $dir
while ($d) {
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
$extPath = "$d.xml"
if (Test-Path $extPath) {
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$cfgPath = Join-Path $d "Configuration.xml"
if (Test-Path $cfgPath) {
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
}
$parent = Split-Path $d -Parent
if ($parent -eq $d) { break }
$d = $parent
}
return "2.17"
}
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
function Get-FormatRank([string]$ver) {
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
return 0
}
# --- Фаза 1: Определение типа объекта ---
# Resolve ObjectPath (directory → .xml)
if (-not [System.IO.Path]::IsPathRooted($ObjectPath)) {
$ObjectPath = Join-Path (Get-Location).Path $ObjectPath
}
if (Test-Path $ObjectPath -PathType Container) {
$dirName = Split-Path $ObjectPath -Leaf
$candidate = Join-Path $ObjectPath "$dirName.xml"
$sibling = Join-Path (Split-Path $ObjectPath) "$dirName.xml"
if (Test-Path $candidate) { $ObjectPath = $candidate }
elseif (Test-Path $sibling) { $ObjectPath = $sibling }
}
if (-not (Test-Path $ObjectPath)) {
Write-Error "Файл объекта не найден: $ObjectPath"
exit 1
}
$objectXmlFull = Resolve-Path $ObjectPath
Assert-EditAllowed $objectXmlFull.Path 'editable'
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
$script:formatVersion = $null
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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"'
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" 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"'
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
if ((Get-FormatRank $script:formatVersion) -ge 221) {
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $true
$xmlDoc.Load($objectXmlFull.Path)
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
# Определяем тип объекта по корневому тегу внутри MetaDataObject
$metaDataObject = $xmlDoc.SelectSingleNode("//md:MetaDataObject", $nsMgr)
if (-not $metaDataObject) {
# Пробуем без namespace (fallback)
$metaDataObject = $xmlDoc.DocumentElement
}
# --- Таблица видов: вид → допустимые назначения ---
#
# Одна запись на вид вместо разрозненных списков «поддерживаемые типы», «объектные типы»,
# «обработко-подобные» и «карта типов реквизита». Раньше они расходились молча: DocumentJournal
# был среди поддерживаемых, но не в карте типов, и в форму уходило `cfg:.Журнал` — платформа
# такую выгрузку не принимает, а навык рапортовал успех.
#
# MainAttr — тип главного реквизита; `{0}` подставляется именем объекта:
# "DynamicList" — динамический список (добавляется Settings/MainTable);
# $null — произвольная форма, блока Attributes нет вовсе.
# Slot — свойство объекта под «основную форму»; $null — такого свойства у вида нет.
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
$formKinds = @{
"Catalog" = @{
"Object" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"Folder" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfCharacteristicTypes" = @{
"Object" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"Folder" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Document" = @{
"Object" = @{ MainAttr = "DocumentObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfAccounts" = @{
"Object" = @{ MainAttr = "ChartOfAccountsObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ChartOfCalculationTypes" = @{
"Object" = @{ MainAttr = "ChartOfCalculationTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExchangePlan" = @{
"Object" = @{ MainAttr = "ExchangePlanObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"BusinessProcess" = @{
"Object" = @{ MainAttr = "BusinessProcessObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Task" = @{
"Object" = @{ MainAttr = "TaskObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"DataProcessor" = @{
"Object" = @{ MainAttr = "DataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Report" = @{
"Object" = @{ MainAttr = "ReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExternalDataProcessor" = @{
"Object" = @{ MainAttr = "ExternalDataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"ExternalReport" = @{
"Object" = @{ MainAttr = "ExternalReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"InformationRegister" = @{
"Record" = @{ MainAttr = "InformationRegisterRecordManager.{1}"; AttrName = "Запись"; Slot = "DefaultRecordForm"; SavedData = $true; Primary = $true }
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
"RecordSet" = @{ MainAttr = "InformationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"AccumulationRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "AccumulationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"AccountingRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "AccountingRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"CalculationRegister" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"RecordSet" = @{ MainAttr = "CalculationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"DocumentJournal" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"FilterCriterion" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"Enum" = @{
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
"SettingsStorage" = @{
"Save" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultSaveForm"; Primary = $true }
"Load" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultLoadForm" }
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
}
}
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает — отказ с причиной,
# а не «тип не поддерживается».
$noOwnForms = @{
"Constant" = "у константы нет собственных форм — используйте общую форму (CommonForm)"
}
$supportedTypes = @($formKinds.Keys) + @($noOwnForms.Keys)
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в метаданных
# формы есть <ExtendedPresentation>.
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему документу
# имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса есть свойство
# <Task>, и он определялся как задача, после чего имя объекта не находилось вовсе.
$objectType = $null
$objectNode = $null
foreach ($child in $metaDataObject.ChildNodes) {
if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element) {
$objectType = $child.LocalName
$objectNode = $child
break
}
}
if ($objectType -and -not ($formKinds.ContainsKey($objectType) -or $noOwnForms.ContainsKey($objectType))) {
Write-Error "Тип объекта '$objectType' не поддерживается. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
exit 1
}
if (-not $objectType) {
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
exit 1
}
if ($noOwnForms.ContainsKey($objectType)) {
Write-Error "$objectType не поддерживается: $($noOwnForms[$objectType])"
exit 1
}
# Имя объекта из Properties/Name
$objectName = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:Name", $nsMgr).InnerText
if (-not $objectName) {
Write-Error "Не удалось определить имя объекта из Properties/Name"
exit 1
}
Write-Host ""
Write-Host "=== form-add ==="
Write-Host ""
Write-Host "Object: $objectType.$objectName"
# --- Фаза 2: Валидация Purpose ---
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell (в py-порту .lower()).
$kindPurposes = $formKinds[$objectType]
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
$purposeSynonyms = @{
"формаобъекта"="Object"; "формаэлемента"="Object"; "формадокумента"="Object"
"объект"="Object"; "элемент"="Object"; "документ"="Object"; "objectform"="Object"
"формасписка"="List"; "список"="List"; "listform"="List"
"формавыбора"="Choice"; "выбор"="Choice"; "choiceform"="Choice"
"формагруппы"="Folder"; "группа"="Folder"; "folderform"="Folder"
"формавыборагруппы"="FolderChoice"; "выборгруппы"="FolderChoice"; "folderchoiceform"="FolderChoice"
"формазаписи"="Record"; "запись"="Record"; "recordform"="Record"
"форманаборазаписей"="RecordSet"; "наборзаписей"="RecordSet"; "recordsetform"="RecordSet"
"формасохранения"="Save"; "формасохранениянастроек"="Save"; "сохранение"="Save"; "saveform"="Save"
"формазагрузки"="Load"; "формазагрузкинастроек"="Load"; "загрузка"="Load"; "loadform"="Load"
"произвольная"="Custom"; "произвольнаяформа"="Custom"; "customform"="Custom"
}
if ($Purpose) {
$purposeProbe = ($Purpose -replace '[\s_-]', '').ToLowerInvariant()
$isKnownPurpose = $false
foreach ($p in $kindPurposes.Keys) {
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $isKnownPurpose = $true; break }
}
if (-not $isKnownPurpose -and $purposeSynonyms.ContainsKey($purposeProbe)) {
$Purpose = $purposeSynonyms[$purposeProbe]
}
}
if (-not $Purpose) {
foreach ($p in $kindPurposes.Keys) {
if ($kindPurposes[$p].Primary) { $Purpose = $p; break }
}
}
$purposeKey = $null
foreach ($p in $kindPurposes.Keys) {
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $purposeKey = $p; break }
}
if (-not $purposeKey) {
Write-Error "Назначение '$Purpose' недопустимо для $objectType. Допустимые: $(($kindPurposes.Keys | Sort-Object) -join ', ')"
exit 1
}
$Purpose = $purposeKey
$purposeRule = $kindPurposes[$Purpose]
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой MainAttr — это
# произвольная форма (законное состояние), а вот наполовину заполненная запись означала бы, что
# таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
if ($purposeRule.MainAttr -and -not $purposeRule.AttrName) {
Write-Error "Внутренняя ошибка таблицы видов: у $objectType/$Purpose задан MainAttr без AttrName"
exit 1
}
# --- Фаза 3: Создание файлов ---
$objectDir = [System.IO.Path]::ChangeExtension($objectXmlFull.Path, $null).TrimEnd('.')
$formsDir = Join-Path $objectDir "Forms"
$formMetaPath = Join-Path $formsDir "$FormName.xml"
if (Test-Path $formMetaPath) {
Write-Error "Форма уже существует: $formMetaPath"
exit 1
}
$formDir = Join-Path $formsDir $FormName
$formExtDir = Join-Path $formDir "Ext"
$formModuleDir = Join-Path $formExtDir "Form"
New-Item -ItemType Directory -Path $formModuleDir -Force | Out-Null
$encBom = New-Object System.Text.UTF8Encoding($true)
# --- 3a. Метаданные формы ---
$formUuid = [guid]::NewGuid().ToString()
# ExtendedPresentation — only for DataProcessor, Report, ExternalDataProcessor, ExternalReport forms
$extPresentationLine = ""
if ($objectType -in $processorLikeTypes) {
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
}
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
$useInIfcLine = ""
if ((Get-FormatRank $script:formatVersion) -ge 221) {
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
}
$formMetaXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
<Form uuid="$formUuid">
<Properties>
<Name>$FormName</Name>
<Synonym>
<v8:item>
<v8:lang>ru</v8:lang>
<v8:content>$Synonym</v8:content>
</v8:item>
</Synonym>
<Comment/>
<FormType>Managed</FormType>
<IncludeHelpInContents>false</IncludeHelpInContents>
<UsePurposes>
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
</UsePurposes>$useInIfcLine$extPresentationLine
</Properties>
</Form>
</MetaDataObject>
"@
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
#
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
#
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
function Write-XmlFile([string]$path, [string]$text, $encoding) {
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
}
Write-XmlFile $formMetaPath $formMetaXml $encBom
# --- 3b. Form.xml ---
$formXmlPath = Join-Path $formExtDir "Form.xml"
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
$attributesBlock = ""
if ($purposeRule.MainAttr) {
$mainAttrType = $purposeRule.MainAttr -f $objectType, $objectName
$mainAttrName = $purposeRule.AttrName
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
$tailLines = ""
if ($mainAttrType -eq "DynamicList") {
$mainTable = "$objectType.$objectName"
$tailLines = "`n`t`t`t<Settings xsi:type=""DynamicList"">`n`t`t`t`t<MainTable>$mainTable</MainTable>`n`t`t`t</Settings>"
} elseif ($purposeRule.SavedData) {
$tailLines = "`n`t`t`t<SavedData>true</SavedData>"
}
$attributesBlock = @"
<Attributes>
<Attribute name="$mainAttrName" id="1">
<Type>
<v8:Type>cfg:$mainAttrType</v8:Type>
</Type>
<MainAttribute>true</MainAttribute>$tailLines
</Attribute>
</Attributes>
"@
}
# Произвольная форма (MainAttr = $null) — без блока Attributes вовсе. В типовых это самая
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
$formXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<Form $($script:formNsDecl) version="$($script:formatVersion)">
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
<Autofill>true</Autofill>
</AutoCommandBar>
<ChildItems/>$attributesBlock
</Form>
"@
if (Test-Path $formXmlPath) {
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
} else {
Write-XmlFile $formXmlPath $formXml $encBom
}
# --- 3c. Module.bsl ---
$modulePath = Join-Path $formModuleDir "Module.bsl"
$moduleBsl = @"
#Область ОбработчикиСобытийФормы
#КонецОбласти
#Область ОбработчикиСобытийЭлементовФормы
#КонецОбласти
#Область ОбработчикиКомандФормы
#КонецОбласти
#Область ОбработчикиОповещений
#КонецОбласти
#Область СлужебныеПроцедурыИФункции
#КонецОбласти
"@
if (Test-Path $modulePath) {
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
} else {
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
# самого скрипта, а он в репозитории хранится с LF.
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
}
# --- Фаза 4: Регистрация в родительском объекте ---
$childObjects = $xmlDoc.SelectSingleNode("//md:${objectType}/md:ChildObjects", $nsMgr)
if (-not $childObjects) {
Write-Error "Не найден элемент ChildObjects в $ObjectPath"
exit 1
}
# Добавить <Form>$FormName</Form> — идемпотентно (не дублировать уже зарегистрированную)
$alreadyRegistered = [bool]$childObjects.SelectSingleNode("md:Form[text()='$FormName']", $nsMgr)
if (-not $alreadyRegistered) {
$formElem = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
$formElem.InnerText = $FormName
# Ищем первый <Template> для вставки перед ним
$firstTemplate = $childObjects.SelectSingleNode("md:Template", $nsMgr)
# Ищем первую <TabularSection> для вставки перед ней (если нет Template)
$firstTabular = $childObjects.SelectSingleNode("md:TabularSection", $nsMgr)
# Определяем точку вставки: перед Template, перед TabularSection, или в конец
$insertBefore = $null
if ($firstTemplate) {
$insertBefore = $firstTemplate
} elseif ($firstTabular) {
$insertBefore = $firstTabular
}
if ($insertBefore) {
# Вставить перед найденным элементом, с переносом строки
$whitespace = $xmlDoc.CreateWhitespace("`n`t`t`t")
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
$childObjects.InsertBefore($whitespace, $formElem) | Out-Null
# Переставляем: whitespace перед formElem — неправильный порядок
# Правильно: formElem, затем whitespace перед insertBefore
# InsertBefore возвращает вставленный узел, порядок: ... formElem whitespace insertBefore ...
# На самом деле нам нужно: ... \n\t\t\tformElem \n\t\t\tinsertBefore
# Удалим и вставим правильно
$childObjects.RemoveChild($whitespace) | Out-Null
$childObjects.RemoveChild($formElem) | Out-Null
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
# Whitespace нужен ДО formElem (перенос строки + отступ)
# Но перед insertBefore уже должен быть whitespace от предыдущего элемента
# Нам нужно добавить whitespace ПОСЛЕ formElem (перед insertBefore)
$ws = $xmlDoc.CreateWhitespace("`n`t`t`t")
$childObjects.InsertBefore($ws, $insertBefore) | Out-Null
} else {
# Добавить в конец ChildObjects
if ($childObjects.ChildNodes.Count -eq 0) {
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
$childObjects.AppendChild($formElem) | Out-Null
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
} else {
$lastChild = $childObjects.LastChild
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
$childObjects.InsertBefore($xmlDoc.CreateWhitespace("`n`t`t`t"), $lastChild) | Out-Null
$childObjects.InsertBefore($formElem, $lastChild) | Out-Null
} else {
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
$childObjects.AppendChild($formElem) | Out-Null
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
}
}
}
}
# --- SetDefault ---
$existingForms = $childObjects.SelectNodes("md:Form", $nsMgr)
$isFirstFormForPurpose = $false
$defaultPropName = $null
$defaultValue = "$objectType.$objectName.Form.$FormName"
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному Purpose без учёта
# вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не находился, навык
# молча ничего не делал.
$defaultPropName = $purposeRule.Slot
$defaultNode = $null
if ($defaultPropName) {
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
if ($defaultNode) {
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
}
}
$defaultUpdated = $false
if ($SetDefault -or $isFirstFormForPurpose) {
if ($defaultNode) {
$defaultNode.InnerText = $defaultValue
$defaultUpdated = $true
}
}
# Сохранить с BOM
$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 отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$xmlText = [regex]::Replace($xmlText, '(?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 $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
# --- Фаза 5: Вывод ---
# Относительные пути для вывода
$basePath = Split-Path $objectXmlFull.Path -Parent
# Определяем корень (ищем родительский каталог типа Documents, Catalogs и т.д.)
$relFormMeta = $formMetaPath.Replace($basePath, "").TrimStart("\", "/")
$relFormXml = $formXmlPath.Replace($basePath, "").TrimStart("\", "/")
$relModule = $modulePath.Replace($basePath, "").TrimStart("\", "/")
$objFileName = [System.IO.Path]::GetFileName($ObjectPath)
$objDirName = Split-Path $ObjectPath -Parent
$objBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
Write-Host "Created:"
Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
Write-Host ""
if ($alreadyRegistered) {
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
} else {
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
}
if ($defaultUpdated) {
Write-Host "${defaultPropName}: $defaultValue"
} elseif (-not $defaultPropName) {
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
# у платформы нет (форма набора записей, произвольная форма).
Write-Host "Основной не назначена: у $objectType нет свойства для формы с назначением $Purpose"
}
Write-Host ""
+906
View File
@@ -0,0 +1,906 @@
#!/usr/bin/env python3
# form-add v1.28 — Add managed form to 1C config object (Python port)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
import os
import re
import sys
import uuid
from lxml import etree
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
def ci_parse_args(parser, argv=None):
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
argv = list(sys.argv[1:] if argv is None else argv)
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
for i, tok in enumerate(argv):
if tok.startswith('-') and tok.lower() in names:
argv[i] = names[tok.lower()]
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
choice_map = {}
for a in parser._actions:
if a.choices:
for s in a.option_strings:
choice_map[s] = {str(c).lower(): c for c in a.choices}
for i in range(len(argv) - 1):
m = choice_map.get(argv[i])
if m and argv[i + 1].lower() in m:
argv[i + 1] = m[argv[i + 1].lower()]
return parser.parse_args(argv)
# ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
# ============================================================
def _sg_root_uuid(xml_path):
if not os.path.isfile(xml_path):
return None
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str) and child.get("uuid"):
return child.get("uuid")
except Exception:
return None
return None
def _sg_is_external_root(xml_path):
if not os.path.isfile(xml_path):
return False
try:
mx = etree.parse(xml_path).getroot()
for child in mx:
if isinstance(child.tag, str):
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
except Exception:
return False
return False
def _sg_find_v8project(start_dir):
d = start_dir
for _ in range(20):
if not d:
break
pj = os.path.join(d, ".v8-project.json")
if os.path.isfile(pj):
return pj
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return None
def _sg_get_edit_mode(cfg_dir):
try:
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
if not pj:
return "deny"
proj = json.loads(open(pj, encoding="utf-8-sig").read())
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
for db in proj.get("databases", []):
src = db.get("configSrc")
if src:
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
if db.get("editingAllowedCheck"):
return db["editingAllowedCheck"]
if proj.get("editingAllowedCheck"):
return proj["editingAllowedCheck"]
return "deny"
except Exception:
return "deny"
def assert_edit_allowed(target_path, require):
try:
rp = os.path.abspath(target_path)
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
if _sg_is_external_root(rp):
return
elem_uuid = _sg_root_uuid(rp)
cfg_dir = None
bin_path = None
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
for _ in range(12):
if not d:
break
if _sg_is_external_root(d + ".xml"):
return
if not elem_uuid:
elem_uuid = _sg_root_uuid(d + ".xml")
if not cfg_dir:
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
cfg_dir = d
bin_path = cand
if elem_uuid and cfg_dir:
break
parent = os.path.dirname(d)
if parent == d:
break
d = parent
if not elem_uuid and cfg_dir:
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
if not bin_path or not os.path.exists(bin_path):
return
data = open(bin_path, "rb").read()
if len(data) <= 32:
return
if data[:3] == b"\xef\xbb\xbf":
data = data[3:]
text = data.decode("utf-8", "replace")
h = re.match(r"\{6,(\d+),(\d+),", text)
if not h:
return
g = int(h.group(1))
k = int(h.group(2))
if k == 0:
return
best = None
if elem_uuid:
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
f1 = int(m.group(1))
if best is None or f1 < best:
best = f1
blocked = False
code = ""
reason = ""
if g == 1:
blocked = True
code = "capability-off"
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
elif require == "removed":
if best is not None and best != 2:
blocked = True
code = "not-removed"
reason = "объект не снят с поддержки — удаление сломает обновления"
else:
if best is not None and best == 0:
blocked = True
code = "locked"
reason = "объект на замке — редактирование сломает обновления"
if not blocked:
return
mode = _sg_get_edit_mode(cfg_dir)
if mode == "off":
return
if mode == "warn":
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
return
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
if code == "capability-off":
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
fix = (
"Либо снять защиту явно (навык support-edit, два шага):\n"
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
)
elif code == "not-removed":
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
fix = (
"Либо сначала снять объект с поддержки, затем удалять:\n"
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
)
else:
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
fix = (
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
)
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
sys.exit(1)
except SystemExit:
raise
except Exception:
return
NSMAP = {
"md": "http://v8.1c.ru/8.3/MDClasses",
"v8": "http://v8.1c.ru/8.1/data/core",
}
def detect_format_version(d):
while d:
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
ext_path = d + ".xml"
if os.path.isfile(ext_path):
with open(ext_path, "r", encoding="utf-8-sig") as f:
ext_head = f.read(2000)
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
if m:
return m.group(1)
cfg_path = os.path.join(d, "Configuration.xml")
if os.path.isfile(cfg_path):
with open(cfg_path, "r", encoding="utf-8-sig") as f:
head = f.read(2000)
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
if m:
return m.group(1)
parent = os.path.dirname(d)
if parent == d:
break
d = parent
return "2.17"
def format_rank(ver):
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
def _detect_xml_style(path):
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
финальный перенос. None → файл новый (сохранить текущее поведение)."""
try:
raw = open(path, "rb").read()
except OSError:
return None
bom = raw.startswith(b"\xef\xbb\xbf")
body = raw[3:] if bom else raw
crlf = b"\r\n" in body
m = re.search(rb'encoding="([^"]+)"', body[:200])
enc = m.group(1).decode("ascii") if m else "utf-8"
final_nl = body.endswith(b"\n")
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
def _finalize_xml_bytes(xml_bytes, style):
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
enc_decl = style["enc"] if style else "UTF-8"
xml_bytes = xml_bytes.replace(
b"<?xml version='1.0' encoding='UTF-8'?>",
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
# Канонизировать переносы к LF (убирает &#13; от \r в tail'ах)
xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"")
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
want_final_nl = style["final_nl"] if style else False
xml_bytes = xml_bytes.rstrip(b"\n")
if want_final_nl:
xml_bytes += b"\n"
# EOL — как в оригинале (новый файл → CRLF, канон #57)
if (style["crlf"] if style else True):
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
return xml_bytes
def save_xml_with_bom(tree, path):
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
style = _detect_xml_style(path)
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
with open(path, "wb") as f:
if style is None or style["bom"]:
f.write(b"\xef\xbb\xbf")
f.write(xml_bytes)
def write_utf8_bom(path, content):
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
f.write(content)
def write_xml_file(path, content):
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
Модуль .bsl сюда НЕ идёт — он пишется отдельно.
"""
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
write_utf8_bom(path, text)
def main():
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
parser = argparse.ArgumentParser(description="Add managed form to 1C config object", allow_abbrev=False)
parser.add_argument("-ObjectPath", required=True)
parser.add_argument("-FormName", required=True)
parser.add_argument("-Synonym", default=None)
# Пусто = основная форма вида (primary в таблице): у справочника это форма объекта,
# у регистра сведений — форма записи, у журнала — форма списка.
parser.add_argument("-Purpose", default="")
# Написания с дефисом внутри имени и с двойным дефисом: в PS-порте их принимает алиас
# set-default, здесь — перечисление опций, чтобы порты принимали ровно одно и то же.
parser.add_argument("-SetDefault", "--SetDefault", "--set-default", "-set-default",
dest="SetDefault", action="store_true")
args = ci_parse_args(parser)
object_path = args.ObjectPath
form_name = args.FormName
synonym = args.Synonym if args.Synonym is not None else form_name
purpose = args.Purpose
set_default = args.SetDefault
# --- Phase 1: Determine object type ---
# Resolve ObjectPath (directory → .xml)
if not os.path.isabs(object_path):
object_path = os.path.join(os.getcwd(), object_path)
if os.path.isdir(object_path):
dir_name = os.path.basename(object_path.rstrip("/\\"))
candidate = os.path.join(object_path, dir_name + ".xml")
sibling = os.path.join(os.path.dirname(object_path.rstrip("/\\")), dir_name + ".xml")
if os.path.isfile(candidate):
object_path = candidate
elif os.path.isfile(sibling):
object_path = sibling
if not os.path.isfile(object_path):
print(f"Файл объекта не найден: {object_path}", file=sys.stderr)
sys.exit(1)
object_xml_full = os.path.abspath(object_path)
assert_edit_allowed(object_xml_full, "editable")
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
format_version = None
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
obj_head = f.read(2000)
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
if m_ver:
format_version = m_ver.group(1)
if not format_version:
format_version = detect_format_version(os.path.dirname(object_xml_full))
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
xmlns_decl = (
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
' 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"'
)
form_ns_decl = (
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
' 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"'
)
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
if format_rank(format_version) >= 221:
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
parser_xml = etree.XMLParser(remove_blank_text=False)
tree = etree.parse(object_xml_full, parser_xml)
root = tree.getroot()
# --- Таблица видов: вид -> допустимые назначения ---
#
# Зеркало $formKinds из PS-порта. Одна запись на вид вместо разрозненных списков
# «поддерживаемые типы», «объектные типы», «обработко-подобные» и «карта типов реквизита»:
# раньше они расходились молча, и для DocumentJournal в форму уходило `cfg:.Журнал`.
#
# main_attr — тип главного реквизита, {0} = вид, {1} = имя объекта;
# "DynamicList" — динамический список (добавляется Settings/MainTable);
# None — произвольная форма, блока Attributes нет вовсе.
# slot — свойство объекта под «основную форму»; None — такого свойства у вида нет.
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
form_kinds = {
"Catalog": {
"Object": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"Folder": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
"slot": "DefaultFolderForm", "saved_data": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfCharacteristicTypes": {
"Object": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"Folder": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultFolderForm", "saved_data": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Document": {
"Object": {"main_attr": "DocumentObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfAccounts": {
"Object": {"main_attr": "ChartOfAccountsObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ChartOfCalculationTypes": {
"Object": {"main_attr": "ChartOfCalculationTypesObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExchangePlan": {
"Object": {"main_attr": "ExchangePlanObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"BusinessProcess": {
"Object": {"main_attr": "BusinessProcessObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Task": {
"Object": {"main_attr": "TaskObject.{1}", "attr_name": "Объект",
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"DataProcessor": {
"Object": {"main_attr": "DataProcessorObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Report": {
"Object": {"main_attr": "ReportObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExternalDataProcessor": {
"Object": {"main_attr": "ExternalDataProcessorObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"ExternalReport": {
"Object": {"main_attr": "ExternalReportObject.{1}", "attr_name": "Объект",
"slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"InformationRegister": {
"Record": {"main_attr": "InformationRegisterRecordManager.{1}", "attr_name": "Запись",
"slot": "DefaultRecordForm", "saved_data": True, "primary": True},
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
"RecordSet": {"main_attr": "InformationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"AccumulationRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "AccumulationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"AccountingRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "AccountingRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"CalculationRegister": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"RecordSet": {"main_attr": "CalculationRegisterRecordSet.{1}", "attr_name": "Набор",
"slot": None, "saved_data": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"DocumentJournal": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"FilterCriterion": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"Enum": {
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
"SettingsStorage": {
"Save": {"main_attr": None, "attr_name": None, "slot": "DefaultSaveForm", "primary": True},
"Load": {"main_attr": None, "attr_name": None, "slot": "DefaultLoadForm"},
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
},
}
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает.
no_own_forms = {
"Constant": "у константы нет собственных форм — используйте общую форму (CommonForm)",
}
supported_types = list(form_kinds) + list(no_own_forms)
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в
# метаданных формы есть <ExtendedPresentation>.
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему
# документу имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса
# есть свойство <Task>, и он определялся как задача, после чего имя объекта не находилось.
object_type = None
object_node = None
for child in root:
if isinstance(child.tag, str):
object_type = etree.QName(child).localname
object_node = child
break
if object_type is not None and object_type not in form_kinds and object_type not in no_own_forms:
print(f"Тип объекта '{object_type}' не поддерживается. "
f"Поддерживаемые типы: {', '.join(sorted(form_kinds))}", file=sys.stderr)
sys.exit(1)
if object_type is None:
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(sorted(form_kinds))}",
file=sys.stderr)
sys.exit(1)
if object_type in no_own_forms:
print(f"{object_type} не поддерживается: {no_own_forms[object_type]}", file=sys.stderr)
sys.exit(1)
# Object name from Properties/Name
name_node = root.find(f".//md:{object_type}/md:Properties/md:Name", NSMAP)
if name_node is None or not name_node.text:
print("Не удалось определить имя объекта из Properties/Name", file=sys.stderr)
sys.exit(1)
object_name = name_node.text
print()
print("=== form-add ===")
print()
print(f"Object: {object_type}.{object_name}")
# --- Phase 2: Validate Purpose ---
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell.
kind_purposes = form_kinds[object_type]
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
purpose_synonyms = {
"формаобъекта": "Object", "формаэлемента": "Object", "формадокумента": "Object",
"объект": "Object", "элемент": "Object", "документ": "Object", "objectform": "Object",
"формасписка": "List", "список": "List", "listform": "List",
"формавыбора": "Choice", "выбор": "Choice", "choiceform": "Choice",
"формагруппы": "Folder", "группа": "Folder", "folderform": "Folder",
"формавыборагруппы": "FolderChoice", "выборгруппы": "FolderChoice",
"folderchoiceform": "FolderChoice",
"формазаписи": "Record", "запись": "Record", "recordform": "Record",
"форманаборазаписей": "RecordSet", "наборзаписей": "RecordSet", "recordsetform": "RecordSet",
"формасохранения": "Save", "формасохранениянастроек": "Save", "сохранение": "Save",
"saveform": "Save",
"формазагрузки": "Load", "формазагрузкинастроек": "Load", "загрузка": "Load",
"loadform": "Load",
"произвольная": "Custom", "произвольнаяформа": "Custom", "customform": "Custom",
}
if purpose:
purpose_probe = re.sub(r"[\s_-]", "", purpose).lower()
is_known_purpose = any(k.lower() == purpose.lower() for k in kind_purposes)
if not is_known_purpose and purpose_probe in purpose_synonyms:
purpose = purpose_synonyms[purpose_probe]
if not purpose:
for k, rule in kind_purposes.items():
if rule.get("primary"):
purpose = k
break
purpose_key = None
for k in kind_purposes:
if k.lower() == purpose.lower():
purpose_key = k
break
if purpose_key is None:
print(f"Назначение '{purpose}' недопустимо для {object_type}. "
f"Допустимые: {', '.join(sorted(kind_purposes))}", file=sys.stderr)
sys.exit(1)
purpose = purpose_key
purpose_rule = kind_purposes[purpose]
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой main_attr —
# это произвольная форма (законное состояние), а наполовину заполненная запись означала бы,
# что таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
if purpose_rule.get("main_attr") and not purpose_rule.get("attr_name"):
print(f"Внутренняя ошибка таблицы видов: у {object_type}/{purpose} задан main_attr без attr_name",
file=sys.stderr)
sys.exit(1)
# --- Phase 3: Create files ---
object_dir = os.path.splitext(object_xml_full)[0]
forms_dir = os.path.join(object_dir, "Forms")
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
if os.path.exists(form_meta_path):
print(f"Форма уже существует: {form_meta_path}", file=sys.stderr)
sys.exit(1)
form_dir = os.path.join(forms_dir, form_name)
form_ext_dir = os.path.join(form_dir, "Ext")
form_module_dir = os.path.join(form_ext_dir, "Form")
os.makedirs(form_module_dir, exist_ok=True)
# --- 3a. Form metadata ---
form_uuid = str(uuid.uuid4())
form_meta_xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
f'\t<Form uuid="{form_uuid}">\n'
'\t\t<Properties>\n'
f'\t\t\t<Name>{form_name}</Name>\n'
'\t\t\t<Synonym>\n'
'\t\t\t\t<v8:item>\n'
'\t\t\t\t\t<v8:lang>ru</v8:lang>\n'
f'\t\t\t\t\t<v8:content>{synonym}</v8:content>\n'
'\t\t\t\t</v8:item>\n'
'\t\t\t</Synonym>\n'
'\t\t\t<Comment/>\n'
'\t\t\t<FormType>Managed</FormType>\n'
'\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>\n'
'\t\t\t<UsePurposes>\n'
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
'\t\t\t</UsePurposes>\n'
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
if format_rank(format_version) >= 221 else '')
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
+ '\t\t</Properties>\n'
'\t</Form>\n'
'</MetaDataObject>'
)
write_xml_file(form_meta_path, form_meta_xml)
# --- 3b. Form.xml ---
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
attributes_block = ''
if purpose_rule.get("main_attr"):
main_attr_type = purpose_rule["main_attr"].format(object_type, object_name)
main_attr_name = purpose_rule["attr_name"]
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
tail_lines = ''
if main_attr_type == "DynamicList":
main_table = f"{object_type}.{object_name}"
tail_lines = ('\t\t\t<Settings xsi:type="DynamicList">\n'
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
'\t\t\t</Settings>\n')
elif purpose_rule.get("saved_data"):
tail_lines = '\t\t\t<SavedData>true</SavedData>\n'
attributes_block = (
'\t<Attributes>\n'
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
'\t\t\t<Type>\n'
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
'\t\t\t</Type>\n'
'\t\t\t<MainAttribute>true</MainAttribute>\n'
f'{tail_lines}'
'\t\t</Attribute>\n'
'\t</Attributes>\n'
)
# Произвольная форма (main_attr=None) — без блока Attributes вовсе. В типовых это самая
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
form_xml = (
f'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<Form {form_ns_decl} version="{format_version}">\n'
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
'\t\t<Autofill>true</Autofill>\n'
'\t</AutoCommandBar>\n'
'\t<ChildItems/>\n'
f'{attributes_block}'
'</Form>'
)
if os.path.exists(form_xml_path):
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
else:
write_xml_file(form_xml_path, form_xml)
# --- 3c. Module.bsl ---
module_path = os.path.join(form_module_dir, "Module.bsl")
module_bsl = (
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u042d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041a\u043e\u043c\u0430\u043d\u0434\u0424\u043e\u0440\u043c\u044b\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0439\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
'\n'
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u0421\u043b\u0443\u0436\u0435\u0431\u043d\u044b\u0435\u041f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\u0418\u0424\u0443\u043d\u043a\u0446\u0438\u0438\n'
'\n'
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438'
)
if os.path.exists(module_path):
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
else:
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
# неканоничен (1235 модулей с ним, 766 без).
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
# --- Phase 4: Register in parent object ---
ns = "http://v8.1c.ru/8.3/MDClasses"
child_objects = root.find(f".//md:{object_type}/md:ChildObjects", NSMAP)
if child_objects is None:
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
sys.exit(1)
# Add <Form>$FormName</Form> — idempotent (do not duplicate already-registered form)
already_registered = child_objects.find(f"md:Form[.='{form_name}']", NSMAP) is not None
if not already_registered:
form_elem = etree.Element(f"{{{ns}}}Form")
form_elem.text = form_name
# Find first <Template> to insert before it
first_template = child_objects.find("md:Template", NSMAP)
# Find first <TabularSection> to insert before it (if no Template)
first_tabular = child_objects.find("md:TabularSection", NSMAP)
# Determine insertion point: before Template, before TabularSection, or at end
insert_before = None
if first_template is not None:
insert_before = first_template
elif first_tabular is not None:
insert_before = first_tabular
if insert_before is not None:
# Insert before the found element
idx = list(child_objects).index(insert_before)
child_objects.insert(idx, form_elem)
# Whitespace: form_elem gets "\n\t\t\t" as tail (indent before insert_before)
form_elem.tail = "\n\t\t\t"
else:
# Add to end of ChildObjects
children = list(child_objects)
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
# Empty ChildObjects (self-closing)
child_objects.text = "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = "\n\t\t"
else:
if len(children) > 0:
last_child = children[-1]
old_tail = last_child.tail
last_child.tail = "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = old_tail if old_tail else "\n\t\t"
else:
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
child_objects.append(form_elem)
form_elem.tail = "\n\t\t"
# --- SetDefault ---
is_first_form_for_purpose = False
default_value = f"{object_type}.{object_name}.Form.{form_name}"
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному purpose без
# учёта вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не
# находился, навык молча ничего не делал.
default_prop_name = purpose_rule.get("slot")
default_node = None
if default_prop_name:
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
if default_node is not None:
is_first_form_for_purpose = not (default_node.text or "").strip()
default_updated = False
if set_default or is_first_form_for_purpose:
if default_node is not None:
default_node.text = default_value
default_updated = True
# Save with BOM
save_xml_with_bom(tree, object_xml_full)
# --- Phase 5: Output ---
obj_dir_name = os.path.dirname(object_path)
obj_base_name = os.path.splitext(os.path.basename(object_path))[0]
print("Created:")
print(f" Metadata: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}.xml")
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
print()
if already_registered:
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
else:
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
if default_updated:
print(f"{default_prop_name}: {default_value}")
elif not default_prop_name:
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
# у платформы нет (форма набора записей, произвольная форма).
print(f"Основной не назначена: у {object_type} нет свойства для формы с назначением {purpose}")
print()
if __name__ == "__main__":
main()
@@ -29,10 +29,10 @@ allowed-tools:
```powershell ```powershell
# Режим JSON DSL # Режим JSON DSL
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -JsonPath "<json>" -OutputPath "<Form.xml>" powershell.exe -NoProfile -File ".agents/skills/form-compile/scripts/form-compile.ps1" -JsonPath "<json>" -OutputPath "<Form.xml>"
# Режим from-object (объект и purpose выводятся из OutputPath; Document и Catalog) # Режим from-object (объект и purpose выводятся из OutputPath; Document и Catalog)
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -FromObject -OutputPath "<.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml>" powershell.exe -NoProfile -File ".agents/skills/form-compile/scripts/form-compile.ps1" -FromObject -OutputPath "<.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml>"
``` ```
## JSON DSL — справка ## JSON DSL — справка

Some files were not shown because too many files have changed in this diff Show More