mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-03 16:50:52 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8feccbad71 |
@@ -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.` добавляется автоматически).
|
||||||
+372
-35
@@ -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 ---
|
||||||
+374
-57
@@ -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 (убирает от \r в tail'ах)
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", 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 "<путь>"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Три режима
|
## Три режима
|
||||||
+6
-5
@@ -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",
|
||||||
+31
-9
@@ -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",
|
||||||
@@ -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 — валидировать
|
||||||
|
```
|
||||||
+106
-20
@@ -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('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[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"
|
||||||
@@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
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"
|
||||||
```
|
```
|
||||||
+26
-7
@@ -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
|
||||||
+50
-8
@@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||||
|
|
||||||
+810
-130
File diff suppressed because it is too large
Load Diff
+814
-120
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
```
|
```
|
||||||
+7
-3
@@ -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 ---
|
||||||
+31
-2
@@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
+59
-18
@@ -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('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[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 ---
|
||||||
+103
-22
@@ -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('&','&').replace('<','<').replace('>','>').replace('"','"')
|
# регистр не различают, в 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('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
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}")
|
||||||
+10
-10
@@ -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>
|
||||||
```
|
```
|
||||||
+128
-2
@@ -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+', ' '))"
|
||||||
+170
-4
@@ -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}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
+377
-10
@@ -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
|
||||||
+385
-7
@@ -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 "Новая база"
|
||||||
```
|
```
|
||||||
+2
-2
@@ -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,
|
||||||
+54
-18
@@ -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 "МоёРасширение"
|
||||||
```
|
```
|
||||||
+2
-2
@@ -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,
|
||||||
+57
-19
@@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Связанные навыки
|
## Связанные навыки
|
||||||
+2
-2
@@ -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,
|
||||||
+56
-18
@@ -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 "МоёРасширение"
|
||||||
```
|
```
|
||||||
+135
-7
@@ -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
|
||||||
|
|
||||||
+182
-25
@@ -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 "МоёРасширение"
|
||||||
```
|
```
|
||||||
+2
-2
@@ -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,
|
||||||
+56
-18
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Связанные навыки
|
## Связанные навыки
|
||||||
+2
-2
@@ -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,
|
||||||
+55
-17
@@ -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"
|
||||||
```
|
```
|
||||||
+180
-4
@@ -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
|
||||||
|
|
||||||
+232
-27
@@ -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
|
||||||
```
|
```
|
||||||
+183
-32
@@ -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 }
|
||||||
}
|
}
|
||||||
+235
-53
@@ -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
|
||||||
|
|
||||||
@@ -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 "МоёРасширение"
|
||||||
```
|
```
|
||||||
+154
-4
@@ -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 {
|
||||||
+202
-19
@@ -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"
|
||||||
```
|
```
|
||||||
+2
-2
@@ -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,
|
||||||
+57
-19
@@ -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:
|
||||||
+42
-4
@@ -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
|
||||||
+70
-7
@@ -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"
|
||||||
```
|
```
|
||||||
+2
-2
@@ -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,
|
||||||
+58
-20
@@ -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:
|
||||||
@@ -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`
|
||||||
@@ -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('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
|
[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"
|
||||||
@@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
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"
|
||||||
```
|
```
|
||||||
|
|
||||||
+23
-5
@@ -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
|
||||||
+48
-5
@@ -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"
|
||||||
```
|
```
|
||||||
@@ -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`
|
||||||
@@ -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('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
|
[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"
|
||||||
|
}
|
||||||
@@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
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"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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 ""
|
||||||
@@ -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 (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", 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
Reference in New Issue
Block a user