mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-05 09:40:52 +03:00
Compare commits
288
Commits
w-2026-08-02
..
main
@@ -34,6 +34,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-edit.ps1" -Confi
|
|||||||
| `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство |
|
| `modify-property` | `Ключ=Значение` (batch `;;`) | Изменить свойство |
|
||||||
| `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически |
|
| `add-childObject` | `Type.Name` (batch `;;`) | Зарегистрировать уже существующий файл объекта в ChildObjects. Для создания нового объекта используй `/meta-compile`, `/role-compile`, `/subsystem-compile` — они регистрируют автоматически |
|
||||||
| `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects |
|
| `remove-childObject` | `Type.Name` (batch `;;`) | Удалить объект из ChildObjects |
|
||||||
|
| `sort-childObjects` | вид, напр. `Catalog` (batch `;;`), либо пусто | Упорядочить ChildObjects по имени внутри вида. Без значения — все виды, кроме четырёх (см. reference) |
|
||||||
| `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию |
|
| `add-defaultRole` | `Role.Name` или `Name` | Добавить роль по умолчанию |
|
||||||
| `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию |
|
| `remove-defaultRole` | `Role.Name` или `Name` | Удалить роль по умолчанию |
|
||||||
| `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию |
|
| `set-defaultRoles` | Имена через `;;` | Заменить список ролей по умолчанию |
|
||||||
|
|||||||
@@ -39,6 +39,20 @@
|
|||||||
|
|
||||||
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
|
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
|
||||||
|
|
||||||
|
## sort-childObjects
|
||||||
|
|
||||||
|
Упорядочивает объекты в `<ChildObjects>` по имени **внутри вида**. Значение — имя вида (`Catalog`, `Role`, …), batch через `;;`. Без значения обрабатываются все виды, какие есть в файле.
|
||||||
|
|
||||||
|
```
|
||||||
|
-Operation sort-childObjects — все виды, кроме перечисленных ниже
|
||||||
|
-Operation sort-childObjects -Value "Catalog" — только справочники
|
||||||
|
-Operation sort-childObjects -Value "Catalog ;; Role"
|
||||||
|
```
|
||||||
|
|
||||||
|
Не сортируются, пока вид не назван явно: `CommonAttribute`, `Subsystem`, `CommandGroup`, `Language`.
|
||||||
|
|
||||||
|
Вызов без значения дополнительно ставит группы видов в канонический порядок; вызов с явным видом трогает только имена внутри него.
|
||||||
|
|
||||||
## add-defaultRole / remove-defaultRole / set-defaultRoles
|
## add-defaultRole / remove-defaultRole / set-defaultRoles
|
||||||
|
|
||||||
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
|
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
|
||||||
|
|||||||
@@ -1,15 +1,80 @@
|
|||||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
# cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||||
[string]$DefinitionFile,
|
[string]$DefinitionFile,
|
||||||
[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page")]
|
[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page","sort-childObjects")]
|
||||||
[string]$Operation,
|
[string]$Operation,
|
||||||
[string]$Value,
|
[string]$Value,
|
||||||
[switch]$NoValidate
|
[switch]$NoValidate
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Разбор пользовательского JSON ---
|
||||||
|
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||||
|
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||||
|
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||||
|
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||||
|
# Возврат через -NoEnumerate: без него одноэлементный
|
||||||
|
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||||
|
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||||
|
try {
|
||||||
|
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||||
|
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||||
|
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||||
|
$parsed = $text | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||||
|
if ($Inline) {
|
||||||
|
$got = ($text -replace '\s+', ' ').Trim()
|
||||||
|
$label = 'got'
|
||||||
|
if (-not $got) { $got = '(empty)' }
|
||||||
|
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||||
|
$what = "${what}, ${label}: ${got}"
|
||||||
|
}
|
||||||
|
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Output -NoEnumerate $parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Чтение входного JSON-файла ---
|
||||||
|
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||||
|
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||||
|
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||||
|
# угаданное имя уйдёт в метаданные так же молча.
|
||||||
|
function Read-JsonInputFile([string]$path) {
|
||||||
|
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||||
|
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||||
|
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) {
|
||||||
|
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||||
|
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||||
|
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||||
|
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||||
|
}
|
||||||
|
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||||
|
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||||
|
}
|
||||||
|
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||||
|
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||||
|
} catch {
|
||||||
|
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||||
|
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
# --- Mode validation ---
|
# --- Mode validation ---
|
||||||
@@ -163,6 +228,8 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
Assert-EditAllowed $resolvedPath 'editable'
|
Assert-EditAllowed $resolvedPath 'editable'
|
||||||
|
|
||||||
# --- Load XML with PreserveWhitespace ---
|
# --- Load XML with PreserveWhitespace ---
|
||||||
|
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
|
||||||
|
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
|
||||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||||
$script:xmlDoc.PreserveWhitespace = $true
|
$script:xmlDoc.PreserveWhitespace = $true
|
||||||
$script:xmlDoc.Load($resolvedPath)
|
$script:xmlDoc.Load($resolvedPath)
|
||||||
@@ -210,14 +277,14 @@ foreach ($child in $script:propsEl.ChildNodes) {
|
|||||||
}
|
}
|
||||||
Info "Configuration: $($script:objName)"
|
Info "Configuration: $($script:objName)"
|
||||||
|
|
||||||
# --- Canonical type order for ChildObjects (44 types) ---
|
# --- Canonical type order for ChildObjects (46 types) ---
|
||||||
$script:typeOrder = @(
|
$script:typeOrder = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
|
||||||
"Constant","CommonForm","Catalog","Document",
|
"Constant","CommonForm","Catalog","Document",
|
||||||
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
||||||
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
||||||
@@ -230,7 +297,7 @@ $script:typeOrder = @(
|
|||||||
$script:typeToDir = @{
|
$script:typeToDir = @{
|
||||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
||||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
||||||
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
||||||
@@ -309,6 +376,21 @@ function Import-Fragment([string]$xmlString) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Parse batch value (split by ;;) ---
|
# --- Parse batch value (split by ;;) ---
|
||||||
|
|
||||||
|
# Имя вида из пользовательского ввода → каноническое имя или $null.
|
||||||
|
# Ввод прощающий: регистр не важен, принимается имя каталога выгрузки (Catalogs → Catalog)
|
||||||
|
# и русское имя вида в единственном и множественном числе.
|
||||||
|
function Resolve-TypeName([string]$token) {
|
||||||
|
$key = "$token".Trim()
|
||||||
|
if (-not $key) { return $null }
|
||||||
|
foreach ($canon in $script:typeOrder) { if ($canon -eq $key) { return $canon } }
|
||||||
|
$byDir = $script:dirToType[$key.ToLowerInvariant()]
|
||||||
|
if ($byDir) { return $byDir }
|
||||||
|
$ru = $script:ruTypeMap[$key.ToLowerInvariant()]
|
||||||
|
if ($ru) { return $ru }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
function Parse-BatchValue([string]$val) {
|
function Parse-BatchValue([string]$val) {
|
||||||
$items = @()
|
$items = @()
|
||||||
foreach ($part in $val.Split(";;")) {
|
foreach ($part in $val.Split(";;")) {
|
||||||
@@ -374,6 +456,220 @@ function Do-ModifyProperty([string]$batchVal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Operation: add-childObject ---
|
# --- Operation: add-childObject ---
|
||||||
|
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||||
|
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
|
||||||
|
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
|
||||||
|
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
|
||||||
|
# Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
|
||||||
|
# его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
|
||||||
|
# остаётся рабочим каталогом проекта.
|
||||||
|
# configSrc считается от каталога .v8-project.json, как задокументировано в
|
||||||
|
# docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
function Get-NewObjectPosition([string]$cfgDir) {
|
||||||
|
try {
|
||||||
|
if (-not $cfgDir) { $cfgDir = "." }
|
||||||
|
$pj = Find-V8Project (Get-Location).Path
|
||||||
|
if (-not $pj) { $pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir)) }
|
||||||
|
if (-not $pj) { return "end" }
|
||||||
|
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||||
|
$projDir = [System.IO.Path]::GetDirectoryName($pj)
|
||||||
|
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||||
|
if ($proj.databases) {
|
||||||
|
foreach ($db in $proj.databases) {
|
||||||
|
if ($db.configSrc -and $db.newObjectPosition) {
|
||||||
|
$src = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($projDir, $db.configSrc)).TrimEnd('\', '/')
|
||||||
|
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||||
|
if ("$($db.newObjectPosition)" -eq "byName") { return "byName" }
|
||||||
|
return "end"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("$($proj.newObjectPosition)" -eq "byName") { return "byName" }
|
||||||
|
return "end"
|
||||||
|
} catch { return "end" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
|
||||||
|
# CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
|
||||||
|
# порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
|
||||||
|
# пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
|
||||||
|
# порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
|
||||||
|
# (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
|
||||||
|
# без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
|
||||||
|
# Явно названный вид сортируется в любом случае.
|
||||||
|
# Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
function Test-OrderSensitiveType([string]$typeName) {
|
||||||
|
return @("CommonAttribute", "Subsystem", "CommandGroup", "Language") -ccontains $typeName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Порядок имён объектов метаданных, как в дереве Конфигуратора.
|
||||||
|
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
|
||||||
|
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
|
||||||
|
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
|
||||||
|
# одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
|
||||||
|
# Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
function Compare-MetadataNames([string]$a, [string]$b) {
|
||||||
|
$keys = @("", "")
|
||||||
|
$names = @($a, $b)
|
||||||
|
for ($i = 0; $i -lt 2; $i++) {
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
foreach ($ch in $names[$i].ToLowerInvariant().ToCharArray()) {
|
||||||
|
if ($ch -eq [char]0x0451) { $ch = [char]0x0435 }
|
||||||
|
if ([char]::IsDigit($ch)) { [void]$sb.Append('1') }
|
||||||
|
elseif ([char]::IsLetter($ch)) { [void]$sb.Append('2') }
|
||||||
|
else { [void]$sb.Append('0') }
|
||||||
|
[void]$sb.Append($ch)
|
||||||
|
}
|
||||||
|
$keys[$i] = $sb.ToString()
|
||||||
|
}
|
||||||
|
$r = [string]::CompareOrdinal($keys[0], $keys[1])
|
||||||
|
if ($r -eq 0) { $r = [string]::CompareOrdinal($a, $b) }
|
||||||
|
if ($r -lt 0) { return -1 }
|
||||||
|
if ($r -gt 0) { return 1 }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сортировка имён компаратором Compare-MetadataNames. В py-порту ту же роль играет
|
||||||
|
# functools.cmp_to_key — штатный способ отсортировать компаратором; в PS 5.1 его нет,
|
||||||
|
# поэтому слияние вручную. Порядок обоих портов задаёт один и тот же компаратор.
|
||||||
|
function Sort-MetadataNames([string[]]$names) {
|
||||||
|
# Возврат без запятой-обёртки: приёмная сторона всегда пишет @(...), и одноэлементный
|
||||||
|
# результат остаётся массивом. С `return ,@(...)` @() собрал бы ОДИН объект-массив.
|
||||||
|
if ($names.Count -le 1) { return $names }
|
||||||
|
$mid = [int]($names.Count / 2)
|
||||||
|
$left = @(Sort-MetadataNames $names[0..($mid - 1)])
|
||||||
|
$right = @(Sort-MetadataNames $names[$mid..($names.Count - 1)])
|
||||||
|
$out = New-Object System.Collections.ArrayList
|
||||||
|
$i = 0; $j = 0
|
||||||
|
while ($i -lt $left.Count -and $j -lt $right.Count) {
|
||||||
|
if ((Compare-MetadataNames $left[$i] $right[$j]) -le 0) { [void]$out.Add($left[$i]); $i++ }
|
||||||
|
else { [void]$out.Add($right[$j]); $j++ }
|
||||||
|
}
|
||||||
|
while ($i -lt $left.Count) { [void]$out.Add($left[$i]); $i++ }
|
||||||
|
while ($j -lt $right.Count) { [void]$out.Add($right[$j]); $j++ }
|
||||||
|
return $out.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.
|
||||||
|
# Виды из Test-OrderSensitiveType по имени не сортируются, пока не названы явно.
|
||||||
|
# Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
|
||||||
|
# починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
|
||||||
|
# ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы и структура файла
|
||||||
|
# остаются как были, в дифе только перестановка строк.
|
||||||
|
function Do-SortChildObjects([string]$batchVal) {
|
||||||
|
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
|
||||||
|
|
||||||
|
# Ввод прощающий: регистр не важен, принимается и имя каталога (Catalogs → Catalog) —
|
||||||
|
# в дереве выгрузки виды видны именно во множественном числе.
|
||||||
|
# Без @(...) на приёме: Parse-BatchValue возвращает ,$items — обёртка, которую @()
|
||||||
|
# собрал бы как ОДИН объект-массив, и вид не нашёлся бы в $script:typeOrder.
|
||||||
|
$tokens = @()
|
||||||
|
if ("$batchVal".Trim()) { $tokens = Parse-BatchValue $batchVal }
|
||||||
|
$requested = @()
|
||||||
|
foreach ($token in $tokens) {
|
||||||
|
$canon = Resolve-TypeName $token
|
||||||
|
if (-not $canon) { Write-Error "Unknown type '$token'. Valid: $($script:typeOrder -join ', ')"; exit 1 }
|
||||||
|
$requested += $canon
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = New-Object System.Collections.Specialized.OrderedDictionary
|
||||||
|
foreach ($child in $script:childObjsEl.ChildNodes) {
|
||||||
|
if ($child.NodeType -ne 'Element') { continue }
|
||||||
|
$ln = $child.get_LocalName()
|
||||||
|
if (-not $groups.Contains($ln)) { $groups[$ln] = New-Object System.Collections.ArrayList }
|
||||||
|
[void]$groups[$ln].Add($child)
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = if ($requested.Count -gt 0) { $requested } else { @($groups.Keys | Where-Object { -not (Test-OrderSensitiveType $_) }) }
|
||||||
|
foreach ($typeName in $targets) {
|
||||||
|
if (-not $groups.Contains($typeName)) { continue }
|
||||||
|
$els = $groups[$typeName]
|
||||||
|
if ($els.Count -lt 2) { continue }
|
||||||
|
$names = @(foreach ($e in $els) { $e.InnerText })
|
||||||
|
$ordered = @(Sort-MetadataNames $names)
|
||||||
|
$same = $true
|
||||||
|
for ($i = 0; $i -lt $names.Count; $i++) { if ($names[$i] -cne $ordered[$i]) { $same = $false; break } }
|
||||||
|
if ($same) { continue }
|
||||||
|
for ($i = 0; $i -lt $els.Count; $i++) { $els[$i].InnerText = $ordered[$i] }
|
||||||
|
$script:modifyCount++
|
||||||
|
Info "Sorted: $typeName ($($els.Count))"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requested.Count -gt 0) { return }
|
||||||
|
|
||||||
|
# Без аргумента приводим в порядок и сами группы видов: собранная навыками конфигурация
|
||||||
|
# может держать их не в каноне, и первая же выгрузка платформы даст диф. Переставляем
|
||||||
|
# содержимое существующих узлов, а не узлы, поэтому отступы и структура файла не меняются —
|
||||||
|
# в дифе только перестановка строк. Имя тега у XmlElement неизменяемо, поэтому там, где вид
|
||||||
|
# меняется, узел заменяется через ReplaceChild: он сохраняет окружающие пробельные узлы.
|
||||||
|
$elems = @()
|
||||||
|
foreach ($child in $script:childObjsEl.ChildNodes) {
|
||||||
|
if ($child.NodeType -eq 'Element') { $elems += $child }
|
||||||
|
}
|
||||||
|
$tags = @(); $texts = @()
|
||||||
|
foreach ($e in $elems) { $tags += $e.get_LocalName(); $texts += $e.InnerText }
|
||||||
|
$rank = @()
|
||||||
|
for ($i = 0; $i -lt $tags.Count; $i++) {
|
||||||
|
$r = $script:typeOrder.IndexOf($tags[$i])
|
||||||
|
if ($r -lt 0) { $r = $script:typeOrder.Count }
|
||||||
|
$rank += $r
|
||||||
|
}
|
||||||
|
# Порядок стабильный: вторым ключом идёт исходная позиция
|
||||||
|
$order = @(0..($tags.Count - 1) | Sort-Object @{e={$rank[$_]}}, @{e={$_}})
|
||||||
|
$same = $true
|
||||||
|
for ($i = 0; $i -lt $order.Count; $i++) { if ($order[$i] -ne $i) { $same = $false; break } }
|
||||||
|
if ($same) { return }
|
||||||
|
|
||||||
|
for ($i = 0; $i -lt $elems.Count; $i++) {
|
||||||
|
$srcIdx = $order[$i]
|
||||||
|
if ($tags[$i] -ceq $tags[$srcIdx]) {
|
||||||
|
$elems[$i].InnerText = $texts[$srcIdx]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$newEl = $script:xmlDoc.CreateElement($tags[$srcIdx], $script:mdNs)
|
||||||
|
$newEl.InnerText = $texts[$srcIdx]
|
||||||
|
[void]$script:childObjsEl.ReplaceChild($newEl, $elems[$i])
|
||||||
|
}
|
||||||
|
$script:modifyCount++
|
||||||
|
Info "Reordered type groups: $($elems.Count) entries"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
|
# финальный перенос. $null → файл новый (сохранить текущее поведение).
|
||||||
|
# Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
function Detect-XmlStyle([string]$path) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
||||||
|
$raw = [System.IO.File]::ReadAllBytes($path)
|
||||||
|
$bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
|
||||||
|
$body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) }
|
||||||
|
$head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body }
|
||||||
|
$m = [regex]::Match($head, 'encoding="([^"]+)"')
|
||||||
|
return @{
|
||||||
|
bom = $bom
|
||||||
|
crlf = $body.Contains("`r`n")
|
||||||
|
enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" })
|
||||||
|
finalNl = $body.EndsWith("`n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки
|
||||||
|
# Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце.
|
||||||
|
# Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
function Finalize-XmlText([string]$text, $style) {
|
||||||
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
|
$encDecl = $(if ($style) { $style.enc } else { "UTF-8" })
|
||||||
|
$text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
$text = ($text -replace "`r`n", "`n").TrimEnd("`n")
|
||||||
|
if ($style -and $style.finalNl) { $text += "`n" }
|
||||||
|
if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" }
|
||||||
|
return $text
|
||||||
|
}
|
||||||
|
|
||||||
function Do-AddChildObject([string]$batchVal) {
|
function Do-AddChildObject([string]$batchVal) {
|
||||||
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
|
if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }
|
||||||
|
|
||||||
@@ -393,6 +689,8 @@ function Do-AddChildObject([string]$batchVal) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
$typeName = $item.Substring(0, $dotIdx)
|
$typeName = $item.Substring(0, $dotIdx)
|
||||||
|
$canonType = Resolve-TypeName $typeName
|
||||||
|
if ($canonType) { $typeName = $canonType }
|
||||||
$objNameVal = $item.Substring($dotIdx + 1)
|
$objNameVal = $item.Substring($dotIdx + 1)
|
||||||
|
|
||||||
# Check type is valid
|
# Check type is valid
|
||||||
@@ -437,11 +735,11 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find insertion point: after last element of same type, or after last element of preceding type
|
# Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
|
||||||
|
$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $script:configDir) -eq "byName")
|
||||||
$insertBefore = $null
|
$insertBefore = $null
|
||||||
$lastSameType = $null
|
$lastSameType = $null
|
||||||
$lastPrecedingType = $null
|
$firstLaterType = $null
|
||||||
$currentTypeIdx = -1
|
|
||||||
|
|
||||||
foreach ($child in $script:childObjsEl.ChildNodes) {
|
foreach ($child in $script:childObjsEl.ChildNodes) {
|
||||||
if ($child.NodeType -ne 'Element') { continue }
|
if ($child.NodeType -ne 'Element') { continue }
|
||||||
@@ -449,17 +747,29 @@ To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
|
|||||||
if ($childTypeIdx -lt 0) { continue }
|
if ($childTypeIdx -lt 0) { continue }
|
||||||
|
|
||||||
if ($child.LocalName -eq $typeName) {
|
if ($child.LocalName -eq $typeName) {
|
||||||
# Same type — check alphabetical order
|
# Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
|
||||||
if ($child.InnerText -gt $objNameVal -and -not $insertBefore) {
|
# объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
|
||||||
# Insert before this element (alphabetical)
|
# никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
|
||||||
|
$lastSameType = $child
|
||||||
|
if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
|
||||||
$insertBefore = $child
|
$insertBefore = $child
|
||||||
}
|
}
|
||||||
$lastSameType = $child
|
} elseif ($childTypeIdx -gt $typeIdx -and -not $firstLaterType) {
|
||||||
} elseif ($childTypeIdx -lt $typeIdx) {
|
$firstLaterType = $child
|
||||||
$lastPrecedingType = $child
|
}
|
||||||
} elseif ($childTypeIdx -gt $typeIdx -and -not $insertBefore) {
|
}
|
||||||
# First element of a later type — insert before it
|
|
||||||
$insertBefore = $child
|
if (-not $insertBefore) {
|
||||||
|
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
|
||||||
|
# то есть перед его следующим соседом. Через $firstLaterType этого не сделать:
|
||||||
|
# если видов старше в файле нет, запись уехала бы в самый конец блока,
|
||||||
|
# за пределы своей группы.
|
||||||
|
if ($lastSameType) {
|
||||||
|
$next = $lastSameType.NextSibling
|
||||||
|
while ($next -and $next.NodeType -ne 'Element') { $next = $next.NextSibling }
|
||||||
|
$insertBefore = $next
|
||||||
|
} else {
|
||||||
|
$insertBefore = $firstLaterType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +801,8 @@ function Do-RemoveChildObject([string]$batchVal) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
$typeName = $item.Substring(0, $dotIdx)
|
$typeName = $item.Substring(0, $dotIdx)
|
||||||
|
$canonType = Resolve-TypeName $typeName
|
||||||
|
if ($canonType) { $typeName = $canonType }
|
||||||
$objNameVal = $item.Substring($dotIdx + 1)
|
$objNameVal = $item.Substring($dotIdx + 1)
|
||||||
|
|
||||||
$found = $false
|
$found = $false
|
||||||
@@ -637,10 +949,7 @@ function Do-SetPanels($valArg) {
|
|||||||
# Accept string (JSON), PSCustomObject, or hashtable
|
# Accept string (JSON), PSCustomObject, or hashtable
|
||||||
$layout = $valArg
|
$layout = $valArg
|
||||||
if ($layout -is [string]) {
|
if ($layout -is [string]) {
|
||||||
try { $layout = $layout | ConvertFrom-Json } catch {
|
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
|
||||||
Write-Error "set-panels value must be valid JSON object, got: $valArg"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (-not $layout) {
|
if (-not $layout) {
|
||||||
Write-Error "set-panels value is empty"
|
Write-Error "set-panels value is empty"
|
||||||
@@ -691,7 +1000,9 @@ $bodyBlock$declarations
|
|||||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||||
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom)
|
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||||
|
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
Info "Wrote panel layout: $caiPath"
|
Info "Wrote panel layout: $caiPath"
|
||||||
}
|
}
|
||||||
@@ -721,6 +1032,29 @@ $script:ruTypeMap = @{
|
|||||||
"бот" = "Bot"
|
"бот" = "Bot"
|
||||||
"планобмена" = "ExchangePlan"
|
"планобмена" = "ExchangePlan"
|
||||||
"хранилищенастроек" = "SettingsStorage"
|
"хранилищенастроек" = "SettingsStorage"
|
||||||
|
# Множественное число: в дереве конфигурации виды подписаны именно так.
|
||||||
|
"справочники" = "Catalog"
|
||||||
|
"документы" = "Document"
|
||||||
|
"перечисления" = "Enum"
|
||||||
|
"отчёты" = "Report"
|
||||||
|
"отчеты" = "Report"
|
||||||
|
"обработки" = "DataProcessor"
|
||||||
|
"общиеформы" = "CommonForm"
|
||||||
|
"журналыдокументов" = "DocumentJournal"
|
||||||
|
"планывидовхарактеристик" = "ChartOfCharacteristicTypes"
|
||||||
|
"планысчетов" = "ChartOfAccounts"
|
||||||
|
"планывидоврасчета" = "ChartOfCalculationTypes"
|
||||||
|
"планывидоврасчёта" = "ChartOfCalculationTypes"
|
||||||
|
"регистрысведений" = "InformationRegister"
|
||||||
|
"регистрынакопления" = "AccumulationRegister"
|
||||||
|
"регистрыбухгалтерии" = "AccountingRegister"
|
||||||
|
"регистрырасчета" = "CalculationRegister"
|
||||||
|
"регистрырасчёта" = "CalculationRegister"
|
||||||
|
"бизнеспроцессы" = "BusinessProcess"
|
||||||
|
"задачи" = "Task"
|
||||||
|
"боты" = "Bot"
|
||||||
|
"планыобмена" = "ExchangePlan"
|
||||||
|
"хранилищанастроек" = "SettingsStorage"
|
||||||
}
|
}
|
||||||
# plural folder → singular type
|
# plural folder → singular type
|
||||||
$script:dirToType = @{}
|
$script:dirToType = @{}
|
||||||
@@ -822,9 +1156,7 @@ $indent</Item>
|
|||||||
function Do-SetHomePage($valArg) {
|
function Do-SetHomePage($valArg) {
|
||||||
$layout = $valArg
|
$layout = $valArg
|
||||||
if ($layout -is [string]) {
|
if ($layout -is [string]) {
|
||||||
try { $layout = $layout | ConvertFrom-Json } catch {
|
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
|
||||||
Write-Error "set-home-page value must be valid JSON object"; exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
|
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
|
||||||
|
|
||||||
@@ -880,7 +1212,9 @@ $rightXml
|
|||||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||||
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom)
|
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||||
|
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
Info "Wrote home page layout: $hpPath"
|
Info "Wrote home page layout: $hpPath"
|
||||||
}
|
}
|
||||||
@@ -936,8 +1270,8 @@ if ($DefinitionFile) {
|
|||||||
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
|
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
|
||||||
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
|
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
|
||||||
}
|
}
|
||||||
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile
|
$jsonText = Read-JsonInputFile $DefinitionFile
|
||||||
$ops = $jsonText | ConvertFrom-Json
|
$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
|
||||||
if ($ops -is [System.Array]) {
|
if ($ops -is [System.Array]) {
|
||||||
foreach ($op in $ops) { $operations += $op }
|
foreach ($op in $ops) { $operations += $op }
|
||||||
} else {
|
} else {
|
||||||
@@ -962,11 +1296,16 @@ foreach ($op in $operations) {
|
|||||||
"set-defaultRoles" { Do-SetDefaultRoles $opValueStr }
|
"set-defaultRoles" { Do-SetDefaultRoles $opValueStr }
|
||||||
"set-panels" { Do-SetPanels $opValue }
|
"set-panels" { Do-SetPanels $opValue }
|
||||||
"set-home-page" { Do-SetHomePage $opValue }
|
"set-home-page" { Do-SetHomePage $opValue }
|
||||||
|
"sort-childObjects" { Do-SortChildObjects $opValueStr }
|
||||||
default { Write-Error "Unknown operation: $opName"; exit 1 }
|
default { Write-Error "Unknown operation: $opName"; exit 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Save ---
|
# --- Save ---
|
||||||
|
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
|
||||||
|
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$xmlStyle = Detect-XmlStyle $resolvedPath
|
||||||
|
|
||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
@@ -977,14 +1316,12 @@ $writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
|||||||
$script:xmlDoc.Save($writer)
|
$script:xmlDoc.Save($writer)
|
||||||
$writer.Flush(); $writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
|
|
||||||
$bytes = $memStream.ToArray()
|
$text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||||
$memStream.Close()
|
$memStream.Close()
|
||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
$text = Finalize-XmlText $text $xmlStyle
|
||||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
|
||||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
|
||||||
|
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
|
||||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
[System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
|
||||||
Info "Saved: $resolvedPath"
|
Info "Saved: $resolvedPath"
|
||||||
|
|
||||||
# --- Auto-validate ---
|
# --- Auto-validate ---
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
# cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -12,6 +13,127 @@ import uuid as _uuid
|
|||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
|
||||||
|
def parse_json_input(text, source, expected=None, inline=False):
|
||||||
|
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||||
|
|
||||||
|
expected заполняем только для полиморфного входа: у файла подсказка
|
||||||
|
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||||
|
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||||
|
|
||||||
|
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||||
|
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||||
|
"""
|
||||||
|
import json as _pj
|
||||||
|
import sys as _psys
|
||||||
|
try:
|
||||||
|
if not str(text).strip():
|
||||||
|
raise ValueError("input is empty")
|
||||||
|
return _pj.loads(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||||
|
if inline:
|
||||||
|
got = " ".join(str(text).split())
|
||||||
|
label = "got"
|
||||||
|
if not got:
|
||||||
|
got = "(empty)"
|
||||||
|
elif len(got) > 60:
|
||||||
|
label = "got (first 60 chars)"
|
||||||
|
got = got[:60]
|
||||||
|
what = "%s, %s: %s" % (what, label, got)
|
||||||
|
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||||
|
_psys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(path):
|
||||||
|
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||||
|
|
||||||
|
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||||
|
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||||
|
"""
|
||||||
|
import os as _pos
|
||||||
|
import sys as _psys
|
||||||
|
if not _pos.path.exists(path):
|
||||||
|
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||||
|
_psys.exit(1)
|
||||||
|
if _pos.path.isdir(path):
|
||||||
|
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||||
|
_psys.exit(1)
|
||||||
|
with open(path, "rb") as _fh:
|
||||||
|
data = _fh.read()
|
||||||
|
if data[:3] == b"\xef\xbb\xbf":
|
||||||
|
return data[3:].decode("utf-8")
|
||||||
|
if data[:2] == b"\xff\xfe":
|
||||||
|
return data[2:].decode("utf-16-le")
|
||||||
|
if data[:2] == b"\xfe\xff":
|
||||||
|
return data[2:].decode("utf-16-be")
|
||||||
|
try:
|
||||||
|
return data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||||
|
% (path, exc), file=_psys.stderr)
|
||||||
|
_psys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
class CIDict(dict):
|
||||||
|
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||||
|
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||||
|
# сохраняется — от него зависит порядок эмиссии.
|
||||||
|
def _actual(self, key):
|
||||||
|
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||||
|
return key
|
||||||
|
ci = self.__dict__.get('_ci')
|
||||||
|
if ci is None or len(ci) != len(self):
|
||||||
|
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||||
|
self.__dict__['_ci'] = ci
|
||||||
|
return ci.get(key.lower(), key)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return dict.__getitem__(self, self._actual(key))
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return dict.__contains__(self, self._actual(key))
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, self._actual(key), default)
|
||||||
|
|
||||||
|
def pop(self, key, *default):
|
||||||
|
return dict.pop(self, self._actual(key), *default)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||||
|
dict.__setitem__(self, self._actual(key), value)
|
||||||
|
|
||||||
|
def ci_json(obj):
|
||||||
|
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [ci_json(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
@@ -195,14 +317,14 @@ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|||||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||||
|
|
||||||
# Canonical type order for ChildObjects (44 types)
|
# Canonical type order for ChildObjects (46 types)
|
||||||
TYPE_ORDER = [
|
TYPE_ORDER = [
|
||||||
"Language", "Subsystem", "StyleItem", "Style",
|
"Language", "Subsystem", "StyleItem", "Style",
|
||||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
"FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
|
||||||
"Constant", "CommonForm", "Catalog", "Document",
|
"Constant", "CommonForm", "Catalog", "Document",
|
||||||
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
|
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
|
||||||
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
|
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
|
||||||
@@ -215,7 +337,7 @@ TYPE_ORDER = [
|
|||||||
TYPE_TO_DIR = {
|
TYPE_TO_DIR = {
|
||||||
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
||||||
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
||||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "PaletteColor": "PaletteColors", "PaletteColor": "PaletteColors", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||||
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
||||||
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
||||||
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
||||||
@@ -232,6 +354,137 @@ SCALAR_PROPS = ["Name", "Version", "Vendor", "Comment", "NamePrefix", "UpdateCat
|
|||||||
REF_PROPS = ["DefaultLanguage"]
|
REF_PROPS = ["DefaultLanguage"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_new_object_position(cfg_dir):
|
||||||
|
"""Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||||
|
|
||||||
|
databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
|
||||||
|
иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
|
||||||
|
(так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
|
||||||
|
Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
|
||||||
|
его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
|
||||||
|
остаётся рабочим каталогом проекта.
|
||||||
|
configSrc считается от каталога .v8-project.json, как задокументировано в
|
||||||
|
docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(os.path.abspath(cfg_dir or "."))
|
||||||
|
if not pj:
|
||||||
|
return "end"
|
||||||
|
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||||
|
proj_dir = os.path.dirname(pj)
|
||||||
|
cfg_full = os.path.normcase(os.path.abspath(cfg_dir or ".")).rstrip("\\/")
|
||||||
|
for db in proj.get("databases", []):
|
||||||
|
src = db.get("configSrc")
|
||||||
|
if src and db.get("newObjectPosition"):
|
||||||
|
src_full = os.path.normcase(os.path.abspath(os.path.join(proj_dir, src))).rstrip("\\/")
|
||||||
|
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||||
|
return "byName" if str(db["newObjectPosition"]).lower() == "byname" else "end"
|
||||||
|
if str(proj.get("newObjectPosition") or "").lower() == "byname":
|
||||||
|
return "byName"
|
||||||
|
return "end"
|
||||||
|
except Exception:
|
||||||
|
return "end"
|
||||||
|
|
||||||
|
|
||||||
|
def is_order_sensitive_type(type_name):
|
||||||
|
"""Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
|
||||||
|
|
||||||
|
CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
|
||||||
|
порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
|
||||||
|
пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
|
||||||
|
порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
|
||||||
|
(в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
|
||||||
|
без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
|
||||||
|
Явно названный вид сортируется в любом случае.
|
||||||
|
Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
"""
|
||||||
|
return type_name in ("CommonAttribute", "Subsystem", "CommandGroup", "Language")
|
||||||
|
|
||||||
|
|
||||||
|
def compare_metadata_names(a, b):
|
||||||
|
"""Порядок имён объектов метаданных, как в дереве Конфигуратора.
|
||||||
|
|
||||||
|
Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
|
||||||
|
букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
|
||||||
|
используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
|
||||||
|
одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
|
||||||
|
Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
"""
|
||||||
|
keys = []
|
||||||
|
for name in (a, b):
|
||||||
|
parts = []
|
||||||
|
for ch in name.lower():
|
||||||
|
if ch == "ё":
|
||||||
|
ch = "е"
|
||||||
|
if ch.isdigit():
|
||||||
|
parts.append("1" + ch)
|
||||||
|
elif ch.isalpha():
|
||||||
|
parts.append("2" + ch)
|
||||||
|
else:
|
||||||
|
parts.append("0" + ch)
|
||||||
|
keys.append("".join(parts))
|
||||||
|
if keys[0] != keys[1]:
|
||||||
|
return -1 if keys[0] < keys[1] else 1
|
||||||
|
if a != b:
|
||||||
|
return -1 if a < b else 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
RU_TYPE_MAP = {
|
||||||
|
"справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
|
||||||
|
"отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
|
||||||
|
"общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
|
||||||
|
"планвидовхарактеристик": "ChartOfCharacteristicTypes",
|
||||||
|
"плансчетов": "ChartOfAccounts",
|
||||||
|
"планвидоврасчета": "ChartOfCalculationTypes",
|
||||||
|
"планвидоврасчёта": "ChartOfCalculationTypes",
|
||||||
|
"регистрсведений": "InformationRegister",
|
||||||
|
"регистрнакопления": "AccumulationRegister",
|
||||||
|
"регистрбухгалтерии": "AccountingRegister",
|
||||||
|
"регистррасчета": "CalculationRegister",
|
||||||
|
"регистррасчёта": "CalculationRegister",
|
||||||
|
"бизнеспроцесс": "BusinessProcess",
|
||||||
|
"бот": "Bot",
|
||||||
|
"задача": "Task", "планобмена": "ExchangePlan",
|
||||||
|
"хранилищенастроек": "SettingsStorage",
|
||||||
|
# Множественное число: в дереве конфигурации виды подписаны именно так.
|
||||||
|
"справочники": "Catalog", "документы": "Document", "перечисления": "Enum",
|
||||||
|
"отчёты": "Report", "отчеты": "Report", "обработки": "DataProcessor",
|
||||||
|
"общиеформы": "CommonForm", "журналыдокументов": "DocumentJournal",
|
||||||
|
"планывидовхарактеристик": "ChartOfCharacteristicTypes",
|
||||||
|
"планысчетов": "ChartOfAccounts",
|
||||||
|
"планывидоврасчета": "ChartOfCalculationTypes",
|
||||||
|
"планывидоврасчёта": "ChartOfCalculationTypes",
|
||||||
|
"регистрысведений": "InformationRegister",
|
||||||
|
"регистрынакопления": "AccumulationRegister",
|
||||||
|
"регистрыбухгалтерии": "AccountingRegister",
|
||||||
|
"регистррасчета": "CalculationRegister", "регистрырасчета": "CalculationRegister",
|
||||||
|
"регистрырасчёта": "CalculationRegister",
|
||||||
|
"бизнеспроцессы": "BusinessProcess",
|
||||||
|
"боты": "Bot",
|
||||||
|
"задачи": "Task", "планыобмена": "ExchangePlan",
|
||||||
|
"хранилищанастроек": "SettingsStorage",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_type_name(token):
|
||||||
|
"""Имя вида из пользовательского ввода → каноническое имя или None.
|
||||||
|
|
||||||
|
Ввод прощающий: регистр не важен, принимается имя каталога выгрузки
|
||||||
|
(Catalogs → Catalog) и русское имя вида в единственном и множественном числе.
|
||||||
|
"""
|
||||||
|
key = (token or "").strip().lower()
|
||||||
|
if not key:
|
||||||
|
return None
|
||||||
|
for canon in TYPE_ORDER:
|
||||||
|
if canon.lower() == key:
|
||||||
|
return canon
|
||||||
|
for canon, dir_name in TYPE_TO_DIR.items():
|
||||||
|
if dir_name.lower() == key:
|
||||||
|
return canon
|
||||||
|
return RU_TYPE_MAP.get(key)
|
||||||
|
|
||||||
|
|
||||||
def localname(el):
|
def localname(el):
|
||||||
return etree.QName(el.tag).localname
|
return etree.QName(el.tag).localname
|
||||||
|
|
||||||
@@ -341,21 +594,22 @@ def _detect_xml_style(path):
|
|||||||
|
|
||||||
|
|
||||||
def _finalize_xml_bytes(xml_bytes, style):
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
enc_decl = style["enc"] if style else "utf-8"
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
xml_bytes = xml_bytes.replace(
|
xml_bytes = xml_bytes.replace(
|
||||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
# Канонизировать переносы к LF (убирает от \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
|
||||||
insert_before = child
|
if (by_name and insert_before is None
|
||||||
elif child_type_idx > type_idx 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 first_later is None:
|
||||||
|
first_later = child
|
||||||
|
|
||||||
|
if insert_before is None:
|
||||||
|
# Место не выбрано именем — ставим сразу за последним объектом того же вида,
|
||||||
|
# то есть перед его следующим соседом. Через first_later этого не сделать:
|
||||||
|
# если видов старше в файле нет, запись уехала бы в самый конец блока,
|
||||||
|
# за пределы своей группы.
|
||||||
|
if last_same is not None:
|
||||||
|
siblings = [c for c in child_objs_el if isinstance(c.tag, str)]
|
||||||
|
pos = siblings.index(last_same)
|
||||||
|
insert_before = siblings[pos + 1] if pos + 1 < len(siblings) else None
|
||||||
|
else:
|
||||||
|
insert_before = first_later
|
||||||
|
|
||||||
new_el = etree.Element(f"{{{MD_NS}}}{type_name}")
|
new_el = 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)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
# cf-info v1.8 — Compact summary of 1C configuration root
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
|
||||||
[ValidateSet("overview","brief","full")]
|
[ValidateSet("overview","brief","full")]
|
||||||
[string]$Mode = "overview",
|
[string]$Mode = "overview",
|
||||||
[Alias('Name')]
|
[Alias('Name')]
|
||||||
@@ -85,14 +86,14 @@ function Get-PropML([string]$propName) {
|
|||||||
return (Get-MLText $n)
|
return (Get-MLText $n)
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Type name maps (canonical order, 44 types) ---
|
# --- Type name maps (canonical order, 46 types) ---
|
||||||
$typeOrder = @(
|
$typeOrder = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
|
||||||
"Constant","CommonForm","Catalog","Document",
|
"Constant","CommonForm","Catalog","Document",
|
||||||
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
||||||
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
# cf-info v1.8 — Compact summary of 1C configuration root
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -12,6 +12,28 @@ from lxml import etree
|
|||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- Argument parsing ---
|
# --- Argument parsing ---
|
||||||
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
||||||
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
||||||
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
|
|||||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||||
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
||||||
parser.add_argument("-OutFile", default="", help="Write output to file")
|
parser.add_argument("-OutFile", default="", help="Write output to file")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
# --- Output helper (collect all, paginate at the end) ---
|
# --- Output helper (collect all, paginate at the end) ---
|
||||||
lines_buf = []
|
lines_buf = []
|
||||||
@@ -39,11 +61,11 @@ if os.path.isdir(config_path):
|
|||||||
if os.path.isfile(candidate):
|
if os.path.isfile(candidate):
|
||||||
config_path = candidate
|
config_path = candidate
|
||||||
else:
|
else:
|
||||||
print(f"[ERROR] No Configuration.xml found in directory: {config_path}", file=sys.stderr)
|
print(f"[ERROR] No Configuration.xml found in directory: {config_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if not os.path.isfile(config_path):
|
if not os.path.isfile(config_path):
|
||||||
print(f"[ERROR] File not found: {config_path}", file=sys.stderr)
|
print(f"[ERROR] File not found: {config_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Load XML ---
|
# --- Load XML ---
|
||||||
@@ -60,12 +82,12 @@ NS = {
|
|||||||
|
|
||||||
md_root = xml_root # root is MetaDataObject itself
|
md_root = xml_root # root is MetaDataObject itself
|
||||||
if etree.QName(md_root.tag).localname != "MetaDataObject":
|
if etree.QName(md_root.tag).localname != "MetaDataObject":
|
||||||
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)", file=sys.stderr)
|
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
cfg_node = md_root.find("md:Configuration", NS)
|
cfg_node = md_root.find("md:Configuration", NS)
|
||||||
if cfg_node is None:
|
if cfg_node is None:
|
||||||
print("[ERROR] No <Configuration> element found", file=sys.stderr)
|
print("[ERROR] No <Configuration> element found")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
version = md_root.get("version", "")
|
version = md_root.get("version", "")
|
||||||
@@ -91,14 +113,14 @@ def get_prop_ml(prop_name):
|
|||||||
n = props_node.find(f"md:{prop_name}", NS)
|
n = props_node.find(f"md:{prop_name}", NS)
|
||||||
return get_ml_text(n)
|
return get_ml_text(n)
|
||||||
|
|
||||||
# --- Type name maps (canonical order, 44 types) ---
|
# --- Type name maps (canonical order, 46 types) ---
|
||||||
type_order = [
|
type_order = [
|
||||||
"Language", "Subsystem", "StyleItem", "Style",
|
"Language", "Subsystem", "StyleItem", "Style",
|
||||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
"FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
|
||||||
"Constant", "CommonForm", "Catalog", "Document",
|
"Constant", "CommonForm", "Catalog", "Document",
|
||||||
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
|
"DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
|
||||||
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
|
"Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ allowed-tools:
|
|||||||
| `Version` | Версия конфигурации |
|
| `Version` | Версия конфигурации |
|
||||||
| `Vendor` | Поставщик |
|
| `Vendor` | Поставщик |
|
||||||
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
| `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
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
||||||
@@ -36,8 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name
|
|||||||
# С версией и поставщиком
|
# С версией и поставщиком
|
||||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||||
|
|
||||||
# Другой режим совместимости
|
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||||
... -Name TestCfg -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# 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
|
||||||
"""Generates minimal XML source files for a 1C configuration."""
|
"""Generates minimal XML source files for a 1C configuration."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, argparse, re, uuid
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -24,12 +70,32 @@ def main():
|
|||||||
parser.add_argument('-Version', dest='Version', default='')
|
parser.add_argument('-Version', dest='Version', default='')
|
||||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||||
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости:
|
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
|
||||||
# 8.3.20-8.3.24 пишут 2.17, 8.3.25 — 2.18, 8.3.26 — 2.19, 8.3.27 — 2.20.
|
# Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||||
# Дефолт консервативный: 2.17 читается всеми платформами.
|
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17',
|
args = ci_parse_args(parser)
|
||||||
choices=['2.17', '2.18', '2.19', '2.20', '2.21'])
|
|
||||||
args = parser.parse_args()
|
# Проверенный диапазон: 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
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -54,6 +120,11 @@ def main():
|
|||||||
co = [new_uuid() for _ in range(7)]
|
co = [new_uuid() for _ in range(7)]
|
||||||
|
|
||||||
# --- Mobile functionalities ---
|
# --- Mobile functionalities ---
|
||||||
|
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||||
|
is_221 = format_rank_value >= 221
|
||||||
|
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||||
|
is_218 = format_rank_value >= 218
|
||||||
|
|
||||||
mobile_funcs = [
|
mobile_funcs = [
|
||||||
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
||||||
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
||||||
@@ -70,6 +141,13 @@ def main():
|
|||||||
("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 is_218:
|
||||||
|
mobile_funcs.append(("TextToSpeech", "false"))
|
||||||
|
|
||||||
mobile_xml = ""
|
mobile_xml = ""
|
||||||
for func_name, func_use in mobile_funcs:
|
for func_name, func_use in mobile_funcs:
|
||||||
@@ -78,10 +156,12 @@ 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/>"
|
||||||
|
|
||||||
class_ids = [
|
class_ids = [
|
||||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||||
@@ -93,6 +173,28 @@ def main():
|
|||||||
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
"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 = ""
|
contained_objects = ""
|
||||||
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>
|
||||||
@@ -101,12 +203,12 @@ 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="{args.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"{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<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<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<NamePrefix/>
|
\t\t\t<NamePrefix/>
|
||||||
@@ -117,8 +219,8 @@ def main():
|
|||||||
\t\t\t</UsePurposes>
|
\t\t\t</UsePurposes>
|
||||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||||
\t\t\t<DefaultRoles/>
|
\t\t\t<DefaultRoles/>
|
||||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
\t\t\t{vendor_el}
|
||||||
\t\t\t<Version>{version_xml}</Version>
|
\t\t\t{version_el}
|
||||||
\t\t\t<UpdateCatalogAddress/>
|
\t\t\t<UpdateCatalogAddress/>
|
||||||
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||||
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||||
@@ -140,15 +242,15 @@ def main():
|
|||||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>
|
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
|
||||||
\t\t\t<RequiredMobileApplicationPermissions/>
|
\t\t\t<RequiredMobileApplicationPermissions/>
|
||||||
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
||||||
\t\t\t</UsedMobileApplicationFunctionalities>
|
\t\t\t</UsedMobileApplicationFunctionalities>
|
||||||
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
||||||
\t\t\t<MobileApplicationURLs/>
|
\t\t\t<MobileApplicationURLs/>
|
||||||
\t\t\t<AllowedIncomingShareRequestTypes/>
|
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
|
||||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
|
||||||
\t\t\t<DefaultInterface/>
|
\t\t\t<DefaultInterface/>{f221_captions}
|
||||||
\t\t\t<DefaultStyle/>
|
\t\t\t<DefaultStyle/>
|
||||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
\t\t\t<BriefInformation/>
|
\t\t\t<BriefInformation/>
|
||||||
@@ -160,7 +262,7 @@ def main():
|
|||||||
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||||
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
||||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
|
||||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
||||||
\t\t\t<DefaultConstantsForm/>
|
\t\t\t<DefaultConstantsForm/>
|
||||||
@@ -173,7 +275,7 @@ def main():
|
|||||||
|
|
||||||
# --- Languages/Русский.xml ---
|
# --- Languages/Русский.xml ---
|
||||||
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="{args.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"{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<Language uuid="{uuid_lang}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>Русский</Name>
|
\t\t\t<Name>Русский</Name>
|
||||||
@@ -222,11 +324,11 @@ def main():
|
|||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_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)
|
||||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||||
write_utf8_bom(cai_file, cai_xml)
|
write_xml_file(cai_file, cai_xml)
|
||||||
|
|
||||||
print(f"[OK] Создана конфигурация: {name}")
|
print(f"[OK] Создана конфигурация: {name}")
|
||||||
print(f" Каталог: {output_dir}")
|
print(f" Каталог: {output_dir}")
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# cf-validate v1.5 — Validate 1C configuration root structure
|
# cf-validate v1.9 — Validate 1C configuration root structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory, Position=0)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
[string]$ConfigPath,
|
[string]$ConfigPath,
|
||||||
|
|
||||||
@@ -89,6 +90,19 @@ $finalize = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- Reference tables ---
|
# --- Reference tables ---
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||||
@@ -108,10 +122,10 @@ $validClassIds = @(
|
|||||||
$childObjectTypes = @(
|
$childObjectTypes = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
|
||||||
"Constant","CommonForm","Catalog","Document",
|
"Constant","CommonForm","Catalog","Document",
|
||||||
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
||||||
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
||||||
@@ -126,6 +140,7 @@ $childTypeDirMap = @{
|
|||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||||
"Bot"="Bots"
|
"Bot"="Bots"
|
||||||
|
"PaletteColor"="PaletteColors"
|
||||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
||||||
@@ -203,11 +218,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
} elseif ($versionRank -eq 0) {
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||||
|
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Must have Configuration child
|
# Must have Configuration child
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-validate v1.5 — Validate 1C configuration XML structure
|
# cf-validate v1.9 — Validate 1C configuration XML structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NS = {
|
NS = {
|
||||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||||
@@ -37,10 +59,10 @@ VALID_CLASS_IDS = [
|
|||||||
CHILD_OBJECT_TYPES = [
|
CHILD_OBJECT_TYPES = [
|
||||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
|
||||||
'Constant', 'CommonForm', 'Catalog', 'Document',
|
'Constant', 'CommonForm', 'Catalog', 'Document',
|
||||||
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
|
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
|
||||||
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
|
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
|
||||||
@@ -54,7 +76,7 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||||
'Bot': 'Bots',
|
'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
|
||||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||||
@@ -110,6 +132,20 @@ VALID_ENUM_VALUES = {
|
|||||||
|
|
||||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
class Reporter:
|
class Reporter:
|
||||||
def __init__(self, max_errors, detailed=False):
|
def __init__(self, max_errors, detailed=False):
|
||||||
@@ -170,7 +206,7 @@ def main():
|
|||||||
parser.add_argument('-Detailed', action='store_true')
|
parser.add_argument('-Detailed', action='store_true')
|
||||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
config_path = args.ConfigPath
|
config_path = args.ConfigPath
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
@@ -230,11 +266,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get('version', '')
|
version = root.get('version', '')
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
r.warn('1. Missing version attribute on MetaDataObject')
|
r.warn('1. Missing version attribute on MetaDataObject')
|
||||||
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'):
|
elif version_rank == 0:
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||||
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||||
|
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||||
|
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
|
||||||
# Must have Configuration child
|
# Must have Configuration child
|
||||||
cfg_node = None
|
cfg_node = None
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ allowed-tools:
|
|||||||
| `ExtensionPath` | Путь к каталогу расширения (обязат.) |
|
| `ExtensionPath` | Путь к каталогу расширения (обязат.) |
|
||||||
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
|
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
|
||||||
| `Object` | Что заимствовать (обязат.), batch через `;;` |
|
| `Object` | Что заимствовать (обязат.), batch через `;;` |
|
||||||
|
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
|
||||||
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
|
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
|
||||||
|
|
||||||
## Формат -Object
|
## Формат -Object
|
||||||
@@ -41,7 +42,6 @@ allowed-tools:
|
|||||||
- `Enum.ВидыОплат` — перечисление
|
- `Enum.ВидыОплат` — перечисление
|
||||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||||
Поддерживаются все 44 типа объектов конфигурации.
|
|
||||||
|
|
||||||
### Заимствование форм
|
### Заимствование форм
|
||||||
|
|
||||||
@@ -66,36 +66,44 @@ allowed-tools:
|
|||||||
2. `/meta-edit` — добавить новый реквизит в объект расширения
|
2. `/meta-edit` — добавить новый реквизит в объект расширения
|
||||||
3. `/form-edit` — вывести реквизит на заимствованную форму
|
3. `/form-edit` — вывести реквизит на заимствованную форму
|
||||||
|
|
||||||
**Защита существующих данных**: если зависимый объект уже заимствован с содержимым (реквизитами, формами) — скрипт не перезаписывает его, а добавляет только недостающее.
|
**Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mode A — обзор расширения
|
## Mode A — обзор расширения
|
||||||
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Обзор — что изменено в расширении
|
# Обзор — что изменено в расширении
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||||
|
|
||||||
# Проверка переноса — все ли #Вставка перенесены
|
# Проверка переноса — все ли #Вставка перенесены
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
# cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory, Position=0)]
|
||||||
[string]$ExtensionPath,
|
[string]$ExtensionPath,
|
||||||
|
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -49,7 +50,10 @@ $childTypeDirMap = @{
|
|||||||
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
||||||
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
||||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||||
"CommonAttribute"="CommonAttributes"
|
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
|
||||||
|
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
|
"Bot"="Bots"
|
||||||
|
"PaletteColor"="PaletteColors"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Parse extension Configuration.xml ---
|
# --- Parse extension Configuration.xml ---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
# cfe-diff v1.4 — Analyze and compare 1C configuration extension (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -8,6 +8,28 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- Namespace maps ---
|
# --- Namespace maps ---
|
||||||
|
|
||||||
MD_NSMAP = {
|
MD_NSMAP = {
|
||||||
@@ -60,6 +82,13 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
"Sequence": "Sequences",
|
"Sequence": "Sequences",
|
||||||
"IntegrationService": "IntegrationServices",
|
"IntegrationService": "IntegrationServices",
|
||||||
"CommonAttribute": "CommonAttributes",
|
"CommonAttribute": "CommonAttributes",
|
||||||
|
"Style": "Styles",
|
||||||
|
"XDTOPackage": "XDTOPackages",
|
||||||
|
"WebService": "WebServices",
|
||||||
|
"HTTPService": "HTTPServices",
|
||||||
|
"WSReference": "WSReferences",
|
||||||
|
"Bot": "Bots",
|
||||||
|
"PaletteColor": "PaletteColors",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +497,7 @@ def main():
|
|||||||
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
||||||
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
||||||
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
extension_path = args.ExtensionPath
|
extension_path = args.ExtensionPath
|
||||||
config_path = args.ConfigPath
|
config_path = args.ConfigPath
|
||||||
|
|||||||
@@ -33,39 +33,39 @@ allowed-tools:
|
|||||||
| `Name` | Имя расширения (обязат.) | — |
|
| `Name` | Имя расширения (обязат.) | — |
|
||||||
| `Synonym` | Синоним | = Name |
|
| `Synonym` | Синоним | = Name |
|
||||||
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
||||||
| `OutputDir` | Каталог для создания | `src` |
|
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
|
||||||
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
||||||
| `Version` | Версия расширения | — |
|
| `Version` | Версия расширения | — |
|
||||||
| `Vendor` | Поставщик | — |
|
| `Vendor` | Поставщик | — |
|
||||||
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` |
|
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
|
||||||
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
||||||
| `NoRole` | Без основной роли | false |
|
| `NoRole` | Без основной роли | false |
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
||||||
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src
|
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
|
||||||
|
|
||||||
# Расширение-исправление с явным режимом совместимости
|
# Расширение-исправление с явным режимом совместимости
|
||||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src
|
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
|
||||||
|
|
||||||
# Расширение-доработка с версией
|
# Расширение-доработка с версией
|
||||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src
|
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
|
||||||
|
|
||||||
# Без роли, с явным префиксом
|
# Без роли, с явным префиксом
|
||||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src
|
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
```
|
```
|
||||||
/cfe-validate <OutputDir>
|
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
# cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$Name,
|
[string]$Name,
|
||||||
@@ -16,6 +17,12 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return $s.Replace('&','&').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 ---
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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 "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||||
```
|
```
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Код перед записью
|
# Код перед записью
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||||
|
|
||||||
# Перехват После на форме
|
# Перехват После на форме
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||||
|
|
||||||
# Замена функции (ПродолжитьВызов)
|
# Замена функции (ПродолжитьВызов)
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||||
|
|
||||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||||
|
|
||||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||||
|
|
||||||
# Проверить все контролируемые методы расширения на дрейф
|
# Проверить все контролируемые методы расширения на дрейф
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||||
|
|
||||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
```
|
```
|
||||||
/cfe-validate <ExtensionPath>
|
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
# cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$ExtensionPath,
|
[string]$ExtensionPath,
|
||||||
@@ -361,6 +362,22 @@ function Get-Normalized {
|
|||||||
return (($line -replace '\s+', ' ').Trim())
|
return (($line -replace '\s+', ' ').Trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
|
||||||
|
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
|
||||||
|
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
|
||||||
|
function Get-ControlKey {
|
||||||
|
param($lines)
|
||||||
|
return (@($lines | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) -join "`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parameter count of a signature params text. The platform compares only the number of
|
||||||
|
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
|
||||||
|
function Get-ParamCount {
|
||||||
|
param([string]$paramsText)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($paramsText)) { return 0 }
|
||||||
|
return @(Split-TopLevel $paramsText | Where-Object { $_.Trim() -ne '' }).Count
|
||||||
|
}
|
||||||
|
|
||||||
# Reconstruct v1 body and edit ops from a marked body
|
# Reconstruct v1 body and edit ops from a marked body
|
||||||
function Parse-MarkedBody {
|
function Parse-MarkedBody {
|
||||||
param($bodyLines)
|
param($bodyLines)
|
||||||
@@ -650,7 +667,14 @@ function Invoke-Resync {
|
|||||||
$v1norm = @($v1 | ForEach-Object { Get-Normalized $_ })
|
$v1norm = @($v1 | ForEach-Object { Get-Normalized $_ })
|
||||||
$v2norm = @($v2 | ForEach-Object { Get-Normalized $_ })
|
$v2norm = @($v2 | ForEach-Object { Get-Normalized $_ })
|
||||||
|
|
||||||
if (($v1norm -join "`n") -eq ($v2norm -join "`n")) {
|
# Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
|
||||||
|
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
|
||||||
|
$extParamCount = Get-ParamCount $sig.ParamsText
|
||||||
|
$srcParamCount = Get-ParamCount $method.ParamsText
|
||||||
|
$paramsDrift = ($extParamCount -ne $srcParamCount)
|
||||||
|
$paramsReason = if ($paramsDrift) { "список параметров: в оригинале $srcParamCount, в перехватчике $extParamCount" } else { '' }
|
||||||
|
|
||||||
|
if (-not $paramsDrift -and [string]::Equals((Get-ControlKey $v1), (Get-ControlKey $v2), 'Ordinal')) {
|
||||||
return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl }
|
return @{ Id = $methodId; Status = 'АКТУАЛЕН'; ExtBsl = $extBsl }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,7 +724,9 @@ function Invoke-Resync {
|
|||||||
|
|
||||||
if ($ReportOnly) {
|
if ($ReportOnly) {
|
||||||
$st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' }
|
$st = if ($disputed.Count -gt 0) { 'КОНФЛИКТ' } elseif ($transferred.Count -eq 0 -and $absorbed.Count -gt 0) { 'ПЕРЕНЕСЕНО В ОСНОВНУЮ' } else { 'ДРЕЙФ' }
|
||||||
|
if ($paramsDrift -and $st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { $st = 'ДРЕЙФ' }
|
||||||
$rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' }
|
$rsn = if ($disputed.Count -gt 0) { Get-ResyncConflictReason $disputed } elseif ($st -eq 'ПЕРЕНЕСЕНО В ОСНОВНУЮ') { 'все правки уже в основной конфигурации' } else { '' }
|
||||||
|
if ($paramsDrift) { $rsn = if ($rsn) { "$paramsReason; $rsn" } else { $paramsReason } }
|
||||||
return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes }
|
return @{ Id = $methodId; Status = $st; ExtBsl = $extBsl; Transferred = $transferred.Count; Absorbed = $absorbed.Count; Disputed = $disputed.Count; Reason = $rsn; AbsorbedNotes = $absorbedNotes }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,6 +814,100 @@ if (Test-Path $ExtensionPath -PathType Leaf) { $ExtensionPath = Split-Path $Exte
|
|||||||
$cfgFile = Join-Path $ExtensionPath "Configuration.xml"
|
$cfgFile = Join-Path $ExtensionPath "Configuration.xml"
|
||||||
if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 }
|
if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 }
|
||||||
|
|
||||||
|
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
|
||||||
|
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
|
||||||
|
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
|
||||||
|
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
|
||||||
|
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detect-FormatVersion([string]$dir) {
|
||||||
|
$d = $dir
|
||||||
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
|
if (Test-Path $cfgPath) {
|
||||||
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
|
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||||
|
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||||
|
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||||
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
|
$parent = Split-Path $d -Parent
|
||||||
|
if ($parent -eq $d) { break }
|
||||||
|
$d = $parent
|
||||||
|
}
|
||||||
|
return "2.17"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-PropertyStateXml {
|
||||||
|
param([string]$propertyName, [string]$indent)
|
||||||
|
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
$sb.AppendLine("${indent}<xr:PropertyState>") | Out-Null
|
||||||
|
$sb.AppendLine("${indent}`t<xr:Property>${propertyName}</xr:Property>") | Out-Null
|
||||||
|
$sb.AppendLine("${indent}`t<xr:State>Extended</xr:State>") | Out-Null
|
||||||
|
$sb.Append("${indent}</xr:PropertyState>") | Out-Null
|
||||||
|
return $sb.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-PropertyStateFlag {
|
||||||
|
param([string]$objFile, [string]$propertyName, [string]$formatVersion)
|
||||||
|
|
||||||
|
if ((Get-FormatRank $formatVersion) -lt 219) { return }
|
||||||
|
if (-not (Test-Path $objFile)) { return }
|
||||||
|
|
||||||
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
$text = [System.IO.File]::ReadAllText($objFile, $enc)
|
||||||
|
$nl = if ($text -match "`r`n") { "`r`n" } else { "`n" }
|
||||||
|
|
||||||
|
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
|
||||||
|
# но они лежат ниже, внутри <ChildObjects>.
|
||||||
|
$empty = [regex]::Match($text, '([ \t]*)<InternalInfo\s*/>')
|
||||||
|
$open = [regex]::Match($text, '(?s)([ \t]*)<InternalInfo>(.*?)</InternalInfo>')
|
||||||
|
|
||||||
|
if ($empty.Success -and (-not $open.Success -or $empty.Index -lt $open.Index)) {
|
||||||
|
$ind = $empty.Groups[1].Value
|
||||||
|
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
|
||||||
|
$replacement = "${ind}<InternalInfo>${nl}${block}${nl}${ind}</InternalInfo>"
|
||||||
|
$text = $text.Remove($empty.Index, $empty.Length).Insert($empty.Index, $replacement)
|
||||||
|
} elseif ($open.Success) {
|
||||||
|
if ($open.Groups[2].Value -match "<xr:Property>$([regex]::Escape($propertyName))</xr:Property>") { return }
|
||||||
|
$ind = $open.Groups[1].Value
|
||||||
|
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
|
||||||
|
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
|
||||||
|
$closeAt = $open.Index + $open.Length - "</InternalInfo>".Length - $ind.Length
|
||||||
|
$text = $text.Insert($closeAt, "${block}${nl}")
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($objFile, $text, $enc)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
|
||||||
|
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
|
||||||
|
function Get-ModuleFlagTarget {
|
||||||
|
param([string[]]$relParts, [string]$extRoot)
|
||||||
|
|
||||||
|
if ($relParts.Count -ne 4 -or $relParts[2] -ne "Ext") { return $null }
|
||||||
|
$prop = [System.IO.Path]::GetFileNameWithoutExtension($relParts[3])
|
||||||
|
return @{
|
||||||
|
File = (Join-Path (Join-Path $extRoot $relParts[0]) "$($relParts[1]).xml")
|
||||||
|
Property = $prop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Read NamePrefix ---
|
# --- Read NamePrefix ---
|
||||||
$cfgDoc = New-Object System.Xml.XmlDocument
|
$cfgDoc = New-Object System.Xml.XmlDocument
|
||||||
$cfgDoc.PreserveWhitespace = $false
|
$cfgDoc.PreserveWhitespace = $false
|
||||||
@@ -1084,6 +1204,12 @@ if ($reuseRegionIdx -ge 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
|
||||||
|
$flagTarget = Get-ModuleFlagTarget $relParts $ExtensionPath
|
||||||
|
if ($flagTarget) {
|
||||||
|
Set-PropertyStateFlag $flagTarget.File $flagTarget.Property (Detect-FormatVersion $ExtensionPath)
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement"
|
Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement"
|
||||||
Write-Host " Файл: $extBsl"
|
Write-Host " Файл: $extBsl"
|
||||||
Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))"
|
Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
# cfe-patch-method v2.11 — Source-aware method interceptor for 1C extension (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -10,6 +10,28 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
TYPE_DIR_MAP = {
|
TYPE_DIR_MAP = {
|
||||||
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
|
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
|
||||||
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
|
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
|
||||||
@@ -19,6 +41,24 @@ TYPE_DIR_MAP = {
|
|||||||
"BusinessProcess": "BusinessProcesses", "Task": "Tasks",
|
"BusinessProcess": "BusinessProcesses", "Task": "Tasks",
|
||||||
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
|
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
|
||||||
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
|
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
|
||||||
|
# Прощающий ввод: имя каталога принимается наравне с именем типа (Catalogs.X ≡ Catalog.X) —
|
||||||
|
# PS1-порт так умел с самого начала, PY отставал.
|
||||||
|
"Catalogs": "Catalogs",
|
||||||
|
"Documents": "Documents",
|
||||||
|
"Enums": "Enums",
|
||||||
|
"CommonModules": "CommonModules",
|
||||||
|
"Reports": "Reports",
|
||||||
|
"DataProcessors": "DataProcessors",
|
||||||
|
"ExchangePlans": "ExchangePlans",
|
||||||
|
"ChartsOfAccounts": "ChartsOfAccounts",
|
||||||
|
"ChartsOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
|
||||||
|
"ChartsOfCalculationTypes": "ChartsOfCalculationTypes",
|
||||||
|
"BusinessProcesses": "BusinessProcesses",
|
||||||
|
"Tasks": "Tasks",
|
||||||
|
"InformationRegisters": "InformationRegisters",
|
||||||
|
"AccumulationRegisters": "AccumulationRegisters",
|
||||||
|
"AccountingRegisters": "AccountingRegisters",
|
||||||
|
"CalculationRegisters": "CalculationRegisters",
|
||||||
}
|
}
|
||||||
# accept plural forms too
|
# accept plural forms too
|
||||||
for _v in list(TYPE_DIR_MAP.values()):
|
for _v in list(TYPE_DIR_MAP.values()):
|
||||||
@@ -35,6 +75,99 @@ CONTEXT_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
|
||||||
|
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
|
||||||
|
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
|
||||||
|
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
|
||||||
|
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
|
||||||
|
def detect_format_version(d):
|
||||||
|
while d:
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
ext_path = d + ".xml"
|
||||||
|
if os.path.isfile(ext_path):
|
||||||
|
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
ext_head = f.read(2000)
|
||||||
|
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
|
if os.path.isfile(cfg_path):
|
||||||
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
head = f.read(2000)
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
break
|
||||||
|
d = parent
|
||||||
|
return "2.17"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_property_state_xml(property_name, indent):
|
||||||
|
return "\n".join([
|
||||||
|
f"{indent}<xr:PropertyState>",
|
||||||
|
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
|
||||||
|
f"{indent}\t<xr:State>Extended</xr:State>",
|
||||||
|
f"{indent}</xr:PropertyState>",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def set_property_state_flag(obj_file, property_name, format_version):
|
||||||
|
if format_rank(format_version) < 219:
|
||||||
|
return
|
||||||
|
if not os.path.isfile(obj_file):
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
text = fh.read()
|
||||||
|
nl = "\r\n" if "\r\n" in text else "\n"
|
||||||
|
|
||||||
|
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
|
||||||
|
# но они лежат ниже, внутри <ChildObjects>.
|
||||||
|
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
|
||||||
|
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
|
||||||
|
|
||||||
|
if empty and (not opened or empty.start() < opened.start()):
|
||||||
|
ind = empty.group(1)
|
||||||
|
block = build_property_state_xml(property_name, ind + "\t")
|
||||||
|
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
|
||||||
|
text = text[:empty.start()] + replacement + text[empty.end():]
|
||||||
|
elif opened:
|
||||||
|
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
|
||||||
|
return
|
||||||
|
ind = opened.group(1)
|
||||||
|
block = build_property_state_xml(property_name, ind + "\t")
|
||||||
|
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
|
||||||
|
close_at = opened.end() - len("</InternalInfo>") - len(ind)
|
||||||
|
text = text[:close_at] + block + nl + text[close_at:]
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
fh.write(text)
|
||||||
|
|
||||||
|
|
||||||
|
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
|
||||||
|
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
|
||||||
|
def get_module_flag_target(rel_parts, ext_root):
|
||||||
|
if len(rel_parts) != 4 or rel_parts[2] != "Ext":
|
||||||
|
return None
|
||||||
|
prop = os.path.splitext(rel_parts[3])[0]
|
||||||
|
return {
|
||||||
|
"file": os.path.join(ext_root, rel_parts[0], f"{rel_parts[1]}.xml"),
|
||||||
|
"property": prop,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_module_rel_path(module_path):
|
def get_module_rel_path(module_path):
|
||||||
parts = module_path.split(".")
|
parts = module_path.split(".")
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
@@ -358,6 +491,21 @@ def normalize(line):
|
|||||||
return re.sub(r'\s+', ' ', line).strip()
|
return re.sub(r'\s+', ' ', line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# Control comparison key, as the platform compares a &ИзменениеИКонтроль copy with the original:
|
||||||
|
# each line trimmed, blank lines dropped, everything else byte-for-byte and case-sensitive
|
||||||
|
# (inner spaces, comments and letter case are significant). Measured on 8.3.24 and 8.3.27.
|
||||||
|
def control_key(lines):
|
||||||
|
return "\n".join([k for k in (x.strip() for x in lines) if k != ""])
|
||||||
|
|
||||||
|
|
||||||
|
# Parameter count of a signature params text. The platform compares only the number of
|
||||||
|
# parameters of a &ИзменениеИКонтроль copy (names and default values it ignores).
|
||||||
|
def param_count(params_text):
|
||||||
|
if not params_text or not params_text.strip():
|
||||||
|
return 0
|
||||||
|
return len([p for p in split_top_level(params_text) if p.strip()])
|
||||||
|
|
||||||
|
|
||||||
def parse_marked_body(body_lines):
|
def parse_marked_body(body_lines):
|
||||||
v1 = []
|
v1 = []
|
||||||
ops = []
|
ops = []
|
||||||
@@ -539,7 +687,7 @@ def main():
|
|||||||
choices=["", "Before", "After", "Instead", "ModificationAndControl"])
|
choices=["", "Before", "After", "Instead", "ModificationAndControl"])
|
||||||
parser.add_argument("-Check", action="store_true")
|
parser.add_argument("-Check", action="store_true")
|
||||||
parser.add_argument("-Actualize", action="store_true")
|
parser.add_argument("-Actualize", action="store_true")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
extension_path = args.ExtensionPath
|
extension_path = args.ExtensionPath
|
||||||
config_path = args.ConfigPath
|
config_path = args.ConfigPath
|
||||||
@@ -801,6 +949,12 @@ def main():
|
|||||||
|
|
||||||
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core)
|
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core)
|
||||||
|
|
||||||
|
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
|
||||||
|
flag_target = get_module_flag_target(rel_parts, extension_path)
|
||||||
|
if flag_target:
|
||||||
|
set_property_state_flag(flag_target["file"], flag_target["property"],
|
||||||
|
detect_format_version(extension_path))
|
||||||
|
|
||||||
# emit summary
|
# emit summary
|
||||||
placement = place_new.placement
|
placement = place_new.placement
|
||||||
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement))
|
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement))
|
||||||
@@ -990,7 +1144,7 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
|
|||||||
sig = read_signature(ext_lines, sig_line_idx)
|
sig = read_signature(ext_lines, sig_line_idx)
|
||||||
if not sig:
|
if not sig:
|
||||||
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
|
return {"id": method_id, "status": "ОШИБКА", "ext_bsl": ext_bsl, "reason": "не разобрать сигнатуру"}
|
||||||
_params, sig_end = sig
|
ext_params_text, sig_end = sig
|
||||||
is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE))
|
is_func = bool(re.match(r'^\s*(?:Асинх\s+)?Функция\b', ext_lines[sig_line_idx], re.IGNORECASE))
|
||||||
end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE)
|
end_re = re.compile(r'^\s*КонецФункции\b' if is_func else r'^\s*КонецПроцедуры\b', re.IGNORECASE)
|
||||||
block_end = -1
|
block_end = -1
|
||||||
@@ -1007,7 +1161,15 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
|
|||||||
v1norm = [normalize(x) for x in v1]
|
v1norm = [normalize(x) for x in v1]
|
||||||
v2norm = [normalize(x) for x in v2]
|
v2norm = [normalize(x) for x in v2]
|
||||||
|
|
||||||
if "\n".join(v1norm) == "\n".join(v2norm):
|
# Signature: the platform rejects the interceptor when the parameter COUNT differs, so a
|
||||||
|
# body-only comparison would miss a vendor-added parameter. Names/defaults it ignores.
|
||||||
|
ext_param_count = param_count(ext_params_text)
|
||||||
|
src_param_count = param_count(method["params_text"])
|
||||||
|
params_drift = ext_param_count != src_param_count
|
||||||
|
params_reason = ("список параметров: в оригинале %d, в перехватчике %d"
|
||||||
|
% (src_param_count, ext_param_count)) if params_drift else ""
|
||||||
|
|
||||||
|
if not params_drift and control_key(v1) == control_key(v2):
|
||||||
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
|
return {"id": method_id, "status": "АКТУАЛЕН", "ext_bsl": ext_bsl}
|
||||||
|
|
||||||
insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = []
|
insert_top = []; insert_after = {}; del_start = set(); del_end = set(); disputed = []; transferred = 0; absorbed = 0; absorbed_notes = []
|
||||||
@@ -1069,7 +1231,11 @@ def resync_one(ext_bsl, ext_lines, dup, method, logical_module, conflict_folder,
|
|||||||
st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
|
st = "ПЕРЕНЕСЕНО В ОСНОВНУЮ"
|
||||||
else:
|
else:
|
||||||
st = "ДРЕЙФ"
|
st = "ДРЕЙФ"
|
||||||
|
if params_drift and st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ":
|
||||||
|
st = "ДРЕЙФ"
|
||||||
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
|
rsn = conflict_reason(disputed) if disputed else ("все правки уже в основной конфигурации" if st == "ПЕРЕНЕСЕНО В ОСНОВНУЮ" else "")
|
||||||
|
if params_drift:
|
||||||
|
rsn = ("%s; %s" % (params_reason, rsn)) if rsn else params_reason
|
||||||
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
|
return {"id": method_id, "status": st, "ext_bsl": ext_bsl, "transferred": transferred,
|
||||||
"absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes}
|
"absorbed": absorbed, "disputed": len(disputed), "reason": rsn, "absorbed_notes": absorbed_notes}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: cfe-validate
|
name: cfe-validate
|
||||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||||
argument-hint: <ExtensionPath> [-Detailed] [-MaxErrors 30]
|
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
@@ -10,20 +10,31 @@ allowed-tools:
|
|||||||
|
|
||||||
# /cfe-validate — валидация расширения конфигурации (CFE)
|
# /cfe-validate — валидация расширения конфигурации (CFE)
|
||||||
|
|
||||||
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты. Аналог `/cf-validate`, но для расширений.
|
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
|
||||||
|
|
||||||
## Параметры
|
## Параметры
|
||||||
|
|
||||||
| Параметр | Обяз. | Умолч. | Описание |
|
| Параметр | Обяз. | Умолч. | Описание |
|
||||||
|---------------|:-----:|---------|-------------------------------------------------|
|
|---------------|:-----:|---------|-------------------------------------------------|
|
||||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||||
|
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||||
| OutFile | нет | — | Записать результат в файл |
|
| OutFile | нет | — | Записать результат в файл |
|
||||||
|
|
||||||
|
### ConfigPath
|
||||||
|
|
||||||
|
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||||
|
|
||||||
|
Если пользователь не указал путь — определи сам:
|
||||||
|
1. Прочитай `.v8-project.json` из корня проекта
|
||||||
|
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||||
|
3. Возьми её поле `configSrc`
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src/Configuration.xml"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# cfe-validate v1.5 — Validate 1C configuration extension structure (CFE)
|
# cfe-validate v1.15 — Validate 1C configuration extension structure (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory, Position=0)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
[string]$ExtensionPath,
|
[string]$ExtensionPath,
|
||||||
|
|
||||||
@@ -9,7 +10,11 @@ param(
|
|||||||
|
|
||||||
[int]$MaxErrors = 30,
|
[int]$MaxErrors = 30,
|
||||||
|
|
||||||
[string]$OutFile
|
[string]$OutFile,
|
||||||
|
|
||||||
|
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||||
|
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||||
|
[string]$ConfigPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -89,8 +94,42 @@ $finalize = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- Reference tables ---
|
# --- Reference tables ---
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
|
||||||
|
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
|
||||||
|
$moduleKindsByType = @{
|
||||||
|
"CommonModule"=@("Module"); "HTTPService"=@("Module"); "WebService"=@("Module")
|
||||||
|
"Catalog"=@("ObjectModule","ManagerModule"); "Document"=@("ObjectModule","ManagerModule")
|
||||||
|
"Report"=@("ObjectModule","ManagerModule"); "DataProcessor"=@("ObjectModule","ManagerModule")
|
||||||
|
"ExchangePlan"=@("ObjectModule","ManagerModule")
|
||||||
|
"ChartOfCharacteristicTypes"=@("ObjectModule","ManagerModule")
|
||||||
|
"ChartOfAccounts"=@("ObjectModule","ManagerModule")
|
||||||
|
"ChartOfCalculationTypes"=@("ObjectModule","ManagerModule")
|
||||||
|
"BusinessProcess"=@("ObjectModule","ManagerModule"); "Task"=@("ObjectModule","ManagerModule")
|
||||||
|
"InformationRegister"=@("RecordSetModule","ManagerModule")
|
||||||
|
"AccumulationRegister"=@("RecordSetModule","ManagerModule")
|
||||||
|
"AccountingRegister"=@("RecordSetModule","ManagerModule")
|
||||||
|
"CalculationRegister"=@("RecordSetModule","ManagerModule")
|
||||||
|
"Sequence"=@("RecordSetModule","ManagerModule")
|
||||||
|
"Constant"=@("ValueManagerModule","ManagerModule")
|
||||||
|
"Enum"=@("ManagerModule"); "DocumentJournal"=@("ManagerModule")
|
||||||
|
"FilterCriterion"=@("ManagerModule")
|
||||||
|
}
|
||||||
|
|
||||||
|
$guidPattern ='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||||
|
|
||||||
# 7 fixed ClassIds for Configuration
|
# 7 fixed ClassIds for Configuration
|
||||||
@@ -104,14 +143,14 @@ $validClassIds = @(
|
|||||||
"fb282519-d103-4dd3-bc12-cb271d631dfc"
|
"fb282519-d103-4dd3-bc12-cb271d631dfc"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 44 types in canonical order
|
# 46 types in canonical order
|
||||||
$childObjectTypes = @(
|
$childObjectTypes = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
|
||||||
"Constant","CommonForm","Catalog","Document",
|
"Constant","CommonForm","Catalog","Document",
|
||||||
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
||||||
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
||||||
@@ -122,7 +161,7 @@ $childObjectTypes = @(
|
|||||||
|
|
||||||
# Type -> directory mapping
|
# Type -> directory mapping
|
||||||
$childTypeDirMap = @{
|
$childTypeDirMap = @{
|
||||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"; "PaletteColor"="PaletteColors"
|
||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||||
@@ -144,6 +183,46 @@ $childTypeDirMap = @{
|
|||||||
"IntegrationService"="IntegrationServices"
|
"IntegrationService"="IntegrationServices"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||||
|
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||||
|
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||||
|
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||||
|
$generatedTypeCategories = @{
|
||||||
|
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Document" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Enum" = @("Ref","Manager","List")
|
||||||
|
"Constant" = @("Manager","ValueManager","ValueKey")
|
||||||
|
"Report" = @("Object","Manager")
|
||||||
|
"DataProcessor" = @("Object","Manager")
|
||||||
|
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Task" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
|
||||||
|
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
|
||||||
|
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
|
||||||
|
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
|
||||||
|
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
|
||||||
|
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
|
||||||
|
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
|
||||||
|
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
|
||||||
|
"DocumentJournal" = @("Selection","List","Manager")
|
||||||
|
"Sequence" = @("Record","Manager","RecordSet")
|
||||||
|
"FilterCriterion" = @("Manager","List")
|
||||||
|
"SettingsStorage" = @("Manager")
|
||||||
|
"IntegrationService" = @("Manager")
|
||||||
|
"WSReference" = @("Manager")
|
||||||
|
"DefinedType" = @("DefinedType")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||||
|
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||||
|
$script:standardObjectFields = @(
|
||||||
|
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
|
||||||
|
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
|
||||||
|
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
|
||||||
|
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
|
||||||
|
"Движения","ВерсияДанных","КоличествоСтрок"
|
||||||
|
)
|
||||||
|
|
||||||
# Valid enum values for extension properties
|
# Valid enum values for extension properties
|
||||||
$validEnumValues = @{
|
$validEnumValues = @{
|
||||||
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
||||||
@@ -195,11 +274,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
} elseif ($versionRank -eq 0) {
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||||
|
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Must have Configuration child
|
# Must have Configuration child
|
||||||
@@ -537,6 +620,7 @@ if ($script:stopped) { & $finalize; exit 1 }
|
|||||||
|
|
||||||
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
||||||
$script:enumValuesIndex = @{}
|
$script:enumValuesIndex = @{}
|
||||||
|
$script:borrowedTSIndex = @{}
|
||||||
$script:formList = @()
|
$script:formList = @()
|
||||||
|
|
||||||
# Helper: check if sub-item has explicit borrowed metadata
|
# Helper: check if sub-item has explicit borrowed metadata
|
||||||
@@ -640,6 +724,25 @@ if ($childObjNode) {
|
|||||||
} else {
|
} else {
|
||||||
$borrowedOk++
|
$borrowedOk++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||||
|
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||||
|
$expectedCats = $generatedTypeCategories[$typeName]
|
||||||
|
if ($expectedCats) {
|
||||||
|
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
|
||||||
|
$foundCats = @{}
|
||||||
|
if ($objInfo) {
|
||||||
|
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
|
||||||
|
$cat = $gt.GetAttribute("category")
|
||||||
|
if ($cat) { $foundCats[$cat] = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
|
||||||
|
if ($missingCats.Count -gt 0) {
|
||||||
|
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
|
||||||
|
$check9Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||||
@@ -667,6 +770,12 @@ if ($childObjNode) {
|
|||||||
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
||||||
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
||||||
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
||||||
|
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||||
|
if ($tsName) {
|
||||||
|
$tsKey = "${typeName}.${childName}"
|
||||||
|
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
|
||||||
|
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
|
||||||
|
}
|
||||||
if (-not $tsInfo) {
|
if (-not $tsInfo) {
|
||||||
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
||||||
$check10Ok = $false
|
$check10Ok = $false
|
||||||
@@ -896,6 +1005,38 @@ foreach ($bf in $script:borrowedFormsWithTree) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||||
|
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки ниже на
|
||||||
|
# таких формах молча не срабатывали. Ищем сначала в <Attributes> самой формы, потом в <BaseForm>.
|
||||||
|
$rootName = ""
|
||||||
|
$rootMatch = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||||
|
if ($rootMatch.Success) { $rootName = $rootMatch.Groups[1].Value }
|
||||||
|
|
||||||
|
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||||
|
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||||
|
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||||
|
$acTables = @{}
|
||||||
|
if ($rootName) {
|
||||||
|
$rootPat = [regex]::Escape($rootName)
|
||||||
|
foreach ($m in [regex]::Matches($raw, "<AdditionalColumns table=`"${rootPat}\.(\w+)`"")) {
|
||||||
|
$acTables[$m.Groups[1].Value] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||||
|
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||||
|
# поэтому ошибка.
|
||||||
|
if ($acTables.Count -gt 0) {
|
||||||
|
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||||
|
$ownerTS = $script:borrowedTSIndex[$ownerKey]
|
||||||
|
foreach ($tblName in $acTables.Keys) {
|
||||||
|
$depCheckCount++
|
||||||
|
if (-not $ownerTS -or -not $ownerTS.ContainsKey($tblName)) {
|
||||||
|
Report-Error "12. ${ctx}: <AdditionalColumns table=`"${rootName}.${tblName}`"> — TabularSection.${tblName} not borrowed in extension"
|
||||||
|
$check12Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($mi in $missingItems) {
|
foreach ($mi in $missingItems) {
|
||||||
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
||||||
$check12Ok = $false
|
$check12Ok = $false
|
||||||
@@ -931,6 +1072,232 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
|||||||
Report-OK "13. TypeLink: clean"
|
Report-OK "13. TypeLink: clean"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||||
|
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||||
|
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||||
|
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
|
||||||
|
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||||
|
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
|
||||||
|
if (-not $ConfigPath) {
|
||||||
|
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
|
||||||
|
} else {
|
||||||
|
$cfgRoot = $ConfigPath
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
|
||||||
|
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
|
||||||
|
|
||||||
|
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
|
||||||
|
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
|
||||||
|
} else {
|
||||||
|
$check14Ok = $true
|
||||||
|
$pathCheckCount = 0
|
||||||
|
|
||||||
|
foreach ($bf in $script:borrowedFormsWithTree) {
|
||||||
|
$raw = $bf.RawText
|
||||||
|
$ctx = $bf.Context
|
||||||
|
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||||
|
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||||
|
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||||
|
if (-not $rootMatch14.Success) { continue }
|
||||||
|
$rootName = $rootMatch14.Groups[1].Value
|
||||||
|
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||||
|
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||||
|
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||||
|
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||||
|
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
|
||||||
|
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||||
|
$ownerParts = $ownerKey -split '\.', 2
|
||||||
|
if ($ownerParts.Count -lt 2) { continue }
|
||||||
|
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
|
||||||
|
$ownerDir = $childTypeDirMap[$ownerType]
|
||||||
|
if (-not $ownerDir) { continue }
|
||||||
|
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
|
||||||
|
if (-not (Test-Path $srcObjFile)) {
|
||||||
|
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||||
|
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||||
|
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||||
|
$srcNames = @{}
|
||||||
|
$srcTSColumns = @{}
|
||||||
|
$srcDoc = New-Object System.Xml.XmlDocument
|
||||||
|
$srcDoc.PreserveWhitespace = $false
|
||||||
|
$srcDoc.Load($srcObjFile)
|
||||||
|
$srcObjEl = $null
|
||||||
|
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
|
||||||
|
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
|
||||||
|
}
|
||||||
|
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
|
||||||
|
if ($srcChildObjects) {
|
||||||
|
foreach ($sub in $srcChildObjects.ChildNodes) {
|
||||||
|
if ($sub.NodeType -ne 'Element') { continue }
|
||||||
|
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
|
||||||
|
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||||
|
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
|
||||||
|
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||||
|
if (-not $nameNode) { continue }
|
||||||
|
$subName = $nameNode.InnerText.Trim()
|
||||||
|
$srcNames[$subName] = $true
|
||||||
|
if ($sub.LocalName -ne 'TabularSection') { continue }
|
||||||
|
$cols = @{}
|
||||||
|
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
|
||||||
|
$cols[$colName.InnerText.Trim()] = $true
|
||||||
|
}
|
||||||
|
$srcTSColumns[$subName] = $cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||||
|
$rootPat14 = [regex]::Escape($rootName)
|
||||||
|
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
|
||||||
|
$tbl = $acm.Groups[1].Value
|
||||||
|
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
|
||||||
|
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
|
||||||
|
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$badPaths = @{}
|
||||||
|
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||||
|
$segments = $m.Groups[1].Value -split '\.'
|
||||||
|
$seg0 = $segments[0]
|
||||||
|
$pathCheckCount++
|
||||||
|
if ($script:standardObjectFields -contains $seg0) { continue }
|
||||||
|
if (-not $srcNames.ContainsKey($seg0)) {
|
||||||
|
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||||
|
# он ведёт в чужой объект, и это уже другая проверка.
|
||||||
|
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
|
||||||
|
$seg1 = $segments[1]
|
||||||
|
if ($script:standardObjectFields -contains $seg1) { continue }
|
||||||
|
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||||
|
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
|
||||||
|
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
|
||||||
|
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
|
||||||
|
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
|
||||||
|
$check14Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($check14Ok) {
|
||||||
|
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
|
||||||
|
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
|
||||||
|
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
|
||||||
|
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
|
||||||
|
$defaultRoleNodes = @($cfgNode.SelectNodes("md:Properties/md:DefaultRoles/xr:Item", $ns))
|
||||||
|
if ($defaultRoleNodes.Count -gt 0) {
|
||||||
|
$adoptedCache = @{}
|
||||||
|
|
||||||
|
function Test-ObjectAdopted {
|
||||||
|
param([string]$typeName, [string]$objName)
|
||||||
|
$key = "$typeName.$objName"
|
||||||
|
if ($adoptedCache.ContainsKey($key)) { return $adoptedCache[$key] }
|
||||||
|
$adoptedCache[$key] = $false
|
||||||
|
if ($childTypeDirMap.ContainsKey($typeName)) {
|
||||||
|
$objPath = Join-Path (Join-Path $configDir $childTypeDirMap[$typeName]) "$objName.xml"
|
||||||
|
if (Test-Path $objPath) {
|
||||||
|
try {
|
||||||
|
$objDoc = New-Object System.Xml.XmlDocument
|
||||||
|
$objDoc.Load($objPath)
|
||||||
|
$objNs = New-Object System.Xml.XmlNamespaceManager($objDoc.NameTable)
|
||||||
|
$objNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||||
|
$ob = $objDoc.SelectSingleNode("/md:MetaDataObject/md:$typeName/md:Properties/md:ObjectBelonging", $objNs)
|
||||||
|
if ($ob -and $ob.InnerText -eq "Adopted") { $adoptedCache[$key] = $true }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $adoptedCache[$key]
|
||||||
|
}
|
||||||
|
|
||||||
|
$check15Ok = $true
|
||||||
|
$check15Count = 0
|
||||||
|
foreach ($rn in $defaultRoleNodes) {
|
||||||
|
$roleRef = $rn.InnerText
|
||||||
|
if ($roleRef -notmatch '^Role\.(.+)$') { continue }
|
||||||
|
$defRoleName = $Matches[1]
|
||||||
|
$rightsPath = Join-Path (Join-Path (Join-Path $configDir "Roles") $defRoleName) "Ext\Rights.xml"
|
||||||
|
if (-not (Test-Path $rightsPath)) { continue }
|
||||||
|
try {
|
||||||
|
$rDoc = New-Object System.Xml.XmlDocument
|
||||||
|
$rDoc.Load($rightsPath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$rNs = New-Object System.Xml.XmlNamespaceManager($rDoc.NameTable)
|
||||||
|
$rNs.AddNamespace("r", "http://v8.1c.ru/8.2/roles")
|
||||||
|
foreach ($nameNode in $rDoc.SelectNodes("/r:Rights/r:object/r:name", $rNs)) {
|
||||||
|
$fullName = $nameNode.InnerText
|
||||||
|
$segs = $fullName.Split(".")
|
||||||
|
# Configuration.* — права самого расширения, не объект; заимствования там нет.
|
||||||
|
if ($segs.Count -lt 2 -or $segs[0] -eq "Configuration") { continue }
|
||||||
|
$check15Count++
|
||||||
|
if (Test-ObjectAdopted $segs[0] $segs[1]) {
|
||||||
|
Report-Error ("15. Роль '$defRoleName' входит в DefaultRoles и даёт права на заимствованный $($segs[0]).$($segs[1]) " +
|
||||||
|
"($fullName): платформа это запрещает. Вынесите такие права в отдельную роль вне DefaultRoles.")
|
||||||
|
$check15Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($check15Ok -and $check15Count -gt 0) {
|
||||||
|
Report-OK "15. Основные роли: прав на заимствованные объекты нет ($check15Count checked)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
|
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
|
||||||
|
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
|
||||||
|
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
|
||||||
|
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на стенде),
|
||||||
|
# но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
|
||||||
|
if ($versionRank -ge 219 -and $childObjNode) {
|
||||||
|
$stateIssues = @()
|
||||||
|
$stateChecked = 0
|
||||||
|
foreach ($child in $childObjNode.ChildNodes) {
|
||||||
|
if ($child.NodeType -ne 'Element') { continue }
|
||||||
|
$typeName = $child.LocalName
|
||||||
|
if (-not $moduleKindsByType.ContainsKey($typeName)) { continue }
|
||||||
|
if (-not $childTypeDirMap.ContainsKey($typeName)) { continue }
|
||||||
|
$stateObjName = $child.InnerText.Trim()
|
||||||
|
if (-not $stateObjName) { continue }
|
||||||
|
$typeDir = Join-Path $configDir $childTypeDirMap[$typeName]
|
||||||
|
$objFile = Join-Path $typeDir "$stateObjName.xml"
|
||||||
|
if (-not (Test-Path $objFile)) { continue }
|
||||||
|
$objText = [System.IO.File]::ReadAllText($objFile, [System.Text.Encoding]::UTF8)
|
||||||
|
if ($objText -notmatch '<ObjectBelonging>Adopted</ObjectBelonging>') { continue }
|
||||||
|
|
||||||
|
foreach ($kind in $moduleKindsByType[$typeName]) {
|
||||||
|
$stateChecked++
|
||||||
|
$hasFile = Test-Path (Join-Path (Join-Path (Join-Path $typeDir $stateObjName) "Ext") "$kind.bsl")
|
||||||
|
$hasFlag = $objText -match "<xr:Property>$kind</xr:Property>"
|
||||||
|
if ($hasFile -and -not $hasFlag) {
|
||||||
|
$stateIssues += "$typeName.$stateObjName — есть $kind.bsl, но нет <xr:PropertyState> для $kind"
|
||||||
|
} elseif ($hasFlag -and -not $hasFile) {
|
||||||
|
$stateIssues += "$typeName.$stateObjName — есть <xr:PropertyState> для $kind, но нет $kind.bsl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stateChecked -gt 0) {
|
||||||
|
if ($stateIssues.Count -eq 0) {
|
||||||
|
Report-OK "16. Модули заимствованных объектов: пометки расширенных свойств согласованы ($stateChecked)"
|
||||||
|
} else {
|
||||||
|
foreach ($issue in $stateIssues) { Report-Warn "16. $issue" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||||
$extRootDir = Split-Path $resolvedPath -Parent
|
$extRootDir = Split-Path $resolvedPath -Parent
|
||||||
$ctrlCount = 0
|
$ctrlCount = 0
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cfe-validate v1.5 — Validate 1C configuration extension XML structure (CFE)
|
# cfe-validate v1.15 — Validate 1C configuration extension XML structure (CFE)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NS = {
|
NS = {
|
||||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||||
@@ -33,14 +55,14 @@ VALID_CLASS_IDS = [
|
|||||||
'fb282519-d103-4dd3-bc12-cb271d631dfc',
|
'fb282519-d103-4dd3-bc12-cb271d631dfc',
|
||||||
]
|
]
|
||||||
|
|
||||||
# 44 types in canonical order
|
# 46 types in canonical order
|
||||||
CHILD_OBJECT_TYPES = [
|
CHILD_OBJECT_TYPES = [
|
||||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
|
||||||
'Constant', 'CommonForm', 'Catalog', 'Document',
|
'Constant', 'CommonForm', 'Catalog', 'Document',
|
||||||
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
|
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
|
||||||
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
|
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
|
||||||
@@ -49,11 +71,33 @@ CHILD_OBJECT_TYPES = [
|
|||||||
'BusinessProcess', 'Task', 'IntegrationService',
|
'BusinessProcess', 'Task', 'IntegrationService',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
|
||||||
|
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
|
||||||
|
MODULE_KINDS_BY_TYPE = {
|
||||||
|
"CommonModule": ["Module"], "HTTPService": ["Module"], "WebService": ["Module"],
|
||||||
|
"Catalog": ["ObjectModule", "ManagerModule"], "Document": ["ObjectModule", "ManagerModule"],
|
||||||
|
"Report": ["ObjectModule", "ManagerModule"], "DataProcessor": ["ObjectModule", "ManagerModule"],
|
||||||
|
"ExchangePlan": ["ObjectModule", "ManagerModule"],
|
||||||
|
"ChartOfCharacteristicTypes": ["ObjectModule", "ManagerModule"],
|
||||||
|
"ChartOfAccounts": ["ObjectModule", "ManagerModule"],
|
||||||
|
"ChartOfCalculationTypes": ["ObjectModule", "ManagerModule"],
|
||||||
|
"BusinessProcess": ["ObjectModule", "ManagerModule"], "Task": ["ObjectModule", "ManagerModule"],
|
||||||
|
"InformationRegister": ["RecordSetModule", "ManagerModule"],
|
||||||
|
"AccumulationRegister": ["RecordSetModule", "ManagerModule"],
|
||||||
|
"AccountingRegister": ["RecordSetModule", "ManagerModule"],
|
||||||
|
"CalculationRegister": ["RecordSetModule", "ManagerModule"],
|
||||||
|
"Sequence": ["RecordSetModule", "ManagerModule"],
|
||||||
|
"Constant": ["ValueManagerModule", "ManagerModule"],
|
||||||
|
"Enum": ["ManagerModule"], "DocumentJournal": ["ManagerModule"],
|
||||||
|
"FilterCriterion": ["ManagerModule"],
|
||||||
|
}
|
||||||
|
|
||||||
# Type -> directory mapping
|
# Type -> directory mapping
|
||||||
CHILD_TYPE_DIR_MAP = {
|
CHILD_TYPE_DIR_MAP = {
|
||||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||||
|
'Bot': 'Bots', 'PaletteColor': 'PaletteColors',
|
||||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||||
@@ -73,6 +117,50 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
'IntegrationService': 'IntegrationServices',
|
'IntegrationService': 'IntegrationServices',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||||
|
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||||
|
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||||
|
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||||
|
GENERATED_TYPE_CATEGORIES = {
|
||||||
|
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Enum': ['Ref', 'Manager', 'List'],
|
||||||
|
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
|
||||||
|
'Report': ['Object', 'Manager'],
|
||||||
|
'DataProcessor': ['Object', 'Manager'],
|
||||||
|
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
|
||||||
|
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
|
||||||
|
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
|
||||||
|
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
|
||||||
|
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
|
||||||
|
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
|
||||||
|
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
|
||||||
|
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
|
||||||
|
'DocumentJournal': ['Selection', 'List', 'Manager'],
|
||||||
|
'Sequence': ['Record', 'Manager', 'RecordSet'],
|
||||||
|
'FilterCriterion': ['Manager', 'List'],
|
||||||
|
'SettingsStorage': ['Manager'],
|
||||||
|
'IntegrationService': ['Manager'],
|
||||||
|
'WSReference': ['Manager'],
|
||||||
|
'DefinedType': ['DefinedType'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||||
|
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||||
|
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
|
||||||
|
MAIN_ATTR_RE = re.compile(
|
||||||
|
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
|
||||||
|
|
||||||
|
STANDARD_OBJECT_FIELDS = {
|
||||||
|
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
|
||||||
|
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
|
||||||
|
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
|
||||||
|
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
|
||||||
|
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
|
||||||
|
}
|
||||||
|
|
||||||
# Valid enum values for extension properties
|
# Valid enum values for extension properties
|
||||||
VALID_ENUM_VALUES = {
|
VALID_ENUM_VALUES = {
|
||||||
'ConfigurationExtensionCompatibilityMode': [
|
'ConfigurationExtensionCompatibilityMode': [
|
||||||
@@ -94,6 +182,20 @@ VALID_ENUM_VALUES = {
|
|||||||
|
|
||||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
class Reporter:
|
class Reporter:
|
||||||
def __init__(self, max_errors, detailed=False):
|
def __init__(self, max_errors, detailed=False):
|
||||||
@@ -154,11 +256,15 @@ def main():
|
|||||||
parser.add_argument('-Detailed', action='store_true')
|
parser.add_argument('-Detailed', action='store_true')
|
||||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||||
args = parser.parse_args()
|
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||||
|
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||||
|
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
|
||||||
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
extension_path = args.ExtensionPath
|
extension_path = args.ExtensionPath
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
out_file = args.OutFile
|
out_file = args.OutFile
|
||||||
|
config_path_arg = args.ConfigPath
|
||||||
|
|
||||||
# --- Resolve path ---
|
# --- Resolve path ---
|
||||||
if not os.path.isabs(extension_path):
|
if not os.path.isabs(extension_path):
|
||||||
@@ -214,11 +320,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get('version', '')
|
version = root.get('version', '')
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
r.warn('1. Missing version attribute on MetaDataObject')
|
r.warn('1. Missing version attribute on MetaDataObject')
|
||||||
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'):
|
elif version_rank == 0:
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||||
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||||
|
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||||
|
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
|
||||||
# Must have Configuration child
|
# Must have Configuration child
|
||||||
cfg_node = None
|
cfg_node = None
|
||||||
@@ -537,6 +649,7 @@ def main():
|
|||||||
MD = NS['md']
|
MD = NS['md']
|
||||||
XR = NS['xr']
|
XR = NS['xr']
|
||||||
enum_values_index = {}
|
enum_values_index = {}
|
||||||
|
borrowed_ts_index = {}
|
||||||
form_list = []
|
form_list = []
|
||||||
|
|
||||||
def is_borrowed_sub_item(sub_item):
|
def is_borrowed_sub_item(sub_item):
|
||||||
@@ -636,6 +749,23 @@ def main():
|
|||||||
else:
|
else:
|
||||||
borrowed_ok_count += 1
|
borrowed_ok_count += 1
|
||||||
|
|
||||||
|
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||||
|
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||||
|
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
|
||||||
|
if expected_cats:
|
||||||
|
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
|
||||||
|
found_cats = set()
|
||||||
|
if obj_info is not None:
|
||||||
|
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
|
||||||
|
cat = gt.get('category')
|
||||||
|
if cat:
|
||||||
|
found_cats.add(cat)
|
||||||
|
missing_cats = [c for c in expected_cats if c not in found_cats]
|
||||||
|
if missing_cats:
|
||||||
|
word = 'category' if len(missing_cats) == 1 else 'categories'
|
||||||
|
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
|
||||||
|
check9_ok = False
|
||||||
|
|
||||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||||
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
||||||
if obj_child_objects is not None:
|
if obj_child_objects is not None:
|
||||||
@@ -663,6 +793,9 @@ def main():
|
|||||||
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
||||||
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||||
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
||||||
|
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||||
|
if ts_name_el is not None and ts_name_el.text:
|
||||||
|
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
|
||||||
if ts_info is None:
|
if ts_info is None:
|
||||||
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
||||||
check10_ok = False
|
check10_ok = False
|
||||||
@@ -855,6 +988,29 @@ def main():
|
|||||||
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
||||||
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
||||||
|
|
||||||
|
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||||
|
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||||
|
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||||
|
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||||
|
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||||
|
# поэтому ошибка.
|
||||||
|
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||||
|
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
|
||||||
|
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
|
||||||
|
root_match = MAIN_ATTR_RE.search(raw)
|
||||||
|
root_name = root_match.group(1) if root_match else ""
|
||||||
|
ac_tables = set()
|
||||||
|
if root_name:
|
||||||
|
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
|
||||||
|
if ac_tables:
|
||||||
|
owner_key = ctx.split('.Form.')[0]
|
||||||
|
owner_ts = borrowed_ts_index.get(owner_key, {})
|
||||||
|
for tbl_name in sorted(ac_tables):
|
||||||
|
dep_check_count += 1
|
||||||
|
if tbl_name not in owner_ts:
|
||||||
|
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
|
||||||
|
check12_ok = False
|
||||||
|
|
||||||
for mi in missing_items:
|
for mi in missing_items:
|
||||||
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
||||||
check12_ok = False
|
check12_ok = False
|
||||||
@@ -886,6 +1042,228 @@ def main():
|
|||||||
elif check13_ok:
|
elif check13_ok:
|
||||||
r.ok('13. TypeLink: clean')
|
r.ok('13. TypeLink: clean')
|
||||||
|
|
||||||
|
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||||
|
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||||
|
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||||
|
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
|
||||||
|
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||||
|
if not r.stopped and borrowed_forms_with_tree:
|
||||||
|
if not config_path_arg:
|
||||||
|
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
|
||||||
|
else:
|
||||||
|
cfg_root = config_path_arg
|
||||||
|
if not os.path.isabs(cfg_root):
|
||||||
|
cfg_root = os.path.join(os.getcwd(), cfg_root)
|
||||||
|
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
|
||||||
|
cfg_root = os.path.dirname(cfg_root)
|
||||||
|
|
||||||
|
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
|
||||||
|
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
|
||||||
|
else:
|
||||||
|
check14_ok = True
|
||||||
|
path_check_count = 0
|
||||||
|
|
||||||
|
for bf in borrowed_forms_with_tree:
|
||||||
|
raw = bf['RawText']
|
||||||
|
ctx = bf['Context']
|
||||||
|
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||||
|
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||||
|
root_match14 = MAIN_ATTR_RE.search(raw)
|
||||||
|
if root_match14 is None:
|
||||||
|
continue
|
||||||
|
root_name14 = root_match14.group(1)
|
||||||
|
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||||
|
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||||
|
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||||
|
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||||
|
if '>cfg:DynamicList<' in root_match14.group(0):
|
||||||
|
continue
|
||||||
|
owner_key = ctx.split('.Form.')[0]
|
||||||
|
owner_parts = owner_key.split('.', 1)
|
||||||
|
if len(owner_parts) < 2:
|
||||||
|
continue
|
||||||
|
owner_type, owner_name = owner_parts
|
||||||
|
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
|
||||||
|
if not owner_dir:
|
||||||
|
continue
|
||||||
|
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
|
||||||
|
if not os.path.isfile(src_obj_file):
|
||||||
|
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||||
|
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||||
|
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||||
|
src_names = set()
|
||||||
|
src_ts_columns = {}
|
||||||
|
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
|
||||||
|
src_obj_el = None
|
||||||
|
for c in src_tree.getroot():
|
||||||
|
if isinstance(c.tag, str):
|
||||||
|
src_obj_el = c
|
||||||
|
break
|
||||||
|
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
|
||||||
|
if src_child_objects is not None:
|
||||||
|
for sub in src_child_objects:
|
||||||
|
if not isinstance(sub.tag, str):
|
||||||
|
continue
|
||||||
|
sub_ln = etree.QName(sub.tag).localname
|
||||||
|
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
|
||||||
|
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||||
|
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
|
||||||
|
continue
|
||||||
|
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||||
|
if name_el is None or not name_el.text:
|
||||||
|
continue
|
||||||
|
sub_name = name_el.text.strip()
|
||||||
|
src_names.add(sub_name)
|
||||||
|
if sub_ln != 'TabularSection':
|
||||||
|
continue
|
||||||
|
cols = set()
|
||||||
|
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
|
||||||
|
if col_name.text:
|
||||||
|
cols.add(col_name.text.strip())
|
||||||
|
src_ts_columns[sub_name] = cols
|
||||||
|
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||||
|
root_pat14 = re.escape(root_name14)
|
||||||
|
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
|
||||||
|
tbl = acm.group(1)
|
||||||
|
cols = src_ts_columns.setdefault(tbl, set())
|
||||||
|
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
|
||||||
|
cols.add(cm.group(1))
|
||||||
|
|
||||||
|
bad_paths = {}
|
||||||
|
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
|
||||||
|
segments = m.group(1).split('.')
|
||||||
|
seg0 = segments[0]
|
||||||
|
path_check_count += 1
|
||||||
|
if seg0 in STANDARD_OBJECT_FIELDS:
|
||||||
|
continue
|
||||||
|
if seg0 not in src_names:
|
||||||
|
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
|
||||||
|
continue
|
||||||
|
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||||
|
# он ведёт в чужой объект, и это уже другая проверка.
|
||||||
|
if len(segments) < 2 or seg0 not in src_ts_columns:
|
||||||
|
continue
|
||||||
|
seg1 = segments[1]
|
||||||
|
if seg1 in STANDARD_OBJECT_FIELDS:
|
||||||
|
continue
|
||||||
|
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||||
|
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
|
||||||
|
continue
|
||||||
|
if seg1 not in src_ts_columns[seg0]:
|
||||||
|
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
|
||||||
|
|
||||||
|
for bad in sorted(bad_paths):
|
||||||
|
r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}")
|
||||||
|
check14_ok = False
|
||||||
|
|
||||||
|
if check14_ok:
|
||||||
|
r.ok(f'14. Object paths vs source config: {path_check_count} checked')
|
||||||
|
|
||||||
|
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
|
||||||
|
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
|
||||||
|
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
|
||||||
|
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
|
||||||
|
default_role_nodes = cfg_node.findall('md:Properties/md:DefaultRoles/xr:Item', NS)
|
||||||
|
if default_role_nodes:
|
||||||
|
adopted_cache = {}
|
||||||
|
|
||||||
|
def is_object_adopted(type_name, obj_name):
|
||||||
|
key = f"{type_name}.{obj_name}"
|
||||||
|
if key in adopted_cache:
|
||||||
|
return adopted_cache[key]
|
||||||
|
adopted_cache[key] = False
|
||||||
|
dir_name = CHILD_TYPE_DIR_MAP.get(type_name)
|
||||||
|
if dir_name:
|
||||||
|
obj_path = os.path.join(config_dir, dir_name, obj_name + '.xml')
|
||||||
|
if os.path.isfile(obj_path):
|
||||||
|
try:
|
||||||
|
obj_root = etree.parse(obj_path).getroot()
|
||||||
|
ob = obj_root.find(f'md:{type_name}/md:Properties/md:ObjectBelonging', NS)
|
||||||
|
if ob is not None and (ob.text or '') == 'Adopted':
|
||||||
|
adopted_cache[key] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return adopted_cache[key]
|
||||||
|
|
||||||
|
check15_ok = True
|
||||||
|
check15_count = 0
|
||||||
|
roles_ns = {'r': 'http://v8.1c.ru/8.2/roles'}
|
||||||
|
for rn in default_role_nodes:
|
||||||
|
m = re.match(r'^Role\.(.+)$', rn.text or '')
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
def_role_name = m.group(1)
|
||||||
|
rights_path = os.path.join(config_dir, 'Roles', def_role_name, 'Ext', 'Rights.xml')
|
||||||
|
if not os.path.isfile(rights_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rights_root = etree.parse(rights_path).getroot()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for name_node in rights_root.findall('r:object/r:name', roles_ns):
|
||||||
|
full_name = name_node.text or ''
|
||||||
|
segs = full_name.split('.')
|
||||||
|
# Configuration.* — права самого расширения, не объект; заимствования там нет.
|
||||||
|
if len(segs) < 2 or segs[0] == 'Configuration':
|
||||||
|
continue
|
||||||
|
check15_count += 1
|
||||||
|
if is_object_adopted(segs[0], segs[1]):
|
||||||
|
r.error(f"15. Роль '{def_role_name}' входит в DefaultRoles и даёт права на заимствованный "
|
||||||
|
f"{segs[0]}.{segs[1]} ({full_name}): платформа это запрещает. "
|
||||||
|
"Вынесите такие права в отдельную роль вне DefaultRoles.")
|
||||||
|
check15_ok = False
|
||||||
|
if check15_ok and check15_count > 0:
|
||||||
|
r.ok(f'15. Основные роли: прав на заимствованные объекты нет ({check15_count} checked)')
|
||||||
|
|
||||||
|
if r.stopped:
|
||||||
|
r.finalize(out_file)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
|
||||||
|
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
|
||||||
|
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
|
||||||
|
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на
|
||||||
|
# стенде), но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
|
||||||
|
if version_rank >= 219 and child_obj_node is not None:
|
||||||
|
state_issues = []
|
||||||
|
state_checked = 0
|
||||||
|
for child in child_obj_node:
|
||||||
|
if not isinstance(child.tag, str):
|
||||||
|
continue
|
||||||
|
type_name = etree.QName(child.tag).localname
|
||||||
|
if type_name not in MODULE_KINDS_BY_TYPE or type_name not in CHILD_TYPE_DIR_MAP:
|
||||||
|
continue
|
||||||
|
obj_name_val = (child.text or '').strip()
|
||||||
|
if not obj_name_val:
|
||||||
|
continue
|
||||||
|
type_dir = os.path.join(config_dir, CHILD_TYPE_DIR_MAP[type_name])
|
||||||
|
obj_file = os.path.join(type_dir, f'{obj_name_val}.xml')
|
||||||
|
if not os.path.isfile(obj_file):
|
||||||
|
continue
|
||||||
|
with open(obj_file, 'r', encoding='utf-8-sig') as f:
|
||||||
|
obj_text = f.read()
|
||||||
|
if '<ObjectBelonging>Adopted</ObjectBelonging>' not in obj_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for kind in MODULE_KINDS_BY_TYPE[type_name]:
|
||||||
|
state_checked += 1
|
||||||
|
has_file = os.path.isfile(os.path.join(type_dir, obj_name_val, 'Ext', f'{kind}.bsl'))
|
||||||
|
has_flag = f'<xr:Property>{kind}</xr:Property>' in obj_text
|
||||||
|
if has_file and not has_flag:
|
||||||
|
state_issues.append(f'{type_name}.{obj_name_val} — есть {kind}.bsl, но нет <xr:PropertyState> для {kind}')
|
||||||
|
elif has_flag and not has_file:
|
||||||
|
state_issues.append(f'{type_name}.{obj_name_val} — есть <xr:PropertyState> для {kind}, но нет {kind}.bsl')
|
||||||
|
|
||||||
|
if state_checked > 0:
|
||||||
|
if not state_issues:
|
||||||
|
r.ok(f'16. Модули заимствованных объектов: пометки расширенных свойств согласованы ({state_checked})')
|
||||||
|
else:
|
||||||
|
for issue in state_issues:
|
||||||
|
r.warn(f'16. {issue}')
|
||||||
|
|
||||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||||
ctrl_count = 0
|
ctrl_count = 0
|
||||||
for dp, _dn, files in os.walk(config_dir):
|
for dp, _dn, files in os.walk(config_dir):
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: db-cfe-admin
|
||||||
|
description: Управление расширениями конфигурации в информационной базе 1С. Используй когда нужно узнать какие расширения подключены к базе, выполнить проверку применимости или синтаксическую проверку, изменить безопасный режим или активность, удалить расширение из базы
|
||||||
|
argument-hint: <list|check|set-properties|delete> [database] [-Name <Имя>]
|
||||||
|
allowed-tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Glob
|
||||||
|
- AskUserQuestion
|
||||||
|
---
|
||||||
|
|
||||||
|
# /db-cfe-admin — Управление расширениями конфигурации
|
||||||
|
|
||||||
|
Расширения **на стороне базы**: состав и свойства подключения, проверки, удаление.
|
||||||
|
Про исходники расширения — другие навыки, см. «Смежное».
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
/db-cfe-admin list [database]
|
||||||
|
/db-cfe-admin check [database] [-Name Расш1]
|
||||||
|
/db-cfe-admin set-properties [database] -Name Расш1 -SafeMode off
|
||||||
|
/db-cfe-admin delete [database] -Name Расш1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Параметры подключения
|
||||||
|
|
||||||
|
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||||
|
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||||
|
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||||
|
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||||
|
4. Если ветка не совпала — используй `default`
|
||||||
|
|
||||||
|
Если `v8path` не задан — скрипт сам попытается определить платформу.
|
||||||
|
|
||||||
|
## Команда
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-cfe-admin.ps1" -Command <команда> <параметры>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Общие параметры
|
||||||
|
|
||||||
|
| Параметр | Обязательный | Описание |
|
||||||
|
|----------|:------------:|----------|
|
||||||
|
| `-Command <команда>` | да | `list` / `check` / `set-properties` / `delete` |
|
||||||
|
| `-V8Path <путь>` | нет | Каталог bin платформы или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||||
|
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||||
|
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||||
|
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||||
|
| `-UserName <имя>` | нет | Пользователь базы |
|
||||||
|
| `-Password <пароль>` | нет | Пароль пользователя |
|
||||||
|
| `-Name <имя>` | усл. | Расширение. Обязателен для `set-properties`; в `delete` — вместо `-All`. Без него `list` и `check` работают по всем расширениям |
|
||||||
|
| `-All` | усл. | Только для `delete`: удалить все расширения. Вместо `-Name`, а не вместе с ним |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
### Параметры `check`
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `-Checks <список>` | `apply` — применимость расширения, `modules` — синтаксическая проверка, `config` — проверки конфигурации (целостность, ссылки, неиспользуемые процедуры и обработчики). Через запятую, по умолчанию `apply,modules` |
|
||||||
|
| `-Context <список>` | Контексты синтаксической проверки: `ThinClient`, `WebClient`, `Server`, `ExternalConnection`, `ThickClientManagedApplication`, `ThickClientOrdinaryApplication`, `MobileClient` и др. Через запятую, по умолчанию `ThinClient,Server` |
|
||||||
|
|
||||||
|
Применимость и синтаксис проверяют разное и друг друга не заменяют. Проверяется только
|
||||||
|
расширение: ошибки самой конфигурации сюда не попадают.
|
||||||
|
|
||||||
|
### Свойства `set-properties`
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `-SafeMode <on/off>` | Безопасный режим |
|
||||||
|
| `-Active <on/off>` | Активность расширения |
|
||||||
|
| `-UnsafeActionProtection <on/off>` | Защита от опасных действий |
|
||||||
|
| `-UsedInDistributedInfobase <on/off>` | Использование в распределённой ИБ |
|
||||||
|
| `-Scope <область>` | `infobase` / `data-separation` |
|
||||||
|
| `-SecurityProfile <имя>` | Профиль безопасности |
|
||||||
|
|
||||||
|
Передавай только то, что меняешь: не указанное свойство остаётся как было. Расширение, впервые
|
||||||
|
попавшее в базу загрузкой, создаётся с включённым безопасным режимом, а в нём расширение модуля не
|
||||||
|
применяется.
|
||||||
|
|
||||||
|
Свойствами управляет `ibcmd` — он есть не в каждой установке платформы и работает с файловой базой;
|
||||||
|
остальные команды работают всегда.
|
||||||
|
|
||||||
|
## Смежное
|
||||||
|
|
||||||
|
| Задача | Навык |
|
||||||
|
|--------|-------|
|
||||||
|
| Создать расширение, заимствовать объекты, перехватить метод | `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`, `/cfe-validate` |
|
||||||
|
| Загрузить исходники расширения в базу | `/db-load-xml -Extension` (из коммита Git — `/db-load-git`) |
|
||||||
|
| Загрузить готовый `.cfe` | `/db-load-cf -Extension` |
|
||||||
|
| Выгрузить расширение из базы | `/db-dump-xml -Extension`, `/db-dump-cf -Extension` |
|
||||||
|
| Обновить конфигурацию базы после загрузки | `/db-update -Extension` |
|
||||||
|
| Проверить дрейф контролируемых методов по исходникам | `/cfe-patch-method -Check` |
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Что подключено к базе
|
||||||
|
... -Command list -InfoBasePath "C:\Bases\MyDB"
|
||||||
|
|
||||||
|
# Проверить расширение
|
||||||
|
... -Command check -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
|
||||||
|
|
||||||
|
# Синтаксическая проверка в контексте веб-клиента
|
||||||
|
... -Command check -InfoBasePath "C:\Bases\MyDB" -Checks modules -Context WebClient,Server
|
||||||
|
|
||||||
|
# Снять безопасный режим
|
||||||
|
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -SafeMode off
|
||||||
|
|
||||||
|
# Отключить, не удаляя
|
||||||
|
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -Active off
|
||||||
|
|
||||||
|
# Убрать расширение из базы
|
||||||
|
... -Command delete -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
|
||||||
|
```
|
||||||
@@ -0,0 +1,992 @@
|
|||||||
|
# db-cfe-admin v1.0 — Configuration extensions in a 1C infobase: list, check, properties, delete
|
||||||
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Расширения конфигурации в информационной базе 1С
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
list — что за расширения в базе и в каком они состоянии
|
||||||
|
check — применимость и синтаксический контроль
|
||||||
|
set-properties — активность, безопасный режим, защита от опасных действий и прочие свойства
|
||||||
|
delete — удаление расширения из базы
|
||||||
|
|
||||||
|
.PARAMETER Command
|
||||||
|
list | check | set-properties | delete
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\db-cfe-admin.ps1 -Command list -InfoBasePath "C:\Bases\MyDB"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\db-cfe-admin.ps1 -Command check -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\db-cfe-admin.ps1 -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "МоёРасширение" -SafeMode "-"
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
|
param(
|
||||||
|
# Не Mandatory: обязательный параметр PowerShell запрашивает интерактивно, а в пакетном
|
||||||
|
# запуске это зависание. Пустое значение проверяем сами.
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Command,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$V8Path,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$InfoBasePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$InfoBaseServer,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$InfoBaseRef,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$UserName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Password,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Name,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[switch]$All,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Checks,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$Context,
|
||||||
|
|
||||||
|
# Тристабильные флаги: on включить, off выключить, не указан — не трогать.
|
||||||
|
# Значение "-" через powershell.exe -File парсер съедает молча (проверено), поэтому
|
||||||
|
# каноническая форма словесная; "+"/"-" принимаются, но в инструкции не значатся.
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||||
|
[string]$SafeMode,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||||
|
[string]$Active,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||||
|
[string]$UnsafeActionProtection,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||||
|
[string]$UsedInDistributedInfobase,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[ValidateSet("infobase", "data-separation")]
|
||||||
|
[string]$Scope,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$SecurityProfile,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryUser,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPassword,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# Общий блок группы db-*: реквизиты хранилища, дополнительные аргументы, запуск платформы.
|
||||||
|
# Копии держит одинаковыми tests/skills/check-inline-drift.mjs — правку вносить в навык-эталон.
|
||||||
|
$Extension = $Name
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .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 {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
|
# --- Resolve V8Path ---
|
||||||
|
function Find-ProjectV8Path {
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.v8path) { return [string]$j.v8path }
|
||||||
|
} catch {}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $V8Path) {
|
||||||
|
$V8Path = Find-ProjectV8Path
|
||||||
|
}
|
||||||
|
if (-not $V8Path) {
|
||||||
|
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($found) {
|
||||||
|
$V8Path = $found.FullName
|
||||||
|
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path $V8Path -PathType Container) {
|
||||||
|
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $V8Path)) {
|
||||||
|
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
|
function ConvertFrom-PlatformBytes {
|
||||||
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
|
# one of them outright mangles Cyrillic.
|
||||||
|
param([byte[]]$Bytes)
|
||||||
|
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||||
|
try {
|
||||||
|
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||||
|
return $strict.GetString($Bytes)
|
||||||
|
} catch {
|
||||||
|
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-PlatformProcess {
|
||||||
|
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||||
|
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||||
|
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||||
|
# Returns @{ Output; ExitCode }.
|
||||||
|
#
|
||||||
|
# Quoting differs by engine, so the caller says which it built:
|
||||||
|
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||||
|
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||||
|
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||||
|
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||||
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$psi.FileName = $Exe
|
||||||
|
$psi.Arguments = if ($PreQuoted) {
|
||||||
|
$ProcArgs -join ' '
|
||||||
|
} else {
|
||||||
|
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||||
|
}
|
||||||
|
$psi.UseShellExecute = $false
|
||||||
|
$psi.CreateNoWindow = $true
|
||||||
|
$psi.RedirectStandardInput = $true
|
||||||
|
$psi.RedirectStandardOutput = $true
|
||||||
|
$psi.RedirectStandardError = $true
|
||||||
|
$p = [System.Diagnostics.Process]::Start($psi)
|
||||||
|
$p.StandardInput.Close()
|
||||||
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
|
# as soon as the other one fills its pipe buffer.
|
||||||
|
$errMs = New-Object System.IO.MemoryStream
|
||||||
|
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||||
|
$outMs = New-Object System.IO.MemoryStream
|
||||||
|
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||||
|
$errTask.Wait()
|
||||||
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
|
if ($err) { $out += $err }
|
||||||
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Утилиты платформы: нужны обе, выбор по команде ---
|
||||||
|
# -V8Path указывает на каталог bin либо на любой из двух исполняемых файлов; второй берётся соседом.
|
||||||
|
$binDir = Split-Path $V8Path -Parent
|
||||||
|
$exeLeaf = Split-Path $V8Path -Leaf
|
||||||
|
# Расширение файла сохраняем: на Windows это .exe, на *nix его нет, в тестах — .cmd/.sh.
|
||||||
|
$exeSuffix = [System.IO.Path]::GetExtension($V8Path)
|
||||||
|
if ($exeLeaf -match '^ibcmd') {
|
||||||
|
$ibcmdExe = $V8Path
|
||||||
|
$v8Exe = Join-Path $binDir ("1cv8" + $exeSuffix)
|
||||||
|
} else {
|
||||||
|
$v8Exe = $V8Path
|
||||||
|
$ibcmdExe = Join-Path $binDir ("ibcmd" + $exeSuffix)
|
||||||
|
}
|
||||||
|
$hasV8 = Test-Path $v8Exe
|
||||||
|
$hasIbcmd = Test-Path $ibcmdExe
|
||||||
|
|
||||||
|
# --- Разбор и проверка команды ---
|
||||||
|
$knownCommands = @('list', 'check', 'set-properties', 'delete')
|
||||||
|
$cmd = if ($Command) { $Command.Trim().ToLower() } else { '' }
|
||||||
|
if (-not $cmd) {
|
||||||
|
Write-Host "Error: specify a command: $($knownCommands -join ' | ')" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($knownCommands -notcontains $cmd) {
|
||||||
|
Write-Host "Error: unknown command '$Command' (expected: $($knownCommands -join ' | '))" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Пустое имя платформа трактует разрушительно: /DeleteCfg -Extension "" удаляет первое расширение
|
||||||
|
# из списка и рапортует успех. Поэтому пустое значение не доходит до платформы ни в одной команде.
|
||||||
|
if ($PSBoundParameters.ContainsKey('Name') -and [string]::IsNullOrWhiteSpace($Name)) {
|
||||||
|
Write-Host "Error: -Name is empty; omit it to address all extensions, or pass a name" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$hasName = -not [string]::IsNullOrWhiteSpace($Name)
|
||||||
|
if ($hasName) { $Name = $Name.Trim() }
|
||||||
|
|
||||||
|
if ($All -and $cmd -ne 'delete') {
|
||||||
|
Write-Host "Error: -All applies to delete only (list and check address all extensions when -Name is omitted)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($cmd -eq 'delete') {
|
||||||
|
if ($hasName -and $All) {
|
||||||
|
Write-Host "Error: -Name and -All are mutually exclusive - pass one or the other" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not $hasName -and -not $All) {
|
||||||
|
Write-Host "Error: specify -Name <extension> or -All (an omitted name never means all)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($cmd -eq 'set-properties' -and -not $hasName) {
|
||||||
|
Write-Host "Error: set-properties needs -Name <extension>" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Проверки (-Checks) и контексты (-Context) ---
|
||||||
|
$knownChecks = @('apply', 'modules', 'config')
|
||||||
|
$checkList = @()
|
||||||
|
if ($Checks) {
|
||||||
|
$checkList = @($Checks -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ })
|
||||||
|
foreach ($c in $checkList) {
|
||||||
|
if ($knownChecks -notcontains $c) {
|
||||||
|
Write-Host "Error: unknown check '$c' (expected: $($knownChecks -join ', '))" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($checkList.Count -eq 0) { $checkList = @('apply', 'modules') }
|
||||||
|
|
||||||
|
$knownContexts = @('ThinClient', 'WebClient', 'MobileClient', 'MobileClientStandalone', 'MobileAppClient',
|
||||||
|
'Server', 'MobileAppServer', 'ExternalConnection', 'ExternalConnectionServer',
|
||||||
|
'ThickClientManagedApplication', 'ThickClientServerManagedApplication',
|
||||||
|
'ThickClientOrdinaryApplication', 'ThickClientServerOrdinaryApplication')
|
||||||
|
$contextList = @()
|
||||||
|
if ($Context) {
|
||||||
|
foreach ($c in @($Context -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
|
||||||
|
$match = $knownContexts | Where-Object { $_.Equals($c, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1
|
||||||
|
if (-not $match) {
|
||||||
|
Write-Host "Error: unknown context '$c' (expected: $($knownContexts -join ', '))" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$contextList += $match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($Context -and $checkList.Count -gt 0 -and $checkList -notcontains 'modules') {
|
||||||
|
Write-Host "Error: -Context applies to the syntax check - add 'modules' to -Checks" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($contextList.Count -eq 0) { $contextList = @('ThinClient', 'Server') }
|
||||||
|
|
||||||
|
# --- Свойства для set-properties ---
|
||||||
|
$script:propRu = @{
|
||||||
|
'safe-mode' = 'безопасный режим'
|
||||||
|
'active' = 'активно'
|
||||||
|
'unsafe-action-protection' = 'защита от опасных действий'
|
||||||
|
'used-in-distributed-infobase' = 'используется в РИБ'
|
||||||
|
'scope' = 'область действия'
|
||||||
|
'security-profile-name' = 'профиль безопасности'
|
||||||
|
'purpose' = 'назначение'
|
||||||
|
'version' = 'версия'
|
||||||
|
}
|
||||||
|
function Get-PropRu {
|
||||||
|
param([string]$Key)
|
||||||
|
if ($script:propRu.ContainsKey($Key)) { return $script:propRu[$Key] }
|
||||||
|
return $Key
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-FlagValue {
|
||||||
|
param([string]$Value)
|
||||||
|
if (@('on', 'yes', '+') -contains $Value.ToLower()) { return 'yes' }
|
||||||
|
return 'no'
|
||||||
|
}
|
||||||
|
|
||||||
|
$propFlags = [ordered]@{}
|
||||||
|
if ($SafeMode) { $propFlags['safe-mode'] = (Convert-FlagValue $SafeMode) }
|
||||||
|
if ($Active) { $propFlags['active'] = (Convert-FlagValue $Active) }
|
||||||
|
if ($UnsafeActionProtection) { $propFlags['unsafe-action-protection'] = (Convert-FlagValue $UnsafeActionProtection) }
|
||||||
|
if ($UsedInDistributedInfobase) { $propFlags['used-in-distributed-infobase'] = (Convert-FlagValue $UsedInDistributedInfobase) }
|
||||||
|
if ($Scope) { $propFlags['scope'] = $Scope }
|
||||||
|
if ($PSBoundParameters.ContainsKey('SecurityProfile')) { $propFlags['security-profile-name'] = $SecurityProfile }
|
||||||
|
if ($cmd -eq 'set-properties' -and $propFlags.Count -eq 0) {
|
||||||
|
Write-Host "Error: set-properties needs at least one property (-SafeMode, -Active, -UnsafeActionProtection, -UsedInDistributedInfobase, -Scope, -SecurityProfile)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Соединение ---
|
||||||
|
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||||
|
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Дополнительные аргументы: у каждой утилиты свои ---
|
||||||
|
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||||
|
$v8Extra = @(Resolve-ExtraArgs '1cv8' $AdditionalV8Arguments @() $argHints)
|
||||||
|
$ibExtra = @(Resolve-ExtraArgs 'ibcmd' @() $AdditionalIbcmdArguments $argHints)
|
||||||
|
if ($AdditionalIbcmdArguments.Count -gt 0 -and @('check', 'delete') -contains $cmd) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments does not apply to '$cmd' - it runs the Designer only" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:repoSettings = Resolve-RepositorySettings
|
||||||
|
$baseLabel = if ($InfoBasePath) { $InfoBasePath } else { "$InfoBaseServer/$InfoBaseRef" }
|
||||||
|
|
||||||
|
# --- Запуск Конфигуратора: соединение, реквизиты хранилища и /Out навык держит сам ---
|
||||||
|
function Invoke-Designer {
|
||||||
|
param([string[]]$OpArgs)
|
||||||
|
if (-not $hasV8) {
|
||||||
|
Write-Host "Error: 1C executable not found at $v8Exe" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$tempDir = Join-Path $env:TEMP "db_cfe_admin_$(Get-Random)"
|
||||||
|
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||||
|
try {
|
||||||
|
$arguments = @("DESIGNER")
|
||||||
|
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||||
|
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||||
|
} else {
|
||||||
|
$arguments += "/F", "`"$InfoBasePath`""
|
||||||
|
}
|
||||||
|
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||||
|
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов.
|
||||||
|
$arguments += Get-RepositoryArgs $script:repoSettings
|
||||||
|
$arguments += $OpArgs
|
||||||
|
$outFile = Join-Path $tempDir "out.txt"
|
||||||
|
$arguments += "/Out", "`"$outFile`""
|
||||||
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $v8Extra
|
||||||
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments '1cv8') -join ' ') @($Password, $UserName, $script:repoSettings.Password))"
|
||||||
|
$res = Invoke-PlatformProcess $v8Exe $arguments -PreQuoted
|
||||||
|
$log = ''
|
||||||
|
if (Test-Path $outFile) {
|
||||||
|
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||||
|
if ($raw) { $log = $raw.Trim() }
|
||||||
|
}
|
||||||
|
return @{
|
||||||
|
ExitCode = $res.ExitCode
|
||||||
|
Log = $log
|
||||||
|
Output = $res.Output
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (Test-Path $tempDir) { Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Ibcmd {
|
||||||
|
param([string[]]$OpArgs)
|
||||||
|
$arguments = @($OpArgs)
|
||||||
|
$arguments += "--db-path=$InfoBasePath"
|
||||||
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
|
$arguments += $ibExtra
|
||||||
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments 'ibcmd') -join ' ') @($Password, $UserName))"
|
||||||
|
$res = Invoke-PlatformProcess $ibcmdExe $arguments
|
||||||
|
return @{ ExitCode = $res.ExitCode; Output = $res.Output }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-PlatformFailure {
|
||||||
|
# Единый разбор неуспеха: что запускали, чем ответила платформа.
|
||||||
|
param($Result, [string]$What)
|
||||||
|
Write-Host "Error: $What (code: $($Result.ExitCode))$(Get-ExitAnnotation $Result.ExitCode)" -ForegroundColor Red
|
||||||
|
if ($Result.Log) {
|
||||||
|
Write-Host "--- Log ---"
|
||||||
|
Write-Host $Result.Log
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
Write-PlatformOutput $Result.Output
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Свойства расширений: только ibcmd, и только для файловой базы ---
|
||||||
|
function Get-PropertiesUnavailableReason {
|
||||||
|
if (-not $InfoBasePath) { return "свойства читает ibcmd, а он подключается к файловой базе (--db-path)" }
|
||||||
|
if (-not $hasIbcmd) { return "рядом с 1cv8 нет ibcmd ($ibcmdExe) - эта установка платформы его не содержит" }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertFrom-IbcmdRecords {
|
||||||
|
# Вывод ibcmd: строки «ключ : значение», записи разделены пустой строкой.
|
||||||
|
param([string]$Text)
|
||||||
|
$records = @()
|
||||||
|
$cur = [ordered]@{}
|
||||||
|
foreach ($line in ($Text -split "`r?`n")) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
if ($cur.Count -gt 0) { $records += ,$cur; $cur = [ordered]@{} }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$idx = $line.IndexOf(':')
|
||||||
|
if ($idx -lt 0) { continue }
|
||||||
|
$key = $line.Substring(0, $idx).Trim()
|
||||||
|
$val = $line.Substring($idx + 1).Trim().Trim('"')
|
||||||
|
if ($key) { $cur[$key] = $val }
|
||||||
|
}
|
||||||
|
if ($cur.Count -gt 0) { $records += ,$cur }
|
||||||
|
return $records
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExtensionProperties {
|
||||||
|
# Хеш «имя расширения» -> запись свойств. Пустой, если ibcmd недоступен.
|
||||||
|
if (Get-PropertiesUnavailableReason) { return @{} }
|
||||||
|
$r = Invoke-Ibcmd @('infobase', 'config', 'extension', 'list')
|
||||||
|
if ($r.ExitCode -ne 0) { return @{} }
|
||||||
|
$map = @{}
|
||||||
|
foreach ($rec in (ConvertFrom-IbcmdRecords $r.Output)) {
|
||||||
|
if ($rec['name']) { $map[[string]$rec['name']] = $rec }
|
||||||
|
}
|
||||||
|
return $map
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExtensionNames {
|
||||||
|
# Имена расширений базы - Конфигуратором, чтобы работало и без ibcmd, и на серверной базе.
|
||||||
|
$r = Invoke-Designer @('/DumpDBCfgList', '-AllExtensions')
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-PlatformFailure $r "cannot list extensions"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return @($r.Log -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Человекочитаемые значения свойств ---
|
||||||
|
$script:flagRu = @{ 'yes' = 'да'; 'no' = 'нет' }
|
||||||
|
$script:scopeRu = @{ 'infobase' = 'Информационная база'; 'data-separation' = 'Область данных' }
|
||||||
|
$script:purposeRu = @{ 'customization' = 'Адаптация'; 'add-on' = 'Дополнение'; 'patch' = 'Исправление' }
|
||||||
|
|
||||||
|
function Format-PropValue {
|
||||||
|
param([string]$Key, $Value)
|
||||||
|
if ($null -eq $Value -or $Value -eq '') { return '' }
|
||||||
|
$v = [string]$Value
|
||||||
|
if ($Key -eq 'scope' -and $script:scopeRu.ContainsKey($v)) { return $script:scopeRu[$v] }
|
||||||
|
if ($Key -eq 'purpose' -and $script:purposeRu.ContainsKey($v)) { return $script:purposeRu[$v] }
|
||||||
|
if ($script:flagRu.ContainsKey($v)) { return $script:flagRu[$v] }
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PropCell {
|
||||||
|
# Значение свойства для таблицы: «—», когда свойства вообще не читались.
|
||||||
|
param($Record, [string]$Key)
|
||||||
|
if (-not $Record) { return '—' }
|
||||||
|
if ($Record[$Key]) { return (Format-PropValue $Key $Record[$Key]) }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Table {
|
||||||
|
param([string[]]$Headers, $Rows)
|
||||||
|
$widths = @()
|
||||||
|
for ($i = 0; $i -lt $Headers.Count; $i++) {
|
||||||
|
$w = $Headers[$i].Length
|
||||||
|
foreach ($row in $Rows) { if (([string]$row[$i]).Length -gt $w) { $w = ([string]$row[$i]).Length } }
|
||||||
|
$widths += $w
|
||||||
|
}
|
||||||
|
$line = ' '
|
||||||
|
for ($i = 0; $i -lt $Headers.Count; $i++) { $line += $Headers[$i].PadRight($widths[$i] + 2) }
|
||||||
|
Write-Host $line.TrimEnd()
|
||||||
|
foreach ($row in $Rows) {
|
||||||
|
$l = ' '
|
||||||
|
for ($i = 0; $i -lt $Headers.Count; $i++) { $l += ([string]$row[$i]).PadRight($widths[$i] + 2) }
|
||||||
|
Write-Host $l.TrimEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Команды
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if ($cmd -eq 'list') {
|
||||||
|
$names = @(Get-ExtensionNames)
|
||||||
|
if ($hasName) {
|
||||||
|
$names = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) })
|
||||||
|
if ($names.Count -eq 0) {
|
||||||
|
Write-Host "[РАСШИРЕНИЯ] $baseLabel (0)"
|
||||||
|
Write-Host " расширение '$Name' в базе не найдено"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "[РАСШИРЕНИЯ] $baseLabel ($($names.Count))"
|
||||||
|
if ($names.Count -eq 0) {
|
||||||
|
Write-Host " расширений нет"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$props = Get-ExtensionProperties
|
||||||
|
$reason = Get-PropertiesUnavailableReason
|
||||||
|
$rows = @()
|
||||||
|
foreach ($n in $names) {
|
||||||
|
$rec = $props[$n]
|
||||||
|
$rows += ,@($n,
|
||||||
|
(Get-PropCell $rec 'purpose'),
|
||||||
|
(Get-PropCell $rec 'active'),
|
||||||
|
(Get-PropCell $rec 'safe-mode'),
|
||||||
|
(Get-PropCell $rec 'unsafe-action-protection'),
|
||||||
|
(Get-PropCell $rec 'used-in-distributed-infobase'),
|
||||||
|
(Get-PropCell $rec 'scope'))
|
||||||
|
}
|
||||||
|
Write-Table @('Имя', 'Назначение', 'Активно', 'Безопасный режим', 'Защита', 'РИБ', 'Область') $rows
|
||||||
|
if ($reason) { Write-Host " свойства недоступны: $reason" }
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd -eq 'check') {
|
||||||
|
# Список расширений заранее не запрашиваем: платформа сама отвечает «расширение не найдено»,
|
||||||
|
# а лишний запуск конфигуратора стоит дороже, чем разница в формулировке.
|
||||||
|
$target = if ($hasName) { $Name } else { $null }
|
||||||
|
if ($target) {
|
||||||
|
Write-Host "[ПРОВЕРКА] $baseLabel · $target"
|
||||||
|
} else {
|
||||||
|
Write-Host "[ПРОВЕРКА] $baseLabel · все расширения"
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = 0
|
||||||
|
$done = 0
|
||||||
|
$rows = @()
|
||||||
|
|
||||||
|
if ($checkList -contains 'apply') {
|
||||||
|
$opArgs = @('/CheckCanApplyConfigurationExtensions')
|
||||||
|
if ($target) { $opArgs += '-Extension', "`"$target`"" }
|
||||||
|
$r = Invoke-Designer $opArgs
|
||||||
|
$done++
|
||||||
|
$logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' })
|
||||||
|
if ($r.ExitCode -eq 0) {
|
||||||
|
$rows += ,@{ Label = 'применимость'; Status = 'ОК'; Note = ''; Lines = @() }
|
||||||
|
} elseif ($r.ExitCode -eq 1) {
|
||||||
|
$failed++
|
||||||
|
$rows += ,@{ Label = 'применимость'; Status = 'ОШИБКА'; Note = ''; Lines = $logLines }
|
||||||
|
} else {
|
||||||
|
$failed++
|
||||||
|
$rows += ,@{ Label = 'применимость'; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# modules и config - одна и та же команда платформы с разным набором флагов, поэтому при
|
||||||
|
# запросе обеих делается ОДИН запуск. Без флагов контекста платформа рапортует «ошибок не
|
||||||
|
# обнаружено» на заведомо сломанном модуле - набор всегда явный.
|
||||||
|
$wantModules = $checkList -contains 'modules'
|
||||||
|
$wantConfig = $checkList -contains 'config'
|
||||||
|
if ($wantModules -or $wantConfig) {
|
||||||
|
$opArgs = @('/CheckConfig')
|
||||||
|
if ($wantModules) { foreach ($c in $contextList) { $opArgs += "-$c" } }
|
||||||
|
if ($wantConfig) {
|
||||||
|
$opArgs += '-ConfigLogIntegrity', '-IncorrectReferences', '-UnreferenceProcedures', '-HandlersExistence', '-EmptyHandlers'
|
||||||
|
}
|
||||||
|
if ($target) { $opArgs += '-Extension', "`"$target`"" } else { $opArgs += '-AllExtensions' }
|
||||||
|
$r = Invoke-Designer $opArgs
|
||||||
|
$label = if ($wantModules -and $wantConfig) { 'модули и конфигурация' } elseif ($wantModules) { 'модули' } else { 'конфигурация' }
|
||||||
|
$done++
|
||||||
|
$logLines = @($r.Log -split "`r?`n" | Where-Object { $_.Trim() -ne '' })
|
||||||
|
if ($r.ExitCode -eq 0) {
|
||||||
|
$note = if ($wantModules) { "($($contextList -join ', '))" } else { '' }
|
||||||
|
$rows += ,@{ Label = $label; Status = 'ОК'; Note = $note; Lines = @() }
|
||||||
|
} elseif ($r.ExitCode -eq 1 -or $r.ExitCode -eq 101) {
|
||||||
|
$failed++
|
||||||
|
$rows += ,@{ Label = $label; Status = 'ОШИБКА'; Note = ''; Lines = $logLines }
|
||||||
|
} else {
|
||||||
|
$failed++
|
||||||
|
$rows += ,@{ Label = $label; Status = 'СБОЙ'; Note = "код $($r.ExitCode)$(Get-ExitAnnotation $r.ExitCode)"; Lines = $logLines }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сообщения платформы печатаются построчно под своей проверкой: они называют расширение и
|
||||||
|
# место ошибки, и при нескольких расширениях склейка в одну строку нечитаема.
|
||||||
|
$w = 0
|
||||||
|
foreach ($row in $rows) { if ($row.Label.Length -gt $w) { $w = $row.Label.Length } }
|
||||||
|
foreach ($row in $rows) {
|
||||||
|
$l = ' ' + $row.Label.PadRight($w + 2) + $row.Status.PadRight(9)
|
||||||
|
if ($row.Note) { $l += $row.Note }
|
||||||
|
Write-Host $l.TrimEnd()
|
||||||
|
foreach ($line in $row.Lines) { Write-Host (" " + $line.Trim()) }
|
||||||
|
}
|
||||||
|
if ($failed -gt 0) {
|
||||||
|
Write-Host "Итог: провалено $failed из $done"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "Итог: пройдено $done из $done"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd -eq 'set-properties') {
|
||||||
|
$reason = Get-PropertiesUnavailableReason
|
||||||
|
if ($reason) {
|
||||||
|
Write-Host "Error: cannot set properties - $reason" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$before = Get-ExtensionProperties
|
||||||
|
if (-not $before.ContainsKey($Name)) {
|
||||||
|
Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$opArgs = @('infobase', 'config', 'extension', 'update', "--name=$Name")
|
||||||
|
foreach ($k in $propFlags.Keys) { $opArgs += "--$k=$($propFlags[$k])" }
|
||||||
|
$r = Invoke-Ibcmd $opArgs
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-Host "Error: cannot set properties (code: $($r.ExitCode))$(Get-ExitAnnotation $r.ExitCode)" -ForegroundColor Red
|
||||||
|
Write-PlatformOutput $r.Output
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Постусловие: состояние перечитывается, а не берётся из кода возврата.
|
||||||
|
$after = Get-ExtensionProperties
|
||||||
|
if (-not $after.ContainsKey($Name)) {
|
||||||
|
Write-Host "Error: extension '$Name' disappeared after the update" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "[СВОЙСТВА] $baseLabel · $Name"
|
||||||
|
$changed = 0
|
||||||
|
$stale = @()
|
||||||
|
foreach ($k in $propFlags.Keys) {
|
||||||
|
$was = Format-PropValue $k $before[$Name][$k]
|
||||||
|
$now = Format-PropValue $k $after[$Name][$k]
|
||||||
|
$want = Format-PropValue $k $propFlags[$k]
|
||||||
|
if ($was -ne $now) {
|
||||||
|
Write-Host (' ' + (Get-PropRu $k).PadRight(30) + "$was → $now")
|
||||||
|
$changed++
|
||||||
|
} elseif ($now -ne $want) {
|
||||||
|
$stale += "$(Get-PropRu $k): просили '$want', в базе осталось '$now'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($stale.Count -gt 0) {
|
||||||
|
foreach ($s in $stale) { Write-Host " $s" -ForegroundColor Yellow }
|
||||||
|
Write-Host "Итог: изменено $changed, не применено $($stale.Count)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($changed -eq 0) { Write-Host " свойства уже в этом состоянии" }
|
||||||
|
Write-Host "Итог: изменено $changed"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cmd -eq 'delete') {
|
||||||
|
$names = @(Get-ExtensionNames)
|
||||||
|
if ($names.Count -eq 0) {
|
||||||
|
Write-Host "[УДАЛЕНИЕ] $baseLabel"
|
||||||
|
Write-Host " расширений нет - удалять нечего"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$targets = @()
|
||||||
|
if ($hasName) {
|
||||||
|
$match = @($names | Where-Object { $_.Equals($Name, [System.StringComparison]::OrdinalIgnoreCase) })
|
||||||
|
if ($match.Count -eq 0) {
|
||||||
|
Write-Host "Error: extension '$Name' not found in the infobase" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$targets = $match
|
||||||
|
} else {
|
||||||
|
$targets = $names
|
||||||
|
}
|
||||||
|
Write-Host "[УДАЛЕНИЕ] $baseLabel (будет удалено: $($targets.Count))"
|
||||||
|
foreach ($t in $targets) {
|
||||||
|
# Имя непустое по построению: пустое отбито разбором параметров, список получен от платформы.
|
||||||
|
$r = Invoke-Designer @('/DeleteCfg', '-Extension', "`"$t`"")
|
||||||
|
if ($r.ExitCode -ne 0) {
|
||||||
|
Write-PlatformFailure $r "cannot delete extension '$t'"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " удалено: $t"
|
||||||
|
}
|
||||||
|
# Постусловие: список перечитывается - код возврата платформы сам по себе ничего не доказывает.
|
||||||
|
$rest = @(Get-ExtensionNames)
|
||||||
|
foreach ($t in $targets) {
|
||||||
|
if ($rest | Where-Object { $_.Equals($t, [System.StringComparison]::OrdinalIgnoreCase) }) {
|
||||||
|
Write-Host "Error: platform reported success, but '$t' is still in the infobase" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Write-Host "Итог: удалено $($targets.Count), осталось $($rest.Count)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
# db-create v1.10 — Create 1C information base
|
# db-create v1.14 — Create 1C information base
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-create v1.10 — Create 1C information base
|
# db-create v1.14 — Create 1C information base
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -270,7 +288,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -291,7 +309,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -310,11 +328,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -355,7 +393,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -379,15 +417,15 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate template ---
|
# --- Validate template ---
|
||||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
||||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
print(f"Error: template file not found: {args.UseTemplate}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
# --- ibcmd branch (file infobase only) ---
|
||||||
@@ -414,10 +452,9 @@ def main():
|
|||||||
print(
|
print(
|
||||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||||
"— information base was not created",
|
"— information base was not created",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error creating information base (code: {exit_code})")
|
||||||
print_platform_output(result)
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
@@ -474,10 +511,9 @@ def main():
|
|||||||
print(
|
print(
|
||||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||||
"— information base was not created",
|
"— information base was not created",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error creating information base (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
# db-dump-cf v1.16 — Dump 1C configuration to CF file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
# db-dump-cf v1.16 — Dump 1C configuration to CF file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -375,7 +413,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -400,10 +438,10 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
# --- Ensure output directory exists ---
|
||||||
@@ -414,7 +452,7 @@ def main():
|
|||||||
# --- ibcmd branch (file infobase only) ---
|
# --- ibcmd branch (file infobase only) ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if args.AllExtensions:
|
if args.AllExtensions:
|
||||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
@@ -437,9 +475,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -487,9 +525,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
# db-dump-dt v1.15 — Dump 1C information base to DT file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
# db-dump-dt v1.15 — Dump 1C information base to DT file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -373,7 +411,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -398,10 +436,10 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
# --- Ensure output directory exists ---
|
||||||
@@ -430,9 +468,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping information base (code: {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -474,9 +512,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping information base (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ allowed-tools:
|
|||||||
Если файла нет — предложи `/db-list add`.
|
Если файла нет — предложи `/db-list add`.
|
||||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||||
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
|
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
|
||||||
|
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
# db-dump-xml v1.21 — Dump 1C configuration to XML files
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
@@ -85,8 +85,10 @@ param(
|
|||||||
[string]$ConfigDir,
|
[string]$ConfigDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")]
|
# Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
|
||||||
[string]$Mode = "Changes",
|
# того как станет видно, перечислены ли объекты.
|
||||||
|
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
|
||||||
|
[string]$Mode = "",
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Objects,
|
[string]$Objects,
|
||||||
@@ -101,6 +103,18 @@ param(
|
|||||||
[ValidateSet("Hierarchical", "Plain")]
|
[ValidateSet("Hierarchical", "Plain")]
|
||||||
[string]$Format = "Hierarchical",
|
[string]$Format = "Hierarchical",
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$ObjectsFile,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryUser,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPassword,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string[]]$AdditionalV8Arguments = @(),
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
@@ -111,6 +125,90 @@ param(
|
|||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||||
|
function Find-V8Project([string]$startDir) {
|
||||||
|
$d = $startDir
|
||||||
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
|
$pj = Join-Path $d ".v8-project.json"
|
||||||
|
if (Test-Path $pj) { return $pj }
|
||||||
|
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||||
|
if ($parent -eq $d) { break }
|
||||||
|
$d = $parent
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Test-SamePath {
|
||||||
|
param([string]$A, [string]$B)
|
||||||
|
if (-not $A -or -not $B) { return $false }
|
||||||
|
try {
|
||||||
|
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||||
|
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||||
|
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
} catch { return $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-ProjectDatabase {
|
||||||
|
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||||
|
$pf = Find-V8Project (Get-Location).Path
|
||||||
|
if (-not $pf) { return $null }
|
||||||
|
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||||
|
if (-not $proj.databases) { return $null }
|
||||||
|
foreach ($db in $proj.databases) {
|
||||||
|
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||||
|
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||||
|
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||||
|
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RepositorySettings {
|
||||||
|
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||||
|
$dbRec = Find-ProjectDatabase
|
||||||
|
$rec = $null
|
||||||
|
if ($dbRec) {
|
||||||
|
if ($Extension) {
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
if ($dbRec.extensions) {
|
||||||
|
foreach ($ext in $dbRec.extensions) {
|
||||||
|
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
$rec = $ext.repository
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$rec = $dbRec.repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||||
|
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||||
|
return @{
|
||||||
|
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||||
|
User = $user
|
||||||
|
Password = $pwd
|
||||||
|
FromRegistry = [bool]($rec -and $rec.path)
|
||||||
|
DbRecord = $dbRec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RepositoryArgs {
|
||||||
|
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||||
|
param([hashtable]$Repo)
|
||||||
|
$a = @()
|
||||||
|
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||||
|
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||||
|
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||||
|
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||||
|
return $a
|
||||||
|
}
|
||||||
|
|
||||||
function Protect-Secrets {
|
function Protect-Secrets {
|
||||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
param([string]$Text, [string[]]$Secrets)
|
param([string]$Text, [string[]]$Secrets)
|
||||||
@@ -132,7 +230,7 @@ $script:IbcmdOwnedKeys = @(
|
|||||||
'--import', '--export', '--apply', '--force', '--create-database',
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
'--user', '--password'
|
'--user', '--password'
|
||||||
)
|
)
|
||||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
function Test-ArgKeyMatch {
|
function Test-ArgKeyMatch {
|
||||||
@@ -415,8 +513,33 @@ if ($engine -eq "ibcmd") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
# --- Validate Partial mode ---
|
||||||
|
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||||
|
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||||
|
if ($ObjectsFile) {
|
||||||
|
if (-not (Test-Path $ObjectsFile)) {
|
||||||
|
Write-Host "Error: -ObjectsFile not found: $ObjectsFile" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$fromFile = @([System.IO.File]::ReadAllLines($ObjectsFile, [System.Text.Encoding]::UTF8) |
|
||||||
|
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
|
||||||
|
$Objects = (@(@($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $fromFile) -join ',')
|
||||||
|
}
|
||||||
|
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||||
|
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||||
|
if ($Objects) {
|
||||||
|
if ($Mode -eq "UpdateInfo") {
|
||||||
|
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||||
|
Write-Host "Error: -Mode UpdateInfo does not take an object list — it only refreshes ConfigDumpInfo.xml" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Mode -eq "Full" -or $Mode -eq "Changes") {
|
||||||
|
Write-Host "[note] перечислены объекты — выгружаются только они; -Mode $Mode не применён" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
$Mode = "Partial"
|
||||||
|
}
|
||||||
|
if (-not $Mode) { $Mode = "Changes" }
|
||||||
if ($Mode -eq "Partial" -and -not $Objects) {
|
if ($Mode -eq "Partial" -and -not $Objects) {
|
||||||
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red
|
Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +609,11 @@ try {
|
|||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
$__repo = Resolve-RepositorySettings
|
||||||
|
$arguments += Get-RepositoryArgs $__repo
|
||||||
|
|
||||||
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
|
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
|
||||||
$arguments += "-Format", $Format
|
$arguments += "-Format", $Format
|
||||||
|
|
||||||
@@ -530,7 +658,7 @@ try {
|
|||||||
$arguments += $extraArgs
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $__v8.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
# db-dump-xml v1.21 — Dump 1C configuration to XML files
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -50,10 +72,95 @@ IBCMD_OWNED_KEYS = [
|
|||||||
"--import", "--export", "--apply", "--force", "--create-database",
|
"--import", "--export", "--apply", "--force", "--create-database",
|
||||||
"--user", "--password",
|
"--user", "--password",
|
||||||
]
|
]
|
||||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||||
|
def _sg_find_v8project(start_dir):
|
||||||
|
d = start_dir
|
||||||
|
for _ in range(20):
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
pj = os.path.join(d, ".v8-project.json")
|
||||||
|
if os.path.isfile(pj):
|
||||||
|
return pj
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
break
|
||||||
|
d = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def same_path(a, b):
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_database(args):
|
||||||
|
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||||
|
pf = _sg_find_v8project(os.getcwd())
|
||||||
|
if not pf:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(pf, encoding="utf-8-sig") as f:
|
||||||
|
proj = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for db in proj.get("databases") or []:
|
||||||
|
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||||
|
return db
|
||||||
|
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||||
|
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||||
|
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||||
|
return db
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repository_settings(args):
|
||||||
|
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||||
|
db_rec = find_project_database(args)
|
||||||
|
rec = None
|
||||||
|
if db_rec:
|
||||||
|
if args.Extension:
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
for ext in db_rec.get("extensions") or []:
|
||||||
|
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||||
|
rec = ext.get("repository")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
rec = db_rec.get("repository")
|
||||||
|
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||||
|
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||||
|
return {
|
||||||
|
"path": path.strip().strip('"') if path else None,
|
||||||
|
"user": user,
|
||||||
|
"password": pwd,
|
||||||
|
"from_registry": bool(rec and rec.get("path")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def repository_args(repo):
|
||||||
|
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||||
|
a = []
|
||||||
|
if not repo or not repo.get("path"):
|
||||||
|
return a
|
||||||
|
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||||
|
if repo.get("user"):
|
||||||
|
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||||
|
if repo.get("password"):
|
||||||
|
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
def arg_key_match(token, key):
|
def arg_key_match(token, key):
|
||||||
"""Token matches a key when it equals it, or starts with it and the next character
|
"""Token matches a key when it equals it, or starts with it and the next character
|
||||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||||
@@ -98,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +326,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +364,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +381,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +400,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +453,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -366,14 +489,18 @@ def main():
|
|||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
parser.add_argument("-UserName", default="", help="1C user name")
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
parser.add_argument("-Password", default="", help="1C user password")
|
||||||
|
parser.add_argument("-RepositoryPath", default="")
|
||||||
|
parser.add_argument("-RepositoryUser", default="")
|
||||||
|
parser.add_argument("-RepositoryPassword", default="")
|
||||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-Mode",
|
"-Mode",
|
||||||
default="Changes",
|
default="",
|
||||||
choices=["Full", "Changes", "Partial", "UpdateInfo"],
|
choices=["", "Full", "Changes", "Partial", "UpdateInfo"],
|
||||||
help="Dump mode (default: Changes)",
|
help="Dump mode (default: Changes)",
|
||||||
)
|
)
|
||||||
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
||||||
|
parser.add_argument("-ObjectsFile", default="")
|
||||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
||||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -388,7 +515,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -414,15 +541,40 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
# --- Validate Partial mode ---
|
||||||
|
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||||
|
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||||
|
if args.ObjectsFile:
|
||||||
|
if not os.path.exists(args.ObjectsFile):
|
||||||
|
print("Error: -ObjectsFile not found: %s" % args.ObjectsFile)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(args.ObjectsFile, encoding="utf-8-sig") as f:
|
||||||
|
from_file = [s.strip() for s in f.read().splitlines()
|
||||||
|
if s.strip() and not s.strip().startswith("#")]
|
||||||
|
inline = [s.strip() for s in args.Objects.split(",") if s.strip()]
|
||||||
|
args.Objects = ",".join(inline + from_file)
|
||||||
|
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||||
|
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||||
|
if args.Objects:
|
||||||
|
if args.Mode == "UpdateInfo":
|
||||||
|
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||||
|
print("Error: -Mode UpdateInfo does not take an object list — it only refreshes "
|
||||||
|
"ConfigDumpInfo.xml")
|
||||||
|
sys.exit(1)
|
||||||
|
if args.Mode in ("Full", "Changes"):
|
||||||
|
print("[note] перечислены объекты — выгружаются только они; -Mode %s не применён"
|
||||||
|
% args.Mode)
|
||||||
|
args.Mode = "Partial"
|
||||||
|
if not args.Mode:
|
||||||
|
args.Mode = "Changes"
|
||||||
if args.Mode == "Partial" and not args.Objects:
|
if args.Mode == "Partial" and not args.Objects:
|
||||||
print("Error: -Objects required for Partial mode", file=sys.stderr)
|
print("Error: -Objects or -ObjectsFile required for Partial mode")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Create output dir if needed ---
|
# --- Create output dir if needed ---
|
||||||
@@ -433,12 +585,12 @@ def main():
|
|||||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if args.Format == "Plain":
|
if args.Format == "Plain":
|
||||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args.AllExtensions:
|
if args.AllExtensions:
|
||||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||||
elif args.Mode == "UpdateInfo":
|
elif args.Mode == "UpdateInfo":
|
||||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif args.Mode == "Partial":
|
elif args.Mode == "Partial":
|
||||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||||
@@ -468,9 +620,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
|
||||||
else:
|
else:
|
||||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error exporting configuration (code: {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -491,6 +643,11 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f'/P"{args.Password}"')
|
arguments.append(f'/P"{args.Password}"')
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
repo = resolve_repository_settings(args)
|
||||||
|
arguments.extend(repository_args(repo))
|
||||||
|
|
||||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||||
arguments += ["-Format", args.Format]
|
arguments += ["-Format", args.Format]
|
||||||
|
|
||||||
@@ -529,7 +686,7 @@ def main():
|
|||||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||||
result = run_v8(v8path, arguments)
|
result = run_v8(v8path, arguments)
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
@@ -542,9 +699,9 @@ def main():
|
|||||||
print("Dump completed successfully")
|
print("Dump completed successfully")
|
||||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr)
|
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -40,7 +40,19 @@ allowed-tools:
|
|||||||
"password": "",
|
"password": "",
|
||||||
"aliases": ["dev", "разработка"],
|
"aliases": ["dev", "разработка"],
|
||||||
"branches": ["dev", "develop", "feature/*"],
|
"branches": ["dev", "develop", "feature/*"],
|
||||||
"configSrc": "C:\\WS\\myapp\\cfsrc"
|
"configSrc": "C:\\WS\\myapp\\cfsrc",
|
||||||
|
"repository": {
|
||||||
|
"path": "\\\\srv01\\repo\\MyApp",
|
||||||
|
"user": "Ivanov",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
{
|
||||||
|
"name": "МоёРасширение",
|
||||||
|
"src": "src\\cfe\\МоёРасширение",
|
||||||
|
"repository": { "path": "\\\\srv01\\repo\\MyApp_Ext", "user": "Ivanov", "password": "" }
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "test",
|
"id": "test",
|
||||||
@@ -64,6 +76,7 @@ allowed-tools:
|
|||||||
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
||||||
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
||||||
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
||||||
|
| `newObjectPosition` | `"end"` / `"byName"` | Куда попадает новый объект в составе конфигурации: в конец своего вида (по умолчанию) или на место по имени. Переопределяется в `databases[]` |
|
||||||
| `databases` | array | Массив баз данных |
|
| `databases` | array | Массив баз данных |
|
||||||
| `default` | string | id базы по умолчанию |
|
| `default` | string | id базы по умолчанию |
|
||||||
|
|
||||||
@@ -82,6 +95,35 @@ allowed-tools:
|
|||||||
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
|
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
|
||||||
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
|
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
|
||||||
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации |
|
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации |
|
||||||
|
| `repository` | object | нет | Хранилище конфигурации: `path`, `user`, `password` (см. ниже) |
|
||||||
|
| `extensions` | array | нет | Расширения: `name`, `src`, необязательное `repository` (см. ниже) |
|
||||||
|
|
||||||
|
### Хранилище конфигурации
|
||||||
|
|
||||||
|
База, подключённая к хранилищу конфигурации 1С, **не принимает ни одной операции конфигуратора**
|
||||||
|
без реквизитов доступа к хранилищу — это касается не только `/db-repo`, но и `/db-load-xml`,
|
||||||
|
`/db-dump-xml`, `/db-update`, `/db-load-git`. Реквизиты берутся из `repository` записи базы,
|
||||||
|
передавать их в каждом вызове не нужно.
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательное | Описание |
|
||||||
|
|------|-----|:------------:|----------|
|
||||||
|
| `repository.path` | string | да | Каталог хранилища или `tcp://<хост>[:<порт>]/<имя>` |
|
||||||
|
| `repository.user` | string | нет | Пользователь **хранилища**. Не наследуется от `user` базы |
|
||||||
|
| `repository.password` | string | нет | Пароль пользователя хранилища |
|
||||||
|
|
||||||
|
У расширения **своё хранилище** со своим путём, поэтому одного `repository` мало:
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательное | Описание |
|
||||||
|
|------|-----|:------------:|----------|
|
||||||
|
| `extensions[].name` | string | да | Имя расширения, как в конфигурации |
|
||||||
|
| `extensions[].src` | string | нет | Каталог XML-исходников расширения |
|
||||||
|
| `extensions[].repository` | object | нет | Хранилище расширения. Расширение без хранилища — обычный случай |
|
||||||
|
|
||||||
|
Пароль хранилища — такой же секрет, как `password` базы; `.v8-project.json` в `.gitignore`.
|
||||||
|
|
||||||
|
> **Сетевое хранилище.** Адрес — `tcp://<хост>[:<порт>]/<имя>`, порт по умолчанию 1542.
|
||||||
|
> Обслуживается сервером хранилища. Если он недоступен, платформа отвечает «Соединение с
|
||||||
|
> хранилищем конфигурации не установлено» — тем же сообщением, что и при отсутствии реквизитов.
|
||||||
|
|
||||||
## Алгоритм разрешения базы данных
|
## Алгоритм разрешения базы данных
|
||||||
|
|
||||||
@@ -128,6 +170,7 @@ test Тестовая server srv01/MyApp_Test
|
|||||||
- path (для file) или server + ref (для server)
|
- path (для file) или server + ref (для server)
|
||||||
- user, password (необязательно)
|
- user, password (необязательно)
|
||||||
- aliases, branches (необязательно)
|
- aliases, branches (необязательно)
|
||||||
|
- если база под хранилищем конфигурации — `repository`: путь, пользователь, пароль
|
||||||
|
|
||||||
Добавь в массив `databases`. Если это первая база — установи как `default`.
|
Добавь в массив `databases`. Если это первая база — установи как `default`.
|
||||||
|
|
||||||
@@ -159,3 +202,10 @@ test Тестовая server srv01/MyApp_Test
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
|
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
|
||||||
|
|
||||||
|
**Хранилище конфигурации** (если у базы задан `repository`) — скрипты навыков подставляют
|
||||||
|
сами, сопоставляя параметры соединения с записью реестра:
|
||||||
|
```
|
||||||
|
/ConfigurationRepositoryF"<path>" /ConfigurationRepositoryN"<user>" /ConfigurationRepositoryP"<password>"
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
# db-load-cf v1.17 — Load 1C configuration from CF file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
# db-load-cf v1.17 — Load 1C configuration from CF file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -393,7 +431,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -418,21 +456,21 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate input file ---
|
# --- Validate input file ---
|
||||||
if not os.path.isfile(args.InputFile):
|
if not os.path.isfile(args.InputFile):
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
print(f"Error: input file not found: {args.InputFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
# --- ibcmd branch (file infobase only) ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if args.AllExtensions:
|
if args.AllExtensions:
|
||||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
@@ -451,7 +489,7 @@ def main():
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -495,7 +533,7 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
# db-load-dt v1.16 — Load 1C information base from DT file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
# db-load-dt v1.16 — Load 1C information base from DT file
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -393,7 +431,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -418,15 +456,15 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate input file ---
|
# --- Validate input file ---
|
||||||
if not os.path.isfile(args.InputFile):
|
if not os.path.isfile(args.InputFile):
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
print(f"Error: input file not found: {args.InputFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only) ---
|
# --- ibcmd branch (file infobase only) ---
|
||||||
@@ -448,7 +486,7 @@ def main():
|
|||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
print(f"Information base restored successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -490,7 +528,7 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
print(f"Information base restored successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-git v1.18 — Load Git changes into 1C database
|
# db-load-git v1.26 — Load Git changes into 1C database
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
@@ -110,6 +110,21 @@ param(
|
|||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$UpdateDB,
|
[switch]$UpdateDB,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||||
|
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||||
|
# но в логе есть отбраковка.
|
||||||
|
[switch]$StrictLog,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryUser,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPassword,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string[]]$AdditionalV8Arguments = @(),
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
@@ -120,6 +135,115 @@ param(
|
|||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||||
|
function Find-V8Project([string]$startDir) {
|
||||||
|
$d = $startDir
|
||||||
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
|
$pj = Join-Path $d ".v8-project.json"
|
||||||
|
if (Test-Path $pj) { return $pj }
|
||||||
|
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||||
|
if ($parent -eq $d) { break }
|
||||||
|
$d = $parent
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Test-SamePath {
|
||||||
|
param([string]$A, [string]$B)
|
||||||
|
if (-not $A -or -not $B) { return $false }
|
||||||
|
try {
|
||||||
|
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||||
|
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||||
|
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
} catch { return $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-ProjectDatabase {
|
||||||
|
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||||
|
$pf = Find-V8Project (Get-Location).Path
|
||||||
|
if (-not $pf) { return $null }
|
||||||
|
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||||
|
if (-not $proj.databases) { return $null }
|
||||||
|
foreach ($db in $proj.databases) {
|
||||||
|
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||||
|
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||||
|
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||||
|
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RepositorySettings {
|
||||||
|
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||||
|
$dbRec = Find-ProjectDatabase
|
||||||
|
$rec = $null
|
||||||
|
if ($dbRec) {
|
||||||
|
if ($Extension) {
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
if ($dbRec.extensions) {
|
||||||
|
foreach ($ext in $dbRec.extensions) {
|
||||||
|
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
$rec = $ext.repository
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$rec = $dbRec.repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||||
|
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||||
|
return @{
|
||||||
|
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||||
|
User = $user
|
||||||
|
Password = $pwd
|
||||||
|
FromRegistry = [bool]($rec -and $rec.path)
|
||||||
|
DbRecord = $dbRec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RepositoryArgs {
|
||||||
|
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||||
|
param([hashtable]$Repo)
|
||||||
|
$a = @()
|
||||||
|
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||||
|
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||||
|
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||||
|
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||||
|
return $a
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||||
|
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||||
|
function Write-RepositoryHints {
|
||||||
|
param([string]$LogText)
|
||||||
|
if (-not $LogText) { return }
|
||||||
|
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||||
|
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||||
|
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||||
|
$obj = $m.Groups[1].Value
|
||||||
|
if ($obj -eq 'Configuration') {
|
||||||
|
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||||
|
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||||
|
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||||
|
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||||
|
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Protect-Secrets {
|
function Protect-Secrets {
|
||||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
param([string]$Text, [string[]]$Secrets)
|
param([string]$Text, [string[]]$Secrets)
|
||||||
@@ -141,7 +265,7 @@ $script:IbcmdOwnedKeys = @(
|
|||||||
'--import', '--export', '--apply', '--force', '--create-database',
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
'--user', '--password'
|
'--user', '--password'
|
||||||
)
|
)
|
||||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
function Test-ArgKeyMatch {
|
function Test-ArgKeyMatch {
|
||||||
@@ -394,6 +518,41 @@ function Write-PlatformOutput {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||||
|
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||||
|
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Find-SilentRejections {
|
||||||
|
param([string]$LogText)
|
||||||
|
$patterns = @(
|
||||||
|
'Неверное свойство объекта метаданных',
|
||||||
|
'не входит в состав объекта метаданных',
|
||||||
|
'Неизвестное имя типа',
|
||||||
|
'Неизвестный объект метаданных',
|
||||||
|
'Ни один из документов не является регистратором для регистра',
|
||||||
|
'Неверное значение перечисления',
|
||||||
|
'не может быть приведен к типу',
|
||||||
|
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||||
|
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||||
|
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||||
|
)
|
||||||
|
$found = @()
|
||||||
|
if ($LogText) {
|
||||||
|
foreach ($line in ($LogText -split "`r?`n")) {
|
||||||
|
foreach ($pat in $patterns) {
|
||||||
|
if ($line -match [regex]::Escape($pat)) {
|
||||||
|
$found += $line.Trim()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||||
|
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||||
|
return $found
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
@@ -627,6 +786,11 @@ try {
|
|||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
$__repo = Resolve-RepositorySettings
|
||||||
|
$arguments += Get-RepositoryArgs $__repo
|
||||||
|
|
||||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||||
$arguments += "-listFile", "`"$listFile`""
|
$arguments += "-listFile", "`"$listFile`""
|
||||||
$arguments += "-Format", $Format
|
$arguments += "-Format", $Format
|
||||||
@@ -654,7 +818,7 @@ try {
|
|||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Executing partial configuration load..."
|
Write-Host "Executing partial configuration load..."
|
||||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||||
|
|
||||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $__v8.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
@@ -667,6 +831,7 @@ try {
|
|||||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logContent = $null
|
||||||
if (Test-Path $outFile) {
|
if (Test-Path $outFile) {
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||||
if ($logContent) {
|
if ($logContent) {
|
||||||
@@ -676,6 +841,17 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-PlatformOutput $__v8.Output
|
Write-PlatformOutput $__v8.Output
|
||||||
|
Write-RepositoryHints $logContent
|
||||||
|
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||||
|
$silentFailures = @(Find-SilentRejections $logContent)
|
||||||
|
if ($silentFailures.Count -gt 0) {
|
||||||
|
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||||
|
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||||
|
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-git v1.18 — Load Git changes into 1C database
|
# db-load-git v1.26 — Load Git changes into 1C database
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
|
|||||||
"--import", "--export", "--apply", "--force", "--create-database",
|
"--import", "--export", "--apply", "--force", "--create-database",
|
||||||
"--user", "--password",
|
"--user", "--password",
|
||||||
]
|
]
|
||||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||||
|
def _sg_find_v8project(start_dir):
|
||||||
|
d = start_dir
|
||||||
|
for _ in range(20):
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
pj = os.path.join(d, ".v8-project.json")
|
||||||
|
if os.path.isfile(pj):
|
||||||
|
return pj
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
break
|
||||||
|
d = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def same_path(a, b):
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_database(args):
|
||||||
|
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||||
|
pf = _sg_find_v8project(os.getcwd())
|
||||||
|
if not pf:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(pf, encoding="utf-8-sig") as f:
|
||||||
|
proj = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for db in proj.get("databases") or []:
|
||||||
|
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||||
|
return db
|
||||||
|
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||||
|
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||||
|
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||||
|
return db
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repository_settings(args):
|
||||||
|
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||||
|
db_rec = find_project_database(args)
|
||||||
|
rec = None
|
||||||
|
if db_rec:
|
||||||
|
if args.Extension:
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
for ext in db_rec.get("extensions") or []:
|
||||||
|
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||||
|
rec = ext.get("repository")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
rec = db_rec.get("repository")
|
||||||
|
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||||
|
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||||
|
return {
|
||||||
|
"path": path.strip().strip('"') if path else None,
|
||||||
|
"user": user,
|
||||||
|
"password": pwd,
|
||||||
|
"from_registry": bool(rec and rec.get("path")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def repository_args(repo):
|
||||||
|
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||||
|
a = []
|
||||||
|
if not repo or not repo.get("path"):
|
||||||
|
return a
|
||||||
|
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||||
|
if repo.get("user"):
|
||||||
|
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||||
|
if repo.get("password"):
|
||||||
|
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||||
|
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||||
|
def write_repository_hints(log_text):
|
||||||
|
if not log_text:
|
||||||
|
return
|
||||||
|
if "текущая конфигурация помещена в хранилище" in log_text:
|
||||||
|
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
|
||||||
|
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
|
||||||
|
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
|
||||||
|
obj = m.group(1)
|
||||||
|
if obj == "Configuration":
|
||||||
|
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
|
||||||
|
print(' /db-repo lock <база> -Objects "Конфигурация"')
|
||||||
|
else:
|
||||||
|
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
|
||||||
|
if "Соединение с хранилищем конфигурации не установлено" in log_text:
|
||||||
|
print("[hint] соединение с хранилищем не установлено. Две причины:")
|
||||||
|
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
|
||||||
|
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
|
||||||
|
|
||||||
|
|
||||||
def arg_key_match(token, key):
|
def arg_key_match(token, key):
|
||||||
"""Token matches a key when it equals it, or starts with it and the next character
|
"""Token matches a key when it equals it, or starts with it and the next character
|
||||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||||
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -322,6 +466,38 @@ def print_platform_output(result):
|
|||||||
print("--- End ---")
|
print("--- End ---")
|
||||||
|
|
||||||
|
|
||||||
|
def find_silent_rejections(log_text):
|
||||||
|
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||||
|
|
||||||
|
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||||
|
Возвращает подошедшие строки.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||||
|
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||||
|
весь смысл.
|
||||||
|
"""
|
||||||
|
patterns = [
|
||||||
|
"Неверное свойство объекта метаданных",
|
||||||
|
"не входит в состав объекта метаданных",
|
||||||
|
"Неизвестное имя типа",
|
||||||
|
"Неизвестный объект метаданных",
|
||||||
|
"Ни один из документов не является регистратором для регистра",
|
||||||
|
"Неверное значение перечисления",
|
||||||
|
"не может быть приведен к типу",
|
||||||
|
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||||
|
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||||
|
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||||
|
]
|
||||||
|
found = []
|
||||||
|
if log_text:
|
||||||
|
for line in log_text.splitlines():
|
||||||
|
for pat in patterns:
|
||||||
|
if pat in line:
|
||||||
|
found.append(line.strip())
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -406,6 +582,9 @@ def main():
|
|||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
parser.add_argument("-UserName", default="", help="1C user name")
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
parser.add_argument("-Password", default="", help="1C user password")
|
||||||
|
parser.add_argument("-RepositoryPath", default="")
|
||||||
|
parser.add_argument("-RepositoryUser", default="")
|
||||||
|
parser.add_argument("-RepositoryPassword", default="")
|
||||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
|
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-Source",
|
"-Source",
|
||||||
@@ -424,13 +603,17 @@ def main():
|
|||||||
)
|
)
|
||||||
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
||||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||||
|
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||||
|
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||||
|
# но в логе есть отбраковка.
|
||||||
|
parser.add_argument("-StrictLog", action="store_true")
|
||||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -448,10 +631,10 @@ def main():
|
|||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Resolve additional arguments for the selected engine ---
|
# --- Resolve additional arguments for the selected engine ---
|
||||||
@@ -468,19 +651,19 @@ def main():
|
|||||||
|
|
||||||
# --- Validate config dir ---
|
# --- Validate config dir ---
|
||||||
if not os.path.exists(args.ConfigDir):
|
if not os.path.exists(args.ConfigDir):
|
||||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
print(f"Error: config directory not found: {args.ConfigDir}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate Commit mode ---
|
# --- Validate Commit mode ---
|
||||||
if args.Source == "Commit" and not args.CommitRange:
|
if args.Source == "Commit" and not args.CommitRange:
|
||||||
print("Error: -CommitRange required for Source=Commit", file=sys.stderr)
|
print("Error: -CommitRange required for Source=Commit")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Check git ---
|
# --- Check git ---
|
||||||
try:
|
try:
|
||||||
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
|
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
print("Error: git not found in PATH", file=sys.stderr)
|
print("Error: git not found in PATH")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Get changed files from Git ---
|
# --- Get changed files from Git ---
|
||||||
@@ -559,10 +742,10 @@ def main():
|
|||||||
config_files.append(rel_path)
|
config_files.append(rel_path)
|
||||||
|
|
||||||
if support_skipped:
|
if support_skipped:
|
||||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr)
|
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):")
|
||||||
for sf in support_skipped:
|
for sf in support_skipped:
|
||||||
print(f" - {sf}", file=sys.stderr)
|
print(f" - {sf}")
|
||||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr)
|
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).")
|
||||||
|
|
||||||
if len(config_files) == 0:
|
if len(config_files) == 0:
|
||||||
print("No configuration files found in changes")
|
print("No configuration files found in changes")
|
||||||
@@ -586,10 +769,10 @@ def main():
|
|||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||||
if args.Format == "Plain":
|
if args.Format == "Plain":
|
||||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args.AllExtensions:
|
if args.AllExtensions:
|
||||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr)
|
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
arguments = ["infobase", "config", "import", "files"] + config_files
|
arguments = ["infobase", "config", "import", "files"] + config_files
|
||||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||||
@@ -606,7 +789,7 @@ def main():
|
|||||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
@@ -624,7 +807,7 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
print_platform_output(ar)
|
print_platform_output(ar)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
@@ -646,6 +829,11 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f'/P"{args.Password}"')
|
arguments.append(f'/P"{args.Password}"')
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
repo = resolve_repository_settings(args)
|
||||||
|
arguments.extend(repository_args(repo))
|
||||||
|
|
||||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||||
arguments += ["-listFile", f'"{list_file}"']
|
arguments += ["-listFile", f'"{list_file}"']
|
||||||
arguments += ["-Format", args.Format]
|
arguments += ["-Format", args.Format]
|
||||||
@@ -671,7 +859,7 @@ def main():
|
|||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print("")
|
print("")
|
||||||
print("Executing partial configuration load...")
|
print("Executing partial configuration load...")
|
||||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||||
|
|
||||||
result = run_v8(v8path, arguments)
|
result = run_v8(v8path, arguments)
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
@@ -681,8 +869,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Load completed successfully")
|
print("Load completed successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
|
|
||||||
|
log_content = ""
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -695,6 +884,22 @@ def main():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
print_platform_output(result)
|
print_platform_output(result)
|
||||||
|
write_repository_hints(log_content)
|
||||||
|
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||||
|
silent_failures = find_silent_rejections(log_content)
|
||||||
|
if silent_failures:
|
||||||
|
print(
|
||||||
|
f"[warning] platform reported success, but the log contains "
|
||||||
|
f"{len(silent_failures)} problem(s):"
|
||||||
|
)
|
||||||
|
for line in silent_failures:
|
||||||
|
print(f" {line}")
|
||||||
|
if args.StrictLog and exit_code == 0:
|
||||||
|
exit_code = 1
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ allowed-tools:
|
|||||||
Если файла нет — предложи `/db-list add`.
|
Если файла нет — предложи `/db-list add`.
|
||||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||||
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
|
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
|
||||||
|
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
# db-load-xml v1.28 — Load 1C configuration from XML files
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
@@ -85,8 +85,10 @@ param(
|
|||||||
[string]$ConfigDir,
|
[string]$ConfigDir,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[ValidateSet("Full", "Partial")]
|
# Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
|
||||||
[string]$Mode = "Full",
|
# как станет видно, перечислены ли файлы.
|
||||||
|
[ValidateSet("", "Full", "Partial")]
|
||||||
|
[string]$Mode = "",
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$Files,
|
[string]$Files,
|
||||||
@@ -110,6 +112,15 @@ param(
|
|||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$StrictLog,
|
[switch]$StrictLog,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryUser,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$RepositoryPassword,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string[]]$AdditionalV8Arguments = @(),
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
@@ -120,6 +131,115 @@ param(
|
|||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||||
|
function Find-V8Project([string]$startDir) {
|
||||||
|
$d = $startDir
|
||||||
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
|
$pj = Join-Path $d ".v8-project.json"
|
||||||
|
if (Test-Path $pj) { return $pj }
|
||||||
|
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||||
|
if ($parent -eq $d) { break }
|
||||||
|
$d = $parent
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
function Test-SamePath {
|
||||||
|
param([string]$A, [string]$B)
|
||||||
|
if (-not $A -or -not $B) { return $false }
|
||||||
|
try {
|
||||||
|
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||||
|
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||||
|
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
} catch { return $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-ProjectDatabase {
|
||||||
|
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||||
|
$pf = Find-V8Project (Get-Location).Path
|
||||||
|
if (-not $pf) { return $null }
|
||||||
|
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||||
|
if (-not $proj.databases) { return $null }
|
||||||
|
foreach ($db in $proj.databases) {
|
||||||
|
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||||
|
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||||
|
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||||
|
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RepositorySettings {
|
||||||
|
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||||
|
$dbRec = Find-ProjectDatabase
|
||||||
|
$rec = $null
|
||||||
|
if ($dbRec) {
|
||||||
|
if ($Extension) {
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
if ($dbRec.extensions) {
|
||||||
|
foreach ($ext in $dbRec.extensions) {
|
||||||
|
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
$rec = $ext.repository
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$rec = $dbRec.repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||||
|
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||||
|
return @{
|
||||||
|
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||||
|
User = $user
|
||||||
|
Password = $pwd
|
||||||
|
FromRegistry = [bool]($rec -and $rec.path)
|
||||||
|
DbRecord = $dbRec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RepositoryArgs {
|
||||||
|
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||||
|
param([hashtable]$Repo)
|
||||||
|
$a = @()
|
||||||
|
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||||
|
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||||
|
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||||
|
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||||
|
return $a
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||||
|
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||||
|
function Write-RepositoryHints {
|
||||||
|
param([string]$LogText)
|
||||||
|
if (-not $LogText) { return }
|
||||||
|
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||||
|
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||||
|
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||||
|
$obj = $m.Groups[1].Value
|
||||||
|
if ($obj -eq 'Configuration') {
|
||||||
|
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||||
|
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||||
|
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||||
|
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||||
|
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Protect-Secrets {
|
function Protect-Secrets {
|
||||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
param([string]$Text, [string[]]$Secrets)
|
param([string]$Text, [string[]]$Secrets)
|
||||||
@@ -158,7 +278,7 @@ $script:IbcmdOwnedKeys = @(
|
|||||||
'--import', '--export', '--apply', '--force', '--create-database',
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
'--user', '--password'
|
'--user', '--password'
|
||||||
)
|
)
|
||||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
function Test-ArgKeyMatch {
|
function Test-ArgKeyMatch {
|
||||||
@@ -416,6 +536,41 @@ function Write-PlatformOutput {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||||
|
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||||
|
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Find-SilentRejections {
|
||||||
|
param([string]$LogText)
|
||||||
|
$patterns = @(
|
||||||
|
'Неверное свойство объекта метаданных',
|
||||||
|
'не входит в состав объекта метаданных',
|
||||||
|
'Неизвестное имя типа',
|
||||||
|
'Неизвестный объект метаданных',
|
||||||
|
'Ни один из документов не является регистратором для регистра',
|
||||||
|
'Неверное значение перечисления',
|
||||||
|
'не может быть приведен к типу',
|
||||||
|
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||||
|
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||||
|
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||||
|
)
|
||||||
|
$found = @()
|
||||||
|
if ($LogText) {
|
||||||
|
foreach ($line in ($LogText -split "`r?`n")) {
|
||||||
|
foreach ($pat in $patterns) {
|
||||||
|
if ($line -match [regex]::Escape($pat)) {
|
||||||
|
$found += $line.Trim()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||||
|
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||||
|
return $found
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||||
|
|
||||||
@@ -440,6 +595,16 @@ if (-not (Test-Path $ConfigDir)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание Full
|
||||||
|
# заменило бы всю конфигурацию базы.
|
||||||
|
if ($Files -or $ListFile) {
|
||||||
|
if ($Mode -eq "Full") {
|
||||||
|
Write-Host "[note] перечислены файлы — загружаются только они; -Mode Full не применён" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
$Mode = "Partial"
|
||||||
|
}
|
||||||
|
if (-not $Mode) { $Mode = "Full" }
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
# --- Validate Partial mode ---
|
||||||
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
||||||
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
||||||
@@ -459,7 +624,7 @@ try {
|
|||||||
}
|
}
|
||||||
if ($AllExtensions) {
|
if ($AllExtensions) {
|
||||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
} elseif ($Mode -eq "Partial") {
|
||||||
# partial: import specific files (relative to ConfigDir)
|
# partial: import specific files (relative to ConfigDir)
|
||||||
$fileList = @()
|
$fileList = @()
|
||||||
if ($ListFile) {
|
if ($ListFile) {
|
||||||
@@ -532,6 +697,11 @@ try {
|
|||||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
$__repo = Resolve-RepositorySettings
|
||||||
|
$arguments += Get-RepositoryArgs $__repo
|
||||||
|
|
||||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||||
|
|
||||||
if ($Mode -eq "Full") {
|
if ($Mode -eq "Full") {
|
||||||
@@ -596,7 +766,7 @@ try {
|
|||||||
$arguments += $extraArgs
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $__v8.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
@@ -607,28 +777,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Scan log for silent rejections ---
|
# --- Scan log for silent rejections ---
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
$silentFailures = @(Find-SilentRejections $logContent)
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
|
||||||
$fatalLogPatterns = @(
|
|
||||||
'Неверное свойство объекта метаданных',
|
|
||||||
'не входит в состав объекта метаданных',
|
|
||||||
'Неизвестное имя типа',
|
|
||||||
'Неизвестный объект метаданных',
|
|
||||||
'Ни один из документов не является регистратором для регистра',
|
|
||||||
'Неверное значение перечисления',
|
|
||||||
'не может быть приведен к типу'
|
|
||||||
)
|
|
||||||
$silentFailures = @()
|
|
||||||
if ($logContent) {
|
|
||||||
foreach ($line in ($logContent -split "`r?`n")) {
|
|
||||||
foreach ($pat in $fatalLogPatterns) {
|
|
||||||
if ($line -match [regex]::Escape($pat)) {
|
|
||||||
$silentFailures += $line.Trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||||
@@ -646,11 +795,13 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
Write-PlatformOutput $__v8.Output
|
Write-PlatformOutput $__v8.Output
|
||||||
|
Write-RepositoryHints $logContent
|
||||||
|
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||||
if ($silentFailures.Count -gt 0) {
|
if ($silentFailures.Count -gt 0) {
|
||||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
|
||||||
Write-Host $msg -ForegroundColor Yellow
|
|
||||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
# db-load-xml v1.28 — Load 1C configuration from XML files
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
|
|||||||
"--import", "--export", "--apply", "--force", "--create-database",
|
"--import", "--export", "--apply", "--force", "--create-database",
|
||||||
"--user", "--password",
|
"--user", "--password",
|
||||||
]
|
]
|
||||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- Реквизиты хранилища из .v8-project.json ---
|
||||||
|
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||||
|
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||||
|
def _sg_find_v8project(start_dir):
|
||||||
|
d = start_dir
|
||||||
|
for _ in range(20):
|
||||||
|
if not d:
|
||||||
|
break
|
||||||
|
pj = os.path.join(d, ".v8-project.json")
|
||||||
|
if os.path.isfile(pj):
|
||||||
|
return pj
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
break
|
||||||
|
d = parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def same_path(a, b):
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_database(args):
|
||||||
|
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||||
|
pf = _sg_find_v8project(os.getcwd())
|
||||||
|
if not pf:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(pf, encoding="utf-8-sig") as f:
|
||||||
|
proj = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
for db in proj.get("databases") or []:
|
||||||
|
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||||
|
return db
|
||||||
|
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||||
|
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||||
|
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||||
|
return db
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_repository_settings(args):
|
||||||
|
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||||
|
db_rec = find_project_database(args)
|
||||||
|
rec = None
|
||||||
|
if db_rec:
|
||||||
|
if args.Extension:
|
||||||
|
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||||
|
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||||
|
for ext in db_rec.get("extensions") or []:
|
||||||
|
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||||
|
rec = ext.get("repository")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
rec = db_rec.get("repository")
|
||||||
|
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||||
|
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||||
|
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||||
|
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||||
|
return {
|
||||||
|
"path": path.strip().strip('"') if path else None,
|
||||||
|
"user": user,
|
||||||
|
"password": pwd,
|
||||||
|
"from_registry": bool(rec and rec.get("path")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def repository_args(repo):
|
||||||
|
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||||
|
a = []
|
||||||
|
if not repo or not repo.get("path"):
|
||||||
|
return a
|
||||||
|
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||||
|
if repo.get("user"):
|
||||||
|
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||||
|
if repo.get("password"):
|
||||||
|
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||||
|
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||||
|
def write_repository_hints(log_text):
|
||||||
|
if not log_text:
|
||||||
|
return
|
||||||
|
if "текущая конфигурация помещена в хранилище" in log_text:
|
||||||
|
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
|
||||||
|
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
|
||||||
|
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
|
||||||
|
obj = m.group(1)
|
||||||
|
if obj == "Configuration":
|
||||||
|
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
|
||||||
|
print(' /db-repo lock <база> -Objects "Конфигурация"')
|
||||||
|
else:
|
||||||
|
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
|
||||||
|
if "Соединение с хранилищем конфигурации не установлено" in log_text:
|
||||||
|
print("[hint] соединение с хранилищем не установлено. Две причины:")
|
||||||
|
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
|
||||||
|
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
|
||||||
|
|
||||||
|
|
||||||
def arg_key_match(token, key):
|
def arg_key_match(token, key):
|
||||||
"""Token matches a key when it equals it, or starts with it and the next character
|
"""Token matches a key when it equals it, or starts with it and the next character
|
||||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||||
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -322,6 +466,38 @@ def print_platform_output(result):
|
|||||||
print("--- End ---")
|
print("--- End ---")
|
||||||
|
|
||||||
|
|
||||||
|
def find_silent_rejections(log_text):
|
||||||
|
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||||
|
|
||||||
|
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||||
|
Возвращает подошедшие строки.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||||
|
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||||
|
весь смысл.
|
||||||
|
"""
|
||||||
|
patterns = [
|
||||||
|
"Неверное свойство объекта метаданных",
|
||||||
|
"не входит в состав объекта метаданных",
|
||||||
|
"Неизвестное имя типа",
|
||||||
|
"Неизвестный объект метаданных",
|
||||||
|
"Ни один из документов не является регистратором для регистра",
|
||||||
|
"Неверное значение перечисления",
|
||||||
|
"не может быть приведен к типу",
|
||||||
|
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||||
|
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||||
|
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||||
|
]
|
||||||
|
found = []
|
||||||
|
if log_text:
|
||||||
|
for line in log_text.splitlines():
|
||||||
|
for pat in patterns:
|
||||||
|
if pat in line:
|
||||||
|
found.append(line.strip())
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -384,11 +560,14 @@ def main():
|
|||||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||||
parser.add_argument("-UserName", default="", help="1C user name")
|
parser.add_argument("-UserName", default="", help="1C user name")
|
||||||
parser.add_argument("-Password", default="", help="1C user password")
|
parser.add_argument("-Password", default="", help="1C user password")
|
||||||
|
parser.add_argument("-RepositoryPath", default="")
|
||||||
|
parser.add_argument("-RepositoryUser", default="")
|
||||||
|
parser.add_argument("-RepositoryPassword", default="")
|
||||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-Mode",
|
"-Mode",
|
||||||
default="Full",
|
default="",
|
||||||
choices=["Full", "Partial"],
|
choices=["", "Full", "Partial"],
|
||||||
help="Load mode (default: Full)",
|
help="Load mode (default: Full)",
|
||||||
)
|
)
|
||||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
||||||
@@ -413,7 +592,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -441,34 +620,42 @@ def main():
|
|||||||
# --- Validate connection ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate config dir ---
|
# --- Validate config dir ---
|
||||||
if not os.path.exists(args.ConfigDir):
|
if not os.path.exists(args.ConfigDir):
|
||||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
print(f"Error: config directory not found: {args.ConfigDir}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate Partial mode ---
|
# --- Validate Partial mode ---
|
||||||
|
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
|
||||||
|
# Full заменило бы всю конфигурацию базы.
|
||||||
|
if args.Files or args.ListFile:
|
||||||
|
if args.Mode == "Full":
|
||||||
|
print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
|
||||||
|
args.Mode = "Partial"
|
||||||
|
if not args.Mode:
|
||||||
|
args.Mode = "Full"
|
||||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
||||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
print("Error: -Files or -ListFile required for Partial mode")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if args.Format == "Plain":
|
if args.Format == "Plain":
|
||||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args.AllExtensions:
|
if args.AllExtensions:
|
||||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
elif args.Mode == "Partial":
|
||||||
# partial: import specific files (relative to ConfigDir)
|
# partial: import specific files (relative to ConfigDir)
|
||||||
if args.ListFile:
|
if args.ListFile:
|
||||||
if not os.path.isfile(args.ListFile):
|
if not os.path.isfile(args.ListFile):
|
||||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
print(f"Error: list file not found: {args.ListFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
file_list = [ln.strip() for ln in f if ln.strip()]
|
||||||
@@ -477,7 +664,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
file_list = []
|
file_list = []
|
||||||
if not file_list:
|
if not file_list:
|
||||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
print("Error: -Files or -ListFile required for partial import")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||||
@@ -499,7 +686,7 @@ def main():
|
|||||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||||
sys.exit(result.returncode)
|
sys.exit(result.returncode)
|
||||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
@@ -517,7 +704,7 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
print_platform_output(ar)
|
print_platform_output(ar)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
@@ -539,6 +726,11 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f'/P"{args.Password}"')
|
arguments.append(f'/P"{args.Password}"')
|
||||||
|
|
||||||
|
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||||
|
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||||
|
repo = resolve_repository_settings(args)
|
||||||
|
arguments.extend(repository_args(repo))
|
||||||
|
|
||||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||||
|
|
||||||
if args.Mode == "Full":
|
if args.Mode == "Full":
|
||||||
@@ -549,7 +741,7 @@ def main():
|
|||||||
# Build list file
|
# Build list file
|
||||||
if args.ListFile:
|
if args.ListFile:
|
||||||
if not os.path.isfile(args.ListFile):
|
if not os.path.isfile(args.ListFile):
|
||||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
print(f"Error: list file not found: {args.ListFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
raw_list = [ln.strip() for ln in f if ln.strip()]
|
||||||
@@ -561,12 +753,12 @@ def main():
|
|||||||
support_files = [x for x in raw_list if support_re.search(x)]
|
support_files = [x for x in raw_list if support_re.search(x)]
|
||||||
file_list = [x for x in raw_list if not support_re.search(x)]
|
file_list = [x for x in raw_list if not support_re.search(x)]
|
||||||
if support_files:
|
if support_files:
|
||||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr)
|
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
|
||||||
for sf in support_files:
|
for sf in support_files:
|
||||||
print(f" - {sf}", file=sys.stderr)
|
print(f" - {sf}")
|
||||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
|
||||||
if not file_list:
|
if not file_list:
|
||||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||||
@@ -598,7 +790,7 @@ def main():
|
|||||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||||
result = run_v8(v8path, arguments)
|
result = run_v8(v8path, arguments)
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
@@ -614,22 +806,7 @@ def main():
|
|||||||
# --- Scan log for silent rejections ---
|
# --- Scan log for silent rejections ---
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||||
fatal_log_patterns = [
|
silent_failures = find_silent_rejections(log_content)
|
||||||
"Неверное свойство объекта метаданных",
|
|
||||||
"не входит в состав объекта метаданных",
|
|
||||||
"Неизвестное имя типа",
|
|
||||||
"Неизвестный объект метаданных",
|
|
||||||
"Ни один из документов не является регистратором для регистра",
|
|
||||||
"Неверное значение перечисления",
|
|
||||||
"не может быть приведен к типу",
|
|
||||||
]
|
|
||||||
silent_failures = []
|
|
||||||
if log_content:
|
|
||||||
for line in log_content.splitlines():
|
|
||||||
for pat in fatal_log_patterns:
|
|
||||||
if pat in line:
|
|
||||||
silent_failures.append(line.strip())
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||||
@@ -638,7 +815,7 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Load completed successfully")
|
print("Load completed successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||||
|
|
||||||
if log_content:
|
if log_content:
|
||||||
print("--- Log ---")
|
print("--- Log ---")
|
||||||
@@ -646,15 +823,20 @@ def main():
|
|||||||
print("--- End ---")
|
print("--- End ---")
|
||||||
|
|
||||||
print_platform_output(result)
|
print_platform_output(result)
|
||||||
|
write_repository_hints(log_content)
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||||
|
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||||
|
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||||
|
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||||
if silent_failures:
|
if silent_failures:
|
||||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
|
||||||
print(
|
print(
|
||||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
f"[warning] platform reported success, but the log contains "
|
||||||
f"platform loaded config but dropped properties/refs{suffix}",
|
f"{len(silent_failures)} problem(s):"
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
for f in silent_failures:
|
for f in silent_failures:
|
||||||
print(f" {f}", file=sys.stderr)
|
print(f" {f}")
|
||||||
if args.StrictLog and exit_code == 0:
|
if args.StrictLog and exit_code == 0:
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
|
|
||||||
|
|||||||
@@ -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 "${CLAUDE_SKILL_DIR}/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 "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
|
||||||
|
|
||||||
|
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
|
||||||
|
|
||||||
|
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
|
||||||
|
|
||||||
|
# Поместить с комментарием, оставив захват
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
|
||||||
|
|
||||||
|
# Получить изменения из хранилища
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
|
||||||
|
|
||||||
|
# Серверная база, расширение
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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
@@ -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")
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ allowed-tools:
|
|||||||
|
|
||||||
# /db-update — Обновление конфигурации БД
|
# /db-update — Обновление конфигурации БД
|
||||||
|
|
||||||
Применяет изменения основной конфигурации к конфигурации базы данных (`/UpdateDBCfg`). Обязательный шаг после `/db-load-cf`, `/db-load-xml`, `/db-load-git`.
|
Применяет изменения основной конфигурации к конфигурации базы данных (`/UpdateDBCfg`) —
|
||||||
|
отдельным шагом после загрузки. У `/db-load-xml` и `/db-load-git` то же самое делает
|
||||||
|
ключ `-UpdateDB`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/db-update [database]
|
/db-update [database]
|
||||||
/db-update dev
|
/db-update dev
|
||||||
/db-update dev -Dynamic+
|
/db-update dev -Dynamic on
|
||||||
```
|
```
|
||||||
|
|
||||||
## Параметры подключения
|
## Параметры подключения
|
||||||
@@ -50,7 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
|||||||
| `-Password <пароль>` | нет | Пароль |
|
| `-Password <пароль>` | нет | Пароль |
|
||||||
| `-Extension <имя>` | нет | Обновить расширение |
|
| `-Extension <имя>` | нет | Обновить расширение |
|
||||||
| `-AllExtensions` | нет | Обновить все расширения |
|
| `-AllExtensions` | нет | Обновить все расширения |
|
||||||
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
| `-Dynamic <on/off>` | нет | `on` — динамическое обновление, без монопольного доступа к базе; `off` — отключить |
|
||||||
| `-Server` | нет | Обновление на стороне сервера |
|
| `-Server` | нет | Обновление на стороне сервера |
|
||||||
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
||||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
@@ -68,20 +70,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
|||||||
| `-BackgroundSuspend` | Приостановить |
|
| `-BackgroundSuspend` | Приостановить |
|
||||||
| `-BackgroundResume` | Возобновить |
|
| `-BackgroundResume` | Возобновить |
|
||||||
|
|
||||||
## Предупреждения
|
|
||||||
|
|
||||||
- Если обновление **не динамическое** — потребуется **монопольный доступ** к базе (все пользователи должны выйти)
|
|
||||||
- Для серверных баз рекомендуется `-Dynamic+` для обновления без остановки
|
|
||||||
- Если структура данных существенно изменилась (удаление реквизитов, изменение типов) — динамическое обновление может быть невозможно
|
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Обычное обновление (файловая база)
|
# Обычное обновление (файловая база)
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/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 "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyDB" -UserName "Admin" -Password "secret" -Dynamic on
|
||||||
|
|
||||||
# Обновление расширения
|
# Обновление расширения
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -Extension "МоёРасширение"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-update v1.13 — Update 1C database configuration
|
# db-update v1.20 — 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 не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
Обновить все расширения
|
Обновить все расширения
|
||||||
|
|
||||||
.PARAMETER Dynamic
|
.PARAMETER Dynamic
|
||||||
Динамическое обновление: "+" включить, "-" отключить
|
Динамическое обновление: on включить, off отключить
|
||||||
|
|
||||||
.PARAMETER Server
|
.PARAMETER Server
|
||||||
Обновление на стороне сервера
|
Обновление на стороне сервера
|
||||||
@@ -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,
|
||||||
@@ -81,8 +81,10 @@ param(
|
|||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$AllExtensions,
|
[switch]$AllExtensions,
|
||||||
|
|
||||||
|
# on/off, а не +/-: значение "-" через powershell.exe -File парсер не связывает и молча
|
||||||
|
# выходит с кодом 2, без единого сообщения. "+"/"-" принимаются, но в инструкции не значатся.
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[ValidateSet("+", "-")]
|
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||||
[string]$Dynamic,
|
[string]$Dynamic,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
@@ -91,6 +93,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 = @(),
|
||||||
|
|
||||||
@@ -98,9 +115,95 @@ param(
|
|||||||
[string[]]$AdditionalIbcmdArguments = @()
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if ($Dynamic) { $Dynamic = if (@('on', 'yes', '+') -contains $Dynamic.ToLower()) { '+' } else { '-' } }
|
||||||
|
|
||||||
$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 +242,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 +498,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 +596,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 +628,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 +639,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 +650,16 @@ try {
|
|||||||
}
|
}
|
||||||
Write-PlatformOutput $__v8.Output
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||||
|
$silentFailures = @(Find-SilentRejections $logContent)
|
||||||
|
if ($silentFailures.Count -gt 0) {
|
||||||
|
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||||
|
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||||
|
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||||
|
}
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-update v1.13 — Update 1C database configuration
|
# db-update v1.20 — 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,30 @@ 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=["", "+", "-"])
|
# on/off, а не +/-: значение "-" через powershell.exe -File парсер PS не связывает и молча
|
||||||
|
# выходит с кодом 2. "+"/"-" принимаются, но в инструкции не значатся.
|
||||||
|
parser.add_argument("-Dynamic", default="", choices=["", "on", "off", "yes", "no", "+", "-"])
|
||||||
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)
|
||||||
|
|
||||||
|
if args.Dynamic:
|
||||||
|
args.Dynamic = "+" if args.Dynamic.lower() in ("on", "yes", "+") else "-"
|
||||||
|
|
||||||
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 +587,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 +618,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 +639,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 +667,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 +675,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 +690,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:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
# epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
# epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -374,7 +412,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -398,7 +436,7 @@ def main():
|
|||||||
}
|
}
|
||||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||||
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Auto-create stub database if no connection specified ---
|
# --- Auto-create stub database if no connection specified ---
|
||||||
@@ -419,14 +457,14 @@ def main():
|
|||||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||||
result = subprocess.run(stub_cmd, capture_output=False)
|
result = subprocess.run(stub_cmd, capture_output=False)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("Error: failed to create stub database", file=sys.stderr)
|
print("Error: failed to create stub database")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
args.InfoBasePath = auto_base_path
|
args.InfoBasePath = auto_base_path
|
||||||
auto_created_base = auto_base_path
|
auto_created_base = auto_base_path
|
||||||
|
|
||||||
# --- Validate source file ---
|
# --- Validate source file ---
|
||||||
if not os.path.isfile(args.SourceFile):
|
if not os.path.isfile(args.SourceFile):
|
||||||
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr)
|
print(f"Error: source file not found: {args.SourceFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
# --- Ensure output directory exists ---
|
||||||
@@ -460,9 +498,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||||
else:
|
else:
|
||||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
print(f"Error building external data processor/report (code: {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
@@ -499,9 +537,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Build completed successfully: {args.OutputFile}")
|
print(f"Build completed successfully: {args.OutputFile}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||||
else:
|
else:
|
||||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
print(f"Error building (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -163,14 +163,35 @@ function Format-ArgsForDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- 1. Scan XML files for reference types ---
|
# --- 1. Scan XML files for reference types ---
|
||||||
|
|
||||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||||
|
|
||||||
|
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
|
||||||
|
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
|
||||||
|
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
|
||||||
|
# запасной вариант, если корень почему-то не попался.
|
||||||
|
$srcRootVersion = ""
|
||||||
|
$srcAnyVersion = ""
|
||||||
|
|
||||||
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
||||||
foreach ($f in $xmlFiles) {
|
foreach ($f in $xmlFiles) {
|
||||||
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
|
||||||
|
$ver = $Matches[1]
|
||||||
|
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
|
||||||
|
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
|
||||||
|
$srcRootVersion = $ver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
||||||
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
||||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||||
@@ -337,7 +358,24 @@ if ($hasRefTypes) {
|
|||||||
$cfgDir = Join-Path $TempBasePath "cfg"
|
$cfgDir = Join-Path $TempBasePath "cfg"
|
||||||
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
||||||
|
|
||||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
# Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||||
|
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||||
|
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||||
|
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||||
|
# формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||||
|
#
|
||||||
|
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
|
||||||
|
$srcRank = Get-FormatRank $srcVersion
|
||||||
|
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
|
||||||
|
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
|
||||||
|
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||||
|
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||||
|
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
|
||||||
|
# формата из docs/1c-configuration-spec.md.
|
||||||
|
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
|
||||||
|
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
|
||||||
|
|
||||||
|
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="' + $stubFormatVersion + '"'
|
||||||
|
|
||||||
# GeneratedType definitions per metadata type
|
# GeneratedType definitions per metadata type
|
||||||
$gtDefs = @{
|
$gtDefs = @{
|
||||||
@@ -521,7 +559,7 @@ if ($hasRefTypes) {
|
|||||||
<Synonym/>
|
<Synonym/>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
<NamePrefix/>
|
<NamePrefix/>
|
||||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
<ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
|
||||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
<UsePurposes>
|
<UsePurposes>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
@@ -572,7 +610,7 @@ if ($hasRefTypes) {
|
|||||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
<CompatibilityMode>$stubCompatMode</CompatibilityMode>
|
||||||
<DefaultConstantsForm/>
|
<DefaultConstantsForm/>
|
||||||
</Properties>
|
</Properties>
|
||||||
<ChildObjects>$childXml
|
<ChildObjects>$childXml
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -339,6 +339,66 @@ def scan_ref_types(source_dir):
|
|||||||
return type_map
|
return type_map
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
def detect_stub_format_version(source_dir):
|
||||||
|
"""Версия формата заглушечной конфигурации.
|
||||||
|
|
||||||
|
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||||
|
одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||||
|
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||||
|
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||||
|
формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||||
|
|
||||||
|
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
|
||||||
|
вложенные файлы — запасной вариант, если корень почему-то не попался.
|
||||||
|
"""
|
||||||
|
root_version = ""
|
||||||
|
any_version = ""
|
||||||
|
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
|
||||||
|
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
|
||||||
|
for dirpath, _, filenames in os.walk(source_dir):
|
||||||
|
for fn in filenames:
|
||||||
|
if not fn.endswith('.xml'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
m = ver_pattern.search(content)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
if not any_version:
|
||||||
|
any_version = m.group(1)
|
||||||
|
if not root_version and root_pattern.search(content):
|
||||||
|
root_version = m.group(1)
|
||||||
|
src_version = root_version or any_version or "2.17"
|
||||||
|
src_rank = format_rank(src_version)
|
||||||
|
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
|
||||||
|
|
||||||
|
|
||||||
|
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
|
||||||
|
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||||
|
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||||
|
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
|
||||||
|
# из docs/1c-configuration-spec.md.
|
||||||
|
COMPAT_BY_FORMAT = {
|
||||||
|
"2.13": "Version8_3_20",
|
||||||
|
"2.14": "Version8_3_21",
|
||||||
|
"2.15": "Version8_3_22",
|
||||||
|
"2.16": "Version8_3_23",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stub_compatibility_mode(format_version):
|
||||||
|
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
|
||||||
|
|
||||||
|
|
||||||
def scan_register_columns(source_dir):
|
def scan_register_columns(source_dir):
|
||||||
"""Scan Form.xml for register record set columns referenced via DataPath.
|
"""Scan Form.xml for register record set columns referenced via DataPath.
|
||||||
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
||||||
@@ -417,7 +477,7 @@ NS = (
|
|||||||
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
||||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
)
|
)
|
||||||
|
|
||||||
CLASS_IDS = [
|
CLASS_IDS = [
|
||||||
@@ -1046,6 +1106,9 @@ def main():
|
|||||||
type_map = scan_ref_types(args.SourceDir)
|
type_map = scan_ref_types(args.SourceDir)
|
||||||
register_columns = scan_register_columns(args.SourceDir)
|
register_columns = scan_register_columns(args.SourceDir)
|
||||||
has_ref_types = len(type_map) > 0
|
has_ref_types = len(type_map) > 0
|
||||||
|
stub_format_version = detect_stub_format_version(args.SourceDir)
|
||||||
|
stub_compat = stub_compatibility_mode(stub_format_version)
|
||||||
|
ns_decl = f'{NS} version="{stub_format_version}"'
|
||||||
|
|
||||||
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
||||||
|
|
||||||
@@ -1077,7 +1140,7 @@ def main():
|
|||||||
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
||||||
|
|
||||||
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<Configuration uuid="{uuid_cfg}">
|
\t<Configuration uuid="{uuid_cfg}">
|
||||||
\t\t<InternalInfo>{co_xml}
|
\t\t<InternalInfo>{co_xml}
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
@@ -1086,7 +1149,7 @@ def main():
|
|||||||
\t\t\t<Synonym/>
|
\t\t\t<Synonym/>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
\t\t\t<NamePrefix/>
|
\t\t\t<NamePrefix/>
|
||||||
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
\t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
|
||||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
\t\t\t<UsePurposes>
|
\t\t\t<UsePurposes>
|
||||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
@@ -1137,7 +1200,7 @@ def main():
|
|||||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
\t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
|
||||||
\t\t\t<DefaultConstantsForm/>
|
\t\t\t<DefaultConstantsForm/>
|
||||||
\t\t</Properties>
|
\t\t</Properties>
|
||||||
\t\t<ChildObjects>{child_xml}
|
\t\t<ChildObjects>{child_xml}
|
||||||
@@ -1151,7 +1214,7 @@ def main():
|
|||||||
lang_dir = os.path.join(cfg_dir, 'Languages')
|
lang_dir = os.path.join(cfg_dir, 'Languages')
|
||||||
os.makedirs(lang_dir, exist_ok=True)
|
os.makedirs(lang_dir, exist_ok=True)
|
||||||
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<Language uuid="{uuid_lang}">
|
\t<Language uuid="{uuid_lang}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
||||||
@@ -1280,7 +1343,7 @@ def main():
|
|||||||
child_obj_xml = '\n\t\t<ChildObjects/>'
|
child_obj_xml = '\n\t\t<ChildObjects/>'
|
||||||
|
|
||||||
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
{props_xml}
|
{props_xml}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
print(
|
print(
|
||||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||||
f"({param} cannot extend the ibcmd command)",
|
f"({param} cannot extend the ibcmd command)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for k in owned:
|
for k in owned:
|
||||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
|||||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||||
print(
|
print(
|
||||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
|||||||
print(
|
print(
|
||||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||||
"(use -AdditionalIbcmdArguments)",
|
"(use -AdditionalIbcmdArguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine != "ibcmd" and ibcmd_extra:
|
if engine != "ibcmd" and ibcmd_extra:
|
||||||
print(
|
print(
|
||||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||||
"(use -AdditionalV8Arguments)",
|
"(use -AdditionalV8Arguments)",
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
|||||||
v8path = max(candidates, key=_version_key)
|
v8path = max(candidates, key=_version_key)
|
||||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||||
else:
|
else:
|
||||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
print("Error: 1C executable not found. Specify -V8Path")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if os.path.isdir(v8path):
|
if os.path.isdir(v8path):
|
||||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||||
v8path = os.path.join(v8path, exe)
|
v8path = os.path.join(v8path, exe)
|
||||||
if not os.path.isfile(v8path):
|
if not os.path.isfile(v8path):
|
||||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
print(f"Error: 1C executable not found at {v8path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
|||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
|||||||
if len(v) > 3 and v[-1] in "\\/":
|
if len(v) > 3 and v[-1] in "\\/":
|
||||||
v = v[:-1]
|
v = v[:-1]
|
||||||
if '"' in v:
|
if '"' in v:
|
||||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
|||||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||||
escape those quotes, so there the command line is handed over ready-made.
|
escape those quotes, so there the command line is handed over ready-made.
|
||||||
|
|
||||||
|
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||||
|
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||||
|
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||||
|
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||||
|
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||||
"""
|
"""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||||
else:
|
else:
|
||||||
cmd = [v8path] + arguments
|
def strip_framing_quotes(a):
|
||||||
|
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||||
|
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||||
|
# токеном даёт «Неопределена информационная база», а склеенный
|
||||||
|
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||||
|
# без кавычек обе формы работают.
|
||||||
|
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||||
|
return a[1:-1] # "значение" отдельным токеном
|
||||||
|
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||||
|
i = a.index('"')
|
||||||
|
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||||
|
return a # File="…" не трогаем: там кавычки —
|
||||||
|
# часть синтаксиса строки соединения,
|
||||||
|
# и с ними на POSIX всё работает
|
||||||
|
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
r.stderr = decode_platform_bytes(r.stderr)
|
r.stderr = decode_platform_bytes(r.stderr)
|
||||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||||
"""
|
"""
|
||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||||
r.stdout = decode_platform_bytes(r.stdout)
|
r.stdout = decode_platform_bytes(r.stdout)
|
||||||
@@ -380,7 +418,7 @@ def main():
|
|||||||
help="Extra ibcmd arguments in --key=value form")
|
help="Extra ibcmd arguments in --key=value form")
|
||||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||||
args = parser.parse_args(argv)
|
args = ci_parse_args(parser, argv)
|
||||||
|
|
||||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||||
@@ -406,20 +444,20 @@ def main():
|
|||||||
|
|
||||||
# --- Validate database connection ---
|
# --- Validate database connection ---
|
||||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
|
||||||
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args.Format == "Plain":
|
if args.Format == "Plain":
|
||||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Validate input file ---
|
# --- Validate input file ---
|
||||||
if not os.path.isfile(args.InputFile):
|
if not os.path.isfile(args.InputFile):
|
||||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
print(f"Error: input file not found: {args.InputFile}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Ensure output directory exists ---
|
# --- Ensure output directory exists ---
|
||||||
@@ -451,9 +489,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping external data processor/report (code: {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
@@ -491,9 +529,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||||
elif out_missing:
|
elif out_missing:
|
||||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping (code: {exit_code})")
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -18,19 +18,26 @@ allowed-tools:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/epf-init <Name> [Synonym] [SrcDir]
|
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Обязательный | По умолчанию | Описание |
|
| Параметр | Обязательный | По умолчанию | Описание |
|
||||||
|-----------|:------------:|--------------|-------------------------------------|
|
|---------------|:------------:|--------------|------------------------------------------------|
|
||||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
| 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
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"]
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Дальнейшие шаги
|
## Дальнейшие шаги
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
# 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -6,21 +6,65 @@ param(
|
|||||||
|
|
||||||
[string]$Synonym = $Name,
|
[string]$Synonym = $Name,
|
||||||
|
|
||||||
[string]$SrcDir = "src"
|
[string]$SrcDir = "src",
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
[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
|
||||||
[Console]::InputEncoding = [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()
|
$uuid1 = [guid]::NewGuid().ToString()
|
||||||
$uuid2 = [guid]::NewGuid().ToString()
|
$uuid2 = [guid]::NewGuid().ToString()
|
||||||
$uuid3 = [guid]::NewGuid().ToString()
|
$uuid3 = [guid]::NewGuid().ToString()
|
||||||
$uuid4 = [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 = @"
|
||||||
<?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="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<ExternalDataProcessor uuid="$uuid1">
|
<ExternalDataProcessor uuid="$uuid1">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -33,11 +77,11 @@ $xml = @"
|
|||||||
</xr:GeneratedType>
|
</xr:GeneratedType>
|
||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$Name</Name>
|
<Name>$(Esc-XmlText $Name)</Name>
|
||||||
<Synonym>
|
<Synonym>
|
||||||
<v8:item>
|
<v8:item>
|
||||||
<v8:lang>ru</v8:lang>
|
<v8:lang>ru</v8:lang>
|
||||||
<v8:content>$Synonym</v8:content>
|
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||||
</v8:item>
|
</v8:item>
|
||||||
</Synonym>
|
</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
@@ -64,7 +108,16 @@ $extDir = Join-Path $processorDir "Ext"
|
|||||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $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 (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
|
|
||||||
@@ -83,6 +136,11 @@ $moduleBsl = @"
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
$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)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||||
|
|
||||||
Write-Host "[OK] Создана обработка: $rootFile"
|
Write-Host "[OK] Создана обработка: $rootFile"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
# 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Generates minimal XML source files for a 1C external data processor."""
|
"""Generates minimal XML source files for a 1C external data processor."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, re, argparse, uuid
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -21,7 +67,24 @@ def main():
|
|||||||
parser.add_argument('-Name', dest='Name', required=True)
|
parser.add_argument('-Name', dest='Name', required=True)
|
||||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||||
args = parser.parse_args()
|
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||||
|
# навыки берут уже отсюда: их детектор читает 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
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -32,8 +95,36 @@ def main():
|
|||||||
uuid3 = new_uuid()
|
uuid3 = new_uuid()
|
||||||
uuid4 = 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"?>
|
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="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
\t\t\t<xr:ContainedObject>
|
\t\t\t<xr:ContainedObject>
|
||||||
@@ -46,11 +137,11 @@ def main():
|
|||||||
\t\t\t</xr:GeneratedType>
|
\t\t\t</xr:GeneratedType>
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>
|
\t\t\t<Synonym>
|
||||||
\t\t\t\t<v8:item>
|
\t\t\t\t<v8:item>
|
||||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||||
\t\t\t\t</v8:item>
|
\t\t\t\t</v8:item>
|
||||||
\t\t\t</Synonym>
|
\t\t\t</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
@@ -72,7 +163,7 @@ def main():
|
|||||||
ext_dir = os.path.join(processor_dir, "Ext")
|
ext_dir = os.path.join(processor_dir, "Ext")
|
||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_dir, exist_ok=True)
|
||||||
|
|
||||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
module_bsl = """\
|
module_bsl = """\
|
||||||
@@ -89,7 +180,10 @@ def main():
|
|||||||
#КонецОбласти"""
|
#КонецОбласти"""
|
||||||
|
|
||||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||||
write_utf8_bom(module_path, module_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"[OK] Создана обработка: {root_file}")
|
||||||
print(f" Каталог: {processor_dir}")
|
print(f" Каталог: {processor_dir}")
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
# epf-validate v1.6 — Validate 1C external data processor / report structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory, Position=0)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
[string]$ObjectPath,
|
[string]$ObjectPath,
|
||||||
|
|
||||||
@@ -111,6 +112,19 @@ $finalize = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- Reference tables ---
|
# --- Reference tables ---
|
||||||
|
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
@@ -183,11 +197,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
} elseif ($versionRank -eq 0) {
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||||
|
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Detect type: ExternalDataProcessor or ExternalReport
|
# Detect type: ExternalDataProcessor or ExternalReport
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
# epf-validate v1.6 — Validate 1C external data processor / report structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||||
|
|
||||||
@@ -10,6 +10,28 @@ import sys
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||||
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
def localname(el):
|
def localname(el):
|
||||||
return etree.QName(el.tag).localname
|
return etree.QName(el.tag).localname
|
||||||
|
|
||||||
@@ -50,7 +87,7 @@ def main():
|
|||||||
parser.add_argument("-Detailed", action="store_true")
|
parser.add_argument("-Detailed", action="store_true")
|
||||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||||
parser.add_argument("-OutFile", default=None)
|
parser.add_argument("-OutFile", default=None)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
|
|
||||||
@@ -163,11 +200,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get("version", "")
|
version = root.get("version", "")
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
report_warn("1. Missing version attribute on MetaDataObject")
|
report_warn("1. Missing version attribute on MetaDataObject")
|
||||||
elif version not in ("2.17", "2.18", "2.19", "2.20", "2.21"):
|
elif version_rank == 0:
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
report_error(f"1. Malformed version '{version}' (expected N.N)")
|
||||||
report_warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||||
|
report_warn(f"1. Format version '{version}' is below the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||||
|
report_warn(f"1. Format version '{version}' is above the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
|
||||||
# Detect type
|
# Detect type
|
||||||
child_elements = []
|
child_elements = []
|
||||||
|
|||||||
@@ -18,20 +18,27 @@ allowed-tools:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/erf-init <Name> [Synonym] [SrcDir] [--with-skd]
|
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Обязательный | По умолчанию | Описание |
|
| Параметр | Обязательный | По умолчанию | Описание |
|
||||||
|-----------|:------------:|--------------|---------------------------------------|
|
|---------------|:------------:|--------------|---------------------------------------|
|
||||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||||
|
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
| --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
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-WithSKD]
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Дальнейшие шаги
|
## Дальнейшие шаги
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# erf-init v1.1 — Init 1C external report scaffold
|
# erf-init v1.8 — Init 1C external report 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
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -8,18 +8,62 @@ param(
|
|||||||
|
|
||||||
[string]$SrcDir = "src",
|
[string]$SrcDir = "src",
|
||||||
|
|
||||||
[switch]$WithSKD
|
[switch]$WithSKD,
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
[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
|
||||||
[Console]::InputEncoding = [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()
|
$uuid1 = [guid]::NewGuid().ToString()
|
||||||
$uuid2 = [guid]::NewGuid().ToString()
|
$uuid2 = [guid]::NewGuid().ToString()
|
||||||
$uuid3 = [guid]::NewGuid().ToString()
|
$uuid3 = [guid]::NewGuid().ToString()
|
||||||
$uuid4 = [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 ---
|
# --- Формируем Properties ---
|
||||||
|
|
||||||
$mainDCSValue = ""
|
$mainDCSValue = ""
|
||||||
@@ -48,7 +92,7 @@ $childObjectsXml = if ($childObjectsContent) {
|
|||||||
|
|
||||||
$xml = @"
|
$xml = @"
|
||||||
<?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="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<ExternalReport uuid="$uuid1">
|
<ExternalReport uuid="$uuid1">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -61,11 +105,11 @@ $xml = @"
|
|||||||
</xr:GeneratedType>
|
</xr:GeneratedType>
|
||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$Name</Name>
|
<Name>$(Esc-XmlText $Name)</Name>
|
||||||
<Synonym>
|
<Synonym>
|
||||||
<v8:item>
|
<v8:item>
|
||||||
<v8:lang>ru</v8:lang>
|
<v8:lang>ru</v8:lang>
|
||||||
<v8:content>$Synonym</v8:content>
|
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||||
</v8:item>
|
</v8:item>
|
||||||
</Synonym>
|
</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
@@ -98,7 +142,16 @@ $extDir = Join-Path $reportDir "Ext"
|
|||||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $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 (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
|
|
||||||
@@ -117,6 +170,11 @@ $moduleBsl = @"
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
$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)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||||
|
|
||||||
Write-Host "[OK] Создан отчёт: $rootFile"
|
Write-Host "[OK] Создан отчёт: $rootFile"
|
||||||
@@ -136,7 +194,7 @@ if ($WithSKD) {
|
|||||||
|
|
||||||
$skdMetaXml = @"
|
$skdMetaXml = @"
|
||||||
<?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="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<Template uuid="$skdUuid">
|
<Template uuid="$skdUuid">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$skdName</Name>
|
<Name>$skdName</Name>
|
||||||
@@ -153,7 +211,7 @@ if ($WithSKD) {
|
|||||||
</MetaDataObject>
|
</MetaDataObject>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($skdMetaPath, $skdMetaXml, $enc)
|
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||||
|
|
||||||
$skdContent = @"
|
$skdContent = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -173,7 +231,7 @@ if ($WithSKD) {
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||||
[System.IO.File]::WriteAllText($skdFilePath, $skdContent, $enc)
|
Write-XmlFile $skdFilePath $skdContent $enc
|
||||||
|
|
||||||
Write-Host " СКД: $skdMetaPath"
|
Write-Host " СКД: $skdMetaPath"
|
||||||
Write-Host " Тело: $skdFilePath"
|
Write-Host " Тело: $skdFilePath"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# erf-init v1.1 — Init 1C external report scaffold
|
# erf-init v1.8 — Init 1C external report 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
|
||||||
"""Generates minimal XML source files for a 1C external report."""
|
"""Generates minimal XML source files for a 1C external report."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, re, argparse, uuid
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -21,8 +67,25 @@ def main():
|
|||||||
parser.add_argument('-Name', dest='Name', required=True)
|
parser.add_argument('-Name', dest='Name', required=True)
|
||||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
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')
|
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
||||||
args = parser.parse_args()
|
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
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -33,6 +96,34 @@ def main():
|
|||||||
uuid3 = new_uuid()
|
uuid3 = new_uuid()
|
||||||
uuid4 = 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 ---
|
# --- Properties ---
|
||||||
main_dcs_value = ""
|
main_dcs_value = ""
|
||||||
child_objects_content = ""
|
child_objects_content = ""
|
||||||
@@ -45,7 +136,7 @@ def main():
|
|||||||
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
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"?>
|
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="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<ExternalReport uuid="{uuid1}">
|
\t<ExternalReport uuid="{uuid1}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
\t\t\t<xr:ContainedObject>
|
\t\t\t<xr:ContainedObject>
|
||||||
@@ -58,11 +149,11 @@ def main():
|
|||||||
\t\t\t</xr:GeneratedType>
|
\t\t\t</xr:GeneratedType>
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>
|
\t\t\t<Synonym>
|
||||||
\t\t\t\t<v8:item>
|
\t\t\t\t<v8:item>
|
||||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||||
\t\t\t\t</v8:item>
|
\t\t\t\t</v8:item>
|
||||||
\t\t\t</Synonym>
|
\t\t\t</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
@@ -90,7 +181,7 @@ def main():
|
|||||||
ext_dir = os.path.join(report_dir, "Ext")
|
ext_dir = os.path.join(report_dir, "Ext")
|
||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_dir, exist_ok=True)
|
||||||
|
|
||||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
module_bsl = """\
|
module_bsl = """\
|
||||||
@@ -107,7 +198,10 @@ def main():
|
|||||||
#КонецОбласти"""
|
#КонецОбласти"""
|
||||||
|
|
||||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||||
write_utf8_bom(module_path, module_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"[OK] Создан отчёт: {root_file}")
|
||||||
print(f" Каталог: {report_dir}")
|
print(f" Каталог: {report_dir}")
|
||||||
@@ -124,7 +218,7 @@ def main():
|
|||||||
skd_uuid = new_uuid()
|
skd_uuid = new_uuid()
|
||||||
|
|
||||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
skd_meta_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="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<Template uuid="{skd_uuid}">
|
\t<Template uuid="{skd_uuid}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{skd_name}</Name>
|
\t\t\t<Name>{skd_name}</Name>
|
||||||
@@ -140,7 +234,7 @@ def main():
|
|||||||
\t</Template>
|
\t</Template>
|
||||||
</MetaDataObject>'''
|
</MetaDataObject>'''
|
||||||
|
|
||||||
write_utf8_bom(skd_meta_path, skd_meta_xml)
|
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||||
|
|
||||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||||
@@ -158,7 +252,7 @@ def main():
|
|||||||
</DataCompositionSchema>'''
|
</DataCompositionSchema>'''
|
||||||
|
|
||||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||||
write_utf8_bom(skd_file_path, skd_content)
|
write_xml_file(skd_file_path, skd_content)
|
||||||
|
|
||||||
print(f" СКД: {skd_meta_path}")
|
print(f" СКД: {skd_meta_path}")
|
||||||
print(f" Тело: {skd_file_path}")
|
print(f" Тело: {skd_file_path}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: form-add
|
name: form-add
|
||||||
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
|
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
|
||||||
argument-hint: <ObjectPath> <FormName> [Purpose] [--set-default]
|
argument-hint: <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
@@ -18,16 +18,16 @@ allowed-tools:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/form-add <ObjectPath> <FormName> [Purpose] [Synonym] [--set-default]
|
/form-add <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Обязательный | По умолчанию | Описание |
|
| Параметр | Обязательный | По умолчанию | Описание |
|
||||||
|-------------|:------------:|--------------|----------------------------------------------|
|
|-------------|:------------:|--------------|----------------------------------------------|
|
||||||
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
|
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
|
||||||
| FormName | да | — | Имя формы (ФормаДокумента) |
|
| FormName | да | — | Имя формы (ФормаДокумента) |
|
||||||
| Purpose | нет | Object | Назначение: Object, List, Choice, Record |
|
| Purpose | нет | основная форма вида | Назначение формы — см. таблицу ниже: у справочника это форма объекта, у регистра сведений — форма записи, у журнала — форма списка |
|
||||||
| Synonym | нет | = FormName | Синоним формы |
|
| Synonym | нет | = FormName | Синоним формы |
|
||||||
| --set-default | нет | авто | Установить как форму по умолчанию |
|
| -SetDefault | нет | авто | Сделать основной. Без флага основной становится первая форма каждого назначения |
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
@@ -37,30 +37,52 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-add.ps1" -Obje
|
|||||||
|
|
||||||
## Purpose — назначение формы
|
## Purpose — назначение формы
|
||||||
|
|
||||||
| Purpose | Допустимые типы объектов | Основной реквизит | DefaultForm-свойство |
|
| Purpose | Какая форма | Становится основной |
|
||||||
|---------|-------------------------|-------------------|---------------------|
|
|---------|-------------|---------------------|
|
||||||
| Object | Document, Catalog, DataProcessor, Report, ExternalDataProcessor, ExternalReport, ChartOf*, ExchangePlan, BusinessProcess, Task | Объект (тип: *Object.Имя) | DefaultObjectForm (DefaultForm для DataProcessor/Report/ExternalDataProcessor/ExternalReport) |
|
| Object | форма объекта (элемента, документа, обработки) | да |
|
||||||
| List | Все кроме DataProcessor | Список (DynamicList) | DefaultListForm |
|
| List | форма списка | да |
|
||||||
| Choice | Document, Catalog, ChartOf*, ExchangePlan, BusinessProcess, Task | Список (DynamicList) | DefaultChoiceForm |
|
| Choice | форма выбора | да |
|
||||||
| Record | InformationRegister | Запись (InformationRegisterRecordManager) | DefaultRecordForm |
|
| 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 Documents/АвансовыйОтчет.xml ФормаДокумента -Purpose Object
|
||||||
|
|
||||||
# Форма списка каталога
|
# Форма списка каталога
|
||||||
/form-add Catalogs/Контрагенты.xml ФормаСписка --purpose List
|
/form-add Catalogs/Контрагенты.xml ФормаСписка -Purpose List
|
||||||
|
|
||||||
# Форма записи регистра сведений
|
# Форма записи регистра сведений
|
||||||
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи --purpose Record
|
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи -Purpose Record
|
||||||
|
|
||||||
# Форма выбора с синонимом
|
# Форма выбора с синонимом
|
||||||
/form-add Catalogs/Номенклатура.xml ФормаВыбора --purpose Choice --synonym "Выбор номенклатуры"
|
/form-add Catalogs/Номенклатура.xml ФормаВыбора -Purpose Choice -Synonym "Выбор номенклатуры"
|
||||||
|
|
||||||
# Установить как форму по умолчанию
|
# Установить как форму по умолчанию
|
||||||
/form-add Documents/Заказ.xml ФормаДокументаНовая --purpose Object --set-default
|
/form-add Documents/Заказ.xml ФормаДокументаНовая -Purpose Object -SetDefault
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# form-add v1.12 — Add managed form to 1C config object
|
# form-add v1.28 — Add managed form to 1C config object
|
||||||
# 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]$ObjectPath,
|
[string]$ObjectPath,
|
||||||
@@ -9,8 +10,15 @@ param(
|
|||||||
|
|
||||||
[string]$Synonym = $FormName,
|
[string]$Synonym = $FormName,
|
||||||
|
|
||||||
[string]$Purpose = "Object",
|
# Пусто = основная форма вида (Primary в таблице): у справочника это форма объекта,
|
||||||
|
# у регистра сведений — форма записи, у журнала — форма списка. Жёсткое "Object"
|
||||||
|
# по умолчанию было бы неверным для видов, у которых формы объекта не бывает.
|
||||||
|
[string]$Purpose = "",
|
||||||
|
|
||||||
|
# Алиас с дефисом внутри имени: вызов вида --set-default PowerShell разбирает как имя
|
||||||
|
# параметра "set-default" и без алиаса отвечает отказом биндинга. Написания -SetDefault,
|
||||||
|
# --SetDefault и --setdefault совпадают с именем параметра и так.
|
||||||
|
[Alias('set-default')]
|
||||||
[switch]$SetDefault
|
[switch]$SetDefault
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,6 +162,14 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
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"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
@@ -169,6 +185,13 @@ function Detect-FormatVersion([string]$dir) {
|
|||||||
return "2.17"
|
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: Определение типа объекта ---
|
# --- Фаза 1: Определение типа объекта ---
|
||||||
|
|
||||||
# Resolve ObjectPath (directory → .xml)
|
# Resolve ObjectPath (directory → .xml)
|
||||||
@@ -190,7 +213,26 @@ if (-not (Test-Path $ObjectPath)) {
|
|||||||
|
|
||||||
$objectXmlFull = Resolve-Path $ObjectPath
|
$objectXmlFull = Resolve-Path $ObjectPath
|
||||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||||
|
# внешней обработки/отчёта подниматься к 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 = New-Object System.Xml.XmlDocument
|
||||||
$xmlDoc.PreserveWhitespace = $true
|
$xmlDoc.PreserveWhitespace = $true
|
||||||
@@ -207,26 +249,166 @@ if (-not $metaDataObject) {
|
|||||||
$metaDataObject = $xmlDoc.DocumentElement
|
$metaDataObject = $xmlDoc.DocumentElement
|
||||||
}
|
}
|
||||||
|
|
||||||
$supportedTypes = @(
|
# --- Таблица видов: вид → допустимые назначения ---
|
||||||
"Document", "Catalog", "DataProcessor", "Report",
|
#
|
||||||
"ExternalDataProcessor", "ExternalReport",
|
# Одна запись на вид вместо разрозненных списков «поддерживаемые типы», «объектные типы»,
|
||||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
# «обработко-подобные» и «карта типов реквизита». Раньше они расходились молча: DocumentJournal
|
||||||
"ExchangePlan", "BusinessProcess", "Task", "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
|
$objectType = $null
|
||||||
$objectNode = $null
|
$objectNode = $null
|
||||||
foreach ($t in $supportedTypes) {
|
foreach ($child in $metaDataObject.ChildNodes) {
|
||||||
$node = $xmlDoc.SelectSingleNode("//md:$t", $nsMgr)
|
if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||||
if ($node) {
|
$objectType = $child.LocalName
|
||||||
$objectType = $t
|
$objectNode = $child
|
||||||
$objectNode = $node
|
|
||||||
break
|
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) {
|
if (-not $objectType) {
|
||||||
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $($supportedTypes -join ', ')"
|
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($noOwnForms.ContainsKey($objectType)) {
|
||||||
|
Write-Error "$objectType не поддерживается: $($noOwnForms[$objectType])"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,44 +426,58 @@ Write-Host "Object: $objectType.$objectName"
|
|||||||
|
|
||||||
# --- Фаза 2: Валидация Purpose ---
|
# --- Фаза 2: Валидация Purpose ---
|
||||||
|
|
||||||
$Purpose = $Purpose.Substring(0,1).ToUpper() + $Purpose.Substring(1).ToLower()
|
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell (в py-порту .lower()).
|
||||||
# Нормализация
|
$kindPurposes = $formKinds[$objectType]
|
||||||
switch ($Purpose) {
|
|
||||||
"Object" { }
|
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||||
"List" { }
|
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||||
"Choice" { }
|
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||||
"Record" { }
|
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||||
default {
|
$purposeSynonyms = @{
|
||||||
Write-Error "Недопустимое назначение: $Purpose. Допустимые: Object, List, Choice, Record"
|
"формаобъекта"="Object"; "формаэлемента"="Object"; "формадокумента"="Object"
|
||||||
exit 1
|
"объект"="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]
|
||||||
|
|
||||||
$objectLikeTypes = @("Document", "Catalog", "ChartOfAccounts", "ChartOfCharacteristicTypes", "ExchangePlan", "BusinessProcess", "Task")
|
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой MainAttr — это
|
||||||
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
|
# произвольная форма (законное состояние), а вот наполовину заполненная запись означала бы, что
|
||||||
|
# таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||||
switch ($Purpose) {
|
if ($purposeRule.MainAttr -and -not $purposeRule.AttrName) {
|
||||||
"Object" {
|
Write-Error "Внутренняя ошибка таблицы видов: у $objectType/$Purpose задан MainAttr без AttrName"
|
||||||
# допустимо для всех типов
|
|
||||||
}
|
|
||||||
"List" {
|
|
||||||
if ($objectType -eq "DataProcessor") {
|
|
||||||
Write-Error "Purpose=List недопустим для DataProcessor"
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
|
||||||
}
|
|
||||||
"Choice" {
|
|
||||||
if ($objectType -in $processorLikeTypes -or $objectType -eq "InformationRegister") {
|
|
||||||
Write-Error "Purpose=Choice недопустим для $objectType"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"Record" {
|
|
||||||
if ($objectType -ne "InformationRegister") {
|
|
||||||
Write-Error "Purpose=Record допустим только для InformationRegister"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Фаза 3: Создание файлов ---
|
# --- Фаза 3: Создание файлов ---
|
||||||
@@ -313,9 +509,16 @@ if ($objectType -in $processorLikeTypes) {
|
|||||||
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
$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 = @"
|
$formMetaXml = @"
|
||||||
<?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="$($script:formatVersion)">
|
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||||
<Form uuid="$formUuid">
|
<Form uuid="$formUuid">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$FormName</Name>
|
<Name>$FormName</Name>
|
||||||
@@ -331,120 +534,74 @@ $formMetaXml = @"
|
|||||||
<UsePurposes>
|
<UsePurposes>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||||
</UsePurposes>$extPresentationLine
|
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||||
</Properties>
|
</Properties>
|
||||||
</Form>
|
</Form>
|
||||||
</MetaDataObject>
|
</MetaDataObject>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($formMetaPath, $formMetaXml, $encBom)
|
# 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 ---
|
# --- 3b. Form.xml ---
|
||||||
|
|
||||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||||
|
|
||||||
$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"'
|
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||||
|
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||||
|
$attributesBlock = ""
|
||||||
|
if ($purposeRule.MainAttr) {
|
||||||
|
$mainAttrType = $purposeRule.MainAttr -f $objectType, $objectName
|
||||||
|
$mainAttrName = $purposeRule.AttrName
|
||||||
|
|
||||||
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||||
# Динамический список
|
$tailLines = ""
|
||||||
# MainTable: тип.имя
|
if ($mainAttrType -eq "DynamicList") {
|
||||||
$mainTable = "$objectType.$objectName"
|
$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>"
|
||||||
|
}
|
||||||
|
|
||||||
$formXml = @"
|
$attributesBlock = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
|
||||||
<Autofill>true</Autofill>
|
|
||||||
</AutoCommandBar>
|
|
||||||
<ChildItems/>
|
|
||||||
<Attributes>
|
|
||||||
<Attribute name="Список" id="1">
|
|
||||||
<Type>
|
|
||||||
<v8:Type>cfg:DynamicList</v8:Type>
|
|
||||||
</Type>
|
|
||||||
<MainAttribute>true</MainAttribute>
|
|
||||||
<Settings xsi:type="DynamicList">
|
|
||||||
<MainTable>$mainTable</MainTable>
|
|
||||||
</Settings>
|
|
||||||
</Attribute>
|
|
||||||
</Attributes>
|
|
||||||
</Form>
|
|
||||||
"@
|
|
||||||
} elseif ($Purpose -eq "Record") {
|
|
||||||
# Запись регистра сведений
|
|
||||||
$mainAttrName = "Запись"
|
|
||||||
$mainAttrType = "InformationRegisterRecordManager.$objectName"
|
|
||||||
|
|
||||||
$formXml = @"
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
|
||||||
<Autofill>true</Autofill>
|
|
||||||
</AutoCommandBar>
|
|
||||||
<ChildItems/>
|
|
||||||
<Attributes>
|
<Attributes>
|
||||||
<Attribute name="$mainAttrName" id="1">
|
<Attribute name="$mainAttrName" id="1">
|
||||||
<Type>
|
<Type>
|
||||||
<v8:Type>cfg:$mainAttrType</v8:Type>
|
<v8:Type>cfg:$mainAttrType</v8:Type>
|
||||||
</Type>
|
</Type>
|
||||||
<MainAttribute>true</MainAttribute>
|
<MainAttribute>true</MainAttribute>$tailLines
|
||||||
<SavedData>true</SavedData>
|
|
||||||
</Attribute>
|
</Attribute>
|
||||||
</Attributes>
|
</Attributes>
|
||||||
</Form>
|
|
||||||
"@
|
|
||||||
} else {
|
|
||||||
# Object — форма объекта
|
|
||||||
$mainAttrName = "Объект"
|
|
||||||
|
|
||||||
# Маппинг типа объекта на тип реквизита
|
|
||||||
$attrTypeMap = @{
|
|
||||||
"Document" = "DocumentObject"
|
|
||||||
"Catalog" = "CatalogObject"
|
|
||||||
"DataProcessor" = "DataProcessorObject"
|
|
||||||
"Report" = "ReportObject"
|
|
||||||
"ExternalDataProcessor" = "ExternalDataProcessorObject"
|
|
||||||
"ExternalReport" = "ExternalReportObject"
|
|
||||||
"ChartOfAccounts" = "ChartOfAccountsObject"
|
|
||||||
"ChartOfCharacteristicTypes" = "ChartOfCharacteristicTypesObject"
|
|
||||||
"ExchangePlan" = "ExchangePlanObject"
|
|
||||||
"BusinessProcess" = "BusinessProcessObject"
|
|
||||||
"Task" = "TaskObject"
|
|
||||||
"InformationRegister" = "InformationRegisterRecordManager"
|
|
||||||
"AccumulationRegister" = "AccumulationRegisterRecordSet"
|
|
||||||
}
|
|
||||||
|
|
||||||
$mainAttrType = "$($attrTypeMap[$objectType]).$objectName"
|
|
||||||
|
|
||||||
# SavedData: standard for Catalog/Document/etc, but not for processor-like (DataProcessor/Report/External*)
|
|
||||||
$savedDataLine = ""
|
|
||||||
if ($objectType -notin $processorLikeTypes) {
|
|
||||||
$savedDataLine = "`n`t`t`t<SavedData>true</SavedData>"
|
|
||||||
}
|
|
||||||
|
|
||||||
$formXml = @"
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
|
||||||
<Autofill>true</Autofill>
|
|
||||||
</AutoCommandBar>
|
|
||||||
<ChildItems/>
|
|
||||||
<Attributes>
|
|
||||||
<Attribute name="$mainAttrName" id="1">
|
|
||||||
<Type>
|
|
||||||
<v8:Type>cfg:$mainAttrType</v8:Type>
|
|
||||||
</Type>
|
|
||||||
<MainAttribute>true</MainAttribute>$savedDataLine
|
|
||||||
</Attribute>
|
|
||||||
</Attributes>
|
|
||||||
</Form>
|
|
||||||
"@
|
"@
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Произвольная форма (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) {
|
if (Test-Path $formXmlPath) {
|
||||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||||
} else {
|
} else {
|
||||||
[System.IO.File]::WriteAllText($formXmlPath, $formXml, $encBom)
|
Write-XmlFile $formXmlPath $formXml $encBom
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 3c. Module.bsl ---
|
# --- 3c. Module.bsl ---
|
||||||
@@ -476,6 +633,11 @@ $moduleBsl = @"
|
|||||||
if (Test-Path $modulePath) {
|
if (Test-Path $modulePath) {
|
||||||
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
||||||
} else {
|
} 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)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,24 +715,17 @@ $isFirstFormForPurpose = $false
|
|||||||
$defaultPropName = $null
|
$defaultPropName = $null
|
||||||
$defaultValue = "$objectType.$objectName.Form.$FormName"
|
$defaultValue = "$objectType.$objectName.Form.$FormName"
|
||||||
|
|
||||||
# Определяем имя свойства для DefaultForm
|
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному Purpose без учёта
|
||||||
switch ($Purpose) {
|
# вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не находился, навык
|
||||||
"Object" {
|
# молча ничего не делал.
|
||||||
if ($objectType -in $processorLikeTypes) {
|
$defaultPropName = $purposeRule.Slot
|
||||||
$defaultPropName = "DefaultForm"
|
|
||||||
} else {
|
|
||||||
$defaultPropName = "DefaultObjectForm"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"List" { $defaultPropName = "DefaultListForm" }
|
|
||||||
"Choice" { $defaultPropName = "DefaultChoiceForm" }
|
|
||||||
"Record" { $defaultPropName = "DefaultRecordForm" }
|
|
||||||
}
|
|
||||||
|
|
||||||
# Проверяем, установлено ли уже значение
|
$defaultNode = $null
|
||||||
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
|
if ($defaultPropName) {
|
||||||
if ($defaultNode) {
|
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
|
||||||
|
if ($defaultNode) {
|
||||||
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
|
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultUpdated = $false
|
$defaultUpdated = $false
|
||||||
@@ -585,12 +740,27 @@ if ($SetDefault -or $isFirstFormForPurpose) {
|
|||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = $encBom
|
$settings.Encoding = $encBom
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
|
|
||||||
$stream = New-Object System.IO.FileStream($objectXmlFull.Path, [System.IO.FileMode]::Create)
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
$xmlDoc.Save($writer)
|
$xmlDoc.Save($writer)
|
||||||
$writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
$stream.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: Вывод ---
|
# --- Фаза 5: Вывод ---
|
||||||
|
|
||||||
@@ -617,5 +787,9 @@ if ($alreadyRegistered) {
|
|||||||
}
|
}
|
||||||
if ($defaultUpdated) {
|
if ($defaultUpdated) {
|
||||||
Write-Host "${defaultPropName}: $defaultValue"
|
Write-Host "${defaultPropName}: $defaultValue"
|
||||||
|
} elseif (-not $defaultPropName) {
|
||||||
|
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||||
|
# у платформы нет (форма набора записей, произвольная форма).
|
||||||
|
Write-Host "Основной не назначена: у $objectType нет свойства для формы с назначением $Purpose"
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-add v1.12 — Add managed form to 1C config object
|
# form-add v1.28 — Add managed form to 1C config object (Python port)
|
||||||
# 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 uuid
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
@@ -196,6 +218,16 @@ NSMAP = {
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while 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")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -210,6 +242,12 @@ def detect_format_version(d):
|
|||||||
return "2.17"
|
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):
|
def _detect_xml_style(path):
|
||||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
@@ -227,21 +265,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
|
||||||
|
|
||||||
@@ -257,10 +296,24 @@ def save_xml_with_bom(tree, path):
|
|||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|
||||||
def write_text_with_bom(path, text):
|
def write_utf8_bom(path, content):
|
||||||
"""Write text to file with UTF-8 BOM."""
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
with open(path, "w", encoding="utf-8-sig") as f:
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
f.write(text)
|
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():
|
def main():
|
||||||
@@ -270,9 +323,14 @@ def main():
|
|||||||
parser.add_argument("-ObjectPath", required=True)
|
parser.add_argument("-ObjectPath", required=True)
|
||||||
parser.add_argument("-FormName", required=True)
|
parser.add_argument("-FormName", required=True)
|
||||||
parser.add_argument("-Synonym", default=None)
|
parser.add_argument("-Synonym", default=None)
|
||||||
parser.add_argument("-Purpose", default="Object")
|
# Пусто = основная форма вида (primary в таблице): у справочника это форма объекта,
|
||||||
parser.add_argument("-SetDefault", action="store_true")
|
# у регистра сведений — форма записи, у журнала — форма списка.
|
||||||
args = parser.parse_args()
|
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
|
object_path = args.ObjectPath
|
||||||
form_name = args.FormName
|
form_name = args.FormName
|
||||||
@@ -299,30 +357,244 @@ def main():
|
|||||||
|
|
||||||
object_xml_full = os.path.abspath(object_path)
|
object_xml_full = os.path.abspath(object_path)
|
||||||
assert_edit_allowed(object_xml_full, "editable")
|
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))
|
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)
|
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||||
tree = etree.parse(object_xml_full, parser_xml)
|
tree = etree.parse(object_xml_full, parser_xml)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
|
|
||||||
supported_types = [
|
# --- Таблица видов: вид -> допустимые назначения ---
|
||||||
"Document", "Catalog", "DataProcessor", "Report",
|
#
|
||||||
"ExternalDataProcessor", "ExternalReport",
|
# Зеркало $formKinds из PS-порта. Одна запись на вид вместо разрозненных списков
|
||||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
# «поддерживаемые типы», «объектные типы», «обработко-подобные» и «карта типов реквизита»:
|
||||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal",
|
# раньше они расходились молча, и для 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_type = None
|
||||||
object_node = None
|
object_node = None
|
||||||
for t in supported_types:
|
for child in root:
|
||||||
node = root.find(f".//md:{t}", NSMAP)
|
if isinstance(child.tag, str):
|
||||||
if node is not None:
|
object_type = etree.QName(child).localname
|
||||||
object_type = t
|
object_node = child
|
||||||
object_node = node
|
|
||||||
break
|
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:
|
if object_type is None:
|
||||||
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(supported_types)}", file=sys.stderr)
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
# Object name from Properties/Name
|
# Object name from Properties/Name
|
||||||
@@ -339,31 +611,58 @@ def main():
|
|||||||
|
|
||||||
# --- Phase 2: Validate Purpose ---
|
# --- Phase 2: Validate Purpose ---
|
||||||
|
|
||||||
# Normalize: capitalize first letter, lowercase rest
|
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell.
|
||||||
purpose = purpose[0].upper() + purpose[1:].lower()
|
kind_purposes = form_kinds[object_type]
|
||||||
|
|
||||||
valid_purposes = ["Object", "List", "Choice", "Record"]
|
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||||
if purpose not in valid_purposes:
|
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||||
print(f"Недопустимое назначение: {purpose}. Допустимые: Object, List, Choice, Record", file=sys.stderr)
|
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||||
|
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||||
|
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)
|
sys.exit(1)
|
||||||
|
purpose = purpose_key
|
||||||
|
purpose_rule = kind_purposes[purpose]
|
||||||
|
|
||||||
object_like_types = ["Document", "Catalog", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой main_attr —
|
||||||
"ExchangePlan", "BusinessProcess", "Task"]
|
# это произвольная форма (законное состояние), а наполовину заполненная запись означала бы,
|
||||||
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
|
# что таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||||
|
if purpose_rule.get("main_attr") and not purpose_rule.get("attr_name"):
|
||||||
if purpose == "List":
|
print(f"Внутренняя ошибка таблицы видов: у {object_type}/{purpose} задан main_attr без attr_name",
|
||||||
if object_type == "DataProcessor":
|
file=sys.stderr)
|
||||||
print("Purpose=List недопустим для DataProcessor", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif purpose == "Choice":
|
|
||||||
if object_type in processor_like_types or object_type == "InformationRegister":
|
|
||||||
print(f"Purpose=Choice недопустим для {object_type}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif purpose == "Record":
|
|
||||||
if object_type != "InformationRegister":
|
|
||||||
print("Purpose=Record допустим только для InformationRegister", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# --- Phase 3: Create files ---
|
# --- Phase 3: Create files ---
|
||||||
@@ -388,24 +687,7 @@ def main():
|
|||||||
|
|
||||||
form_meta_xml = (
|
form_meta_xml = (
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
'<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
|
||||||
' 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"'
|
|
||||||
f' version="{format_version}">\n'
|
|
||||||
f'\t<Form uuid="{form_uuid}">\n'
|
f'\t<Form uuid="{form_uuid}">\n'
|
||||||
'\t\t<Properties>\n'
|
'\t\t<Properties>\n'
|
||||||
f'\t\t\t<Name>{form_name}</Name>\n'
|
f'\t\t\t<Name>{form_name}</Name>\n'
|
||||||
@@ -422,67 +704,53 @@ def main():
|
|||||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\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\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
||||||
'\t\t\t</UsePurposes>\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\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
||||||
+ '\t\t</Properties>\n'
|
+ '\t\t</Properties>\n'
|
||||||
'\t</Form>\n'
|
'\t</Form>\n'
|
||||||
'</MetaDataObject>'
|
'</MetaDataObject>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(form_meta_path, form_meta_xml)
|
write_xml_file(form_meta_path, form_meta_xml)
|
||||||
|
|
||||||
# --- 3b. Form.xml ---
|
# --- 3b. Form.xml ---
|
||||||
|
|
||||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||||
|
|
||||||
form_ns_decl = (
|
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
attributes_block = ''
|
||||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
if purpose_rule.get("main_attr"):
|
||||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
main_attr_type = purpose_rule["main_attr"].format(object_type, object_name)
|
||||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
main_attr_name = purpose_rule["attr_name"]
|
||||||
' 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"'
|
|
||||||
)
|
|
||||||
|
|
||||||
if purpose in ("List", "Choice"):
|
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||||
# Dynamic list
|
tail_lines = ''
|
||||||
|
if main_attr_type == "DynamicList":
|
||||||
main_table = f"{object_type}.{object_name}"
|
main_table = f"{object_type}.{object_name}"
|
||||||
|
tail_lines = ('\t\t\t<Settings xsi:type="DynamicList">\n'
|
||||||
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'
|
|
||||||
'\t<Attributes>\n'
|
|
||||||
'\t\t<Attribute name="\u0421\u043f\u0438\u0441\u043e\u043a" id="1">\n'
|
|
||||||
'\t\t\t<Type>\n'
|
|
||||||
'\t\t\t\t<v8:Type>cfg:DynamicList</v8:Type>\n'
|
|
||||||
'\t\t\t</Type>\n'
|
|
||||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
|
||||||
'\t\t\t<Settings xsi:type="DynamicList">\n'
|
|
||||||
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
|
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
|
||||||
'\t\t\t</Settings>\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\t</Attribute>\n'
|
||||||
'\t</Attributes>\n'
|
'\t</Attributes>\n'
|
||||||
'</Form>'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif purpose == "Record":
|
# Произвольная форма (main_attr=None) — без блока Attributes вовсе. В типовых это самая
|
||||||
# Information register record
|
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
|
||||||
main_attr_name = "\u0417\u0430\u043f\u0438\u0441\u044c"
|
|
||||||
main_attr_type = f"InformationRegisterRecordManager.{object_name}"
|
|
||||||
|
|
||||||
form_xml = (
|
form_xml = (
|
||||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||||
@@ -490,68 +758,14 @@ def main():
|
|||||||
'\t\t<Autofill>true</Autofill>\n'
|
'\t\t<Autofill>true</Autofill>\n'
|
||||||
'\t</AutoCommandBar>\n'
|
'\t</AutoCommandBar>\n'
|
||||||
'\t<ChildItems/>\n'
|
'\t<ChildItems/>\n'
|
||||||
'\t<Attributes>\n'
|
f'{attributes_block}'
|
||||||
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'
|
|
||||||
'\t\t\t<SavedData>true</SavedData>\n'
|
|
||||||
'\t\t</Attribute>\n'
|
|
||||||
'\t</Attributes>\n'
|
|
||||||
'</Form>'
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Object — object form
|
|
||||||
main_attr_name = "\u041e\u0431\u044a\u0435\u043a\u0442"
|
|
||||||
|
|
||||||
attr_type_map = {
|
|
||||||
"Document": "DocumentObject",
|
|
||||||
"Catalog": "CatalogObject",
|
|
||||||
"DataProcessor": "DataProcessorObject",
|
|
||||||
"Report": "ReportObject",
|
|
||||||
"ExternalDataProcessor": "ExternalDataProcessorObject",
|
|
||||||
"ExternalReport": "ExternalReportObject",
|
|
||||||
"ChartOfAccounts": "ChartOfAccountsObject",
|
|
||||||
"ChartOfCharacteristicTypes": "ChartOfCharacteristicTypesObject",
|
|
||||||
"ExchangePlan": "ExchangePlanObject",
|
|
||||||
"BusinessProcess": "BusinessProcessObject",
|
|
||||||
"Task": "TaskObject",
|
|
||||||
"InformationRegister": "InformationRegisterRecordManager",
|
|
||||||
"AccumulationRegister": "AccumulationRegisterRecordSet",
|
|
||||||
}
|
|
||||||
|
|
||||||
main_attr_type = f"{attr_type_map[object_type]}.{object_name}"
|
|
||||||
|
|
||||||
# SavedData: standard for Catalog/Document/etc, but not for processor-like (DataProcessor/Report/External*)
|
|
||||||
saved_data_line = ''
|
|
||||||
if object_type not in processor_like_types:
|
|
||||||
saved_data_line = '\t\t\t<SavedData>true</SavedData>\n'
|
|
||||||
|
|
||||||
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'
|
|
||||||
'\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'{saved_data_line}'
|
|
||||||
'\t\t</Attribute>\n'
|
|
||||||
'\t</Attributes>\n'
|
|
||||||
'</Form>'
|
'</Form>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if os.path.exists(form_xml_path):
|
if os.path.exists(form_xml_path):
|
||||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||||
else:
|
else:
|
||||||
write_text_with_bom(form_xml_path, form_xml)
|
write_xml_file(form_xml_path, form_xml)
|
||||||
|
|
||||||
# --- 3c. Module.bsl ---
|
# --- 3c. Module.bsl ---
|
||||||
|
|
||||||
@@ -582,7 +796,10 @@ def main():
|
|||||||
if os.path.exists(module_path):
|
if os.path.exists(module_path):
|
||||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||||
else:
|
else:
|
||||||
write_text_with_bom(module_path, module_bsl)
|
# Модуль пишем в каноне платформы: 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 ---
|
# --- Phase 4: Register in parent object ---
|
||||||
|
|
||||||
@@ -640,26 +857,18 @@ def main():
|
|||||||
# --- SetDefault ---
|
# --- SetDefault ---
|
||||||
|
|
||||||
is_first_form_for_purpose = False
|
is_first_form_for_purpose = False
|
||||||
default_prop_name = None
|
|
||||||
default_value = f"{object_type}.{object_name}.Form.{form_name}"
|
default_value = f"{object_type}.{object_name}.Form.{form_name}"
|
||||||
|
|
||||||
# Determine property name for DefaultForm
|
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному purpose без
|
||||||
if purpose == "Object":
|
# учёта вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не
|
||||||
if object_type in processor_like_types:
|
# находился, навык молча ничего не делал.
|
||||||
default_prop_name = "DefaultForm"
|
default_prop_name = purpose_rule.get("slot")
|
||||||
else:
|
|
||||||
default_prop_name = "DefaultObjectForm"
|
|
||||||
elif purpose == "List":
|
|
||||||
default_prop_name = "DefaultListForm"
|
|
||||||
elif purpose == "Choice":
|
|
||||||
default_prop_name = "DefaultChoiceForm"
|
|
||||||
elif purpose == "Record":
|
|
||||||
default_prop_name = "DefaultRecordForm"
|
|
||||||
|
|
||||||
# Check if value is already set
|
default_node = None
|
||||||
|
if default_prop_name:
|
||||||
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
|
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
|
||||||
if default_node is not None:
|
if default_node is not None:
|
||||||
is_first_form_for_purpose = default_node.text is None or default_node.text.strip() == ""
|
is_first_form_for_purpose = not (default_node.text or "").strip()
|
||||||
|
|
||||||
default_updated = False
|
default_updated = False
|
||||||
if set_default or is_first_form_for_purpose:
|
if set_default or is_first_form_for_purpose:
|
||||||
@@ -686,6 +895,10 @@ def main():
|
|||||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||||
if default_updated:
|
if default_updated:
|
||||||
print(f"{default_prop_name}: {default_value}")
|
print(f"{default_prop_name}: {default_value}")
|
||||||
|
elif not default_prop_name:
|
||||||
|
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||||
|
# у платформы нет (форма набора записей, произвольная форма).
|
||||||
|
print(f"Основной не назначена: у {object_type} нет свойства для формы с назначением {purpose}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
# form-decompile v0.150 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||||
# 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)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
# form-decompile v0.150 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||||
#
|
#
|
||||||
@@ -13,6 +13,28 @@ import xml.etree.ElementTree as ET
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с 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)
|
||||||
|
|
||||||
|
|
||||||
# --- 1. Namespaces ---
|
# --- 1. Namespaces ---
|
||||||
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
||||||
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
||||||
@@ -81,29 +103,29 @@ def _attr(node, name, ns_uri=None):
|
|||||||
def convert_string_to_json_literal(s):
|
def convert_string_to_json_literal(s):
|
||||||
if s is None:
|
if s is None:
|
||||||
return 'null'
|
return 'null'
|
||||||
sb = ['"']
|
out = ['"']
|
||||||
for ch in s:
|
for ch in s:
|
||||||
code = ord(ch)
|
code = ord(ch)
|
||||||
if code == 0x22:
|
if code == 0x22:
|
||||||
sb.append('\\"')
|
out.append('\\"')
|
||||||
elif code == 0x5C:
|
elif code == 0x5C:
|
||||||
sb.append('\\\\')
|
out.append('\\\\')
|
||||||
elif code == 0x08:
|
elif code == 0x08:
|
||||||
sb.append('\\b')
|
out.append('\\b')
|
||||||
elif code == 0x09:
|
elif code == 0x09:
|
||||||
sb.append('\\t')
|
out.append('\\t')
|
||||||
elif code == 0x0A:
|
elif code == 0x0A:
|
||||||
sb.append('\\n')
|
out.append('\\n')
|
||||||
elif code == 0x0C:
|
elif code == 0x0C:
|
||||||
sb.append('\\f')
|
out.append('\\f')
|
||||||
elif code == 0x0D:
|
elif code == 0x0D:
|
||||||
sb.append('\\r')
|
out.append('\\r')
|
||||||
elif code < 0x20:
|
elif code < 0x20:
|
||||||
sb.append('\\u%04x' % code)
|
out.append('\\u%04x' % code)
|
||||||
else:
|
else:
|
||||||
sb.append(ch)
|
out.append(ch)
|
||||||
sb.append('"')
|
out.append('"')
|
||||||
return ''.join(sb)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
def _num_to_str(obj):
|
def _num_to_str(obj):
|
||||||
@@ -3115,7 +3137,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
||||||
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
||||||
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
output_path = args.OutputPath
|
output_path = args.OutputPath
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# form-edit v1.6 — Edit 1C managed form elements
|
# form-edit v1.18 — Edit 1C managed form elements (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||||
# 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)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
@@ -10,6 +11,70 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$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
|
||||||
|
|
||||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||||
@@ -175,7 +240,7 @@ $root = $xmlDoc.DocumentElement
|
|||||||
|
|
||||||
# === 2. Load JSON ===
|
# === 2. Load JSON ===
|
||||||
|
|
||||||
$def = Get-Content -Raw -Encoding UTF8 $JsonPath | ConvertFrom-Json
|
$def = ConvertFrom-JsonInput (Read-JsonInputFile $JsonPath) $JsonPath
|
||||||
|
|
||||||
# === 3. Form name + header ===
|
# === 3. Form name + header ===
|
||||||
|
|
||||||
@@ -270,6 +335,12 @@ function X {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Esc-Xml {
|
function Esc-Xml {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
||||||
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
||||||
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
||||||
@@ -282,7 +353,7 @@ function Emit-MLText {
|
|||||||
X "$indent<$tag>"
|
X "$indent<$tag>"
|
||||||
X "$indent`t<v8:item>"
|
X "$indent`t<v8:item>"
|
||||||
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
||||||
X "$indent`t`t<v8:content>$(Esc-Xml $text)</v8:content>"
|
X "$indent`t`t<v8:content>$(Esc-XmlText $text)</v8:content>"
|
||||||
X "$indent`t</v8:item>"
|
X "$indent`t</v8:item>"
|
||||||
X "$indent</$tag>"
|
X "$indent</$tag>"
|
||||||
}
|
}
|
||||||
@@ -311,24 +382,49 @@ $script:formTypeSynonyms["бизнеспроцессссылка"] = "
|
|||||||
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
||||||
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
||||||
|
|
||||||
|
# Алиас на локальный словарь: тело Resolve-TypeStr ниже — общая реализация,
|
||||||
|
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||||
|
$script:typeSynonyms = $script:formTypeSynonyms
|
||||||
|
|
||||||
function Resolve-TypeStr {
|
function Resolve-TypeStr {
|
||||||
param([string]$typeStr)
|
param([string]$typeStr)
|
||||||
if (-not $typeStr) { return $typeStr }
|
if (-not $typeStr) { return $typeStr }
|
||||||
|
|
||||||
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if ($typeStr.StartsWith('cfg:')) {
|
||||||
|
$typeStr = $typeStr.Substring(4)
|
||||||
|
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||||
|
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||||
$base = $Matches[1].Trim(); $params = $Matches[2]
|
$baseName = $Matches[1].Trim()
|
||||||
$r = $script:formTypeSynonyms[$base.ToLower()]
|
$params = $Matches[2]
|
||||||
if ($r) { return "$r($params)" }
|
$resolved = $script:typeSynonyms[$baseName.ToLower()]
|
||||||
|
if ($resolved) { return "$resolved($params)" }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||||
if ($typeStr.Contains('.')) {
|
if ($typeStr.Contains('.')) {
|
||||||
$i = $typeStr.IndexOf('.')
|
$dotIdx = $typeStr.IndexOf('.')
|
||||||
$prefix = $typeStr.Substring(0, $i); $suffix = $typeStr.Substring($i)
|
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||||
$r = $script:formTypeSynonyms[$prefix.ToLower()]
|
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||||
if ($r) { return "$r$suffix" }
|
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||||
|
if ($resolved) { return "$resolved$suffix" }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
$r = $script:formTypeSynonyms[$typeStr.ToLower()]
|
|
||||||
if ($r) { return $r }
|
# Простое имя
|
||||||
|
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||||
|
if ($resolved) { return $resolved }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +688,7 @@ function Emit-Label {
|
|||||||
X "$inner<Title formatted=`"$formatted`">"
|
X "$inner<Title formatted=`"$formatted`">"
|
||||||
X "$inner`t<v8:item>"
|
X "$inner`t<v8:item>"
|
||||||
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
||||||
X "$inner`t`t<v8:content>$(Esc-Xml "$($el.title)")</v8:content>"
|
X "$inner`t`t<v8:content>$(Esc-XmlText "$($el.title)")</v8:content>"
|
||||||
X "$inner`t</v8:item>"
|
X "$inner`t</v8:item>"
|
||||||
X "$inner</Title>"
|
X "$inner</Title>"
|
||||||
}
|
}
|
||||||
@@ -1387,8 +1483,16 @@ if ($def.elementEvents -and $def.elementEvents.Count -gt 0) {
|
|||||||
$content = $xmlDoc.OuterXml
|
$content = $xmlDoc.OuterXml
|
||||||
# Ensure encoding declaration is uppercase UTF-8
|
# Ensure encoding declaration is uppercase UTF-8
|
||||||
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$content = [regex]::Replace($content, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $resolvedFormPath) -and ([System.IO.File]::ReadAllText($resolvedFormPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$content = ($content -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
||||||
|
|
||||||
# === 14. Summary ===
|
# === 14. Summary ===
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-edit v1.6 — Edit 1C managed form elements (Python port)
|
# form-edit v1.18 — Edit 1C managed form elements (Python port) (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -11,6 +11,127 @@ 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 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
|
||||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||||
@@ -192,7 +313,7 @@ def assert_edit_allowed(target_path, require):
|
|||||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||||
parser.add_argument("-FormPath", "-Path", required=True)
|
parser.add_argument("-FormPath", "-Path", required=True)
|
||||||
parser.add_argument("-JsonPath", required=True)
|
parser.add_argument("-JsonPath", required=True)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
json_path = args.JsonPath
|
json_path = args.JsonPath
|
||||||
@@ -226,6 +347,11 @@ def local_name(node):
|
|||||||
# ── helpers ──────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def esc_xml(s):
|
def esc_xml(s):
|
||||||
|
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||||||
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
@@ -253,8 +379,7 @@ root = tree.getroot()
|
|||||||
|
|
||||||
# ── 2. Load JSON ────────────────────────────────────────────
|
# ── 2. Load JSON ────────────────────────────────────────────
|
||||||
|
|
||||||
with open(json_path, "r", encoding="utf-8-sig") as f:
|
defn = ci_json(parse_json_input(read_json_file(json_path), json_path))
|
||||||
defn = json.load(f)
|
|
||||||
|
|
||||||
# ── 3. Form name + header ───────────────────────────────────
|
# ── 3. Form name + header ───────────────────────────────────
|
||||||
|
|
||||||
@@ -386,23 +511,48 @@ _FORM_TYPE_SYNONYMS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||||
|
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||||
|
TYPE_SYNONYMS = _FORM_TYPE_SYNONYMS
|
||||||
|
|
||||||
|
|
||||||
def resolve_type_str(type_str):
|
def resolve_type_str(type_str):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
return type_str
|
return type_str
|
||||||
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if type_str.startswith('cfg:'):
|
||||||
|
type_str = type_str[4:]
|
||||||
|
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||||
|
type_str = type_str[type_str.index(':') + 1:]
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||||
if m:
|
if m:
|
||||||
base, params = m.group(1).strip(), m.group(2)
|
base_name = m.group(1).strip()
|
||||||
r = _FORM_TYPE_SYNONYMS.get(base.lower())
|
params = m.group(2)
|
||||||
return f"{r}({params})" if r else type_str
|
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||||
|
if resolved:
|
||||||
|
return f'{resolved}({params})'
|
||||||
|
return type_str
|
||||||
|
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||||
if '.' in type_str:
|
if '.' in type_str:
|
||||||
i = type_str.index('.')
|
dot_idx = type_str.index('.')
|
||||||
prefix, suffix = type_str[:i], type_str[i:]
|
prefix = type_str[:dot_idx]
|
||||||
r = _FORM_TYPE_SYNONYMS.get(prefix.lower())
|
suffix = type_str[dot_idx:] # includes the dot
|
||||||
return f"{r}{suffix}" if r else type_str
|
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||||
r = _FORM_TYPE_SYNONYMS.get(type_str.lower())
|
if resolved:
|
||||||
return r if r else type_str
|
return f'{resolved}{suffix}'
|
||||||
|
return type_str
|
||||||
|
# Простое имя
|
||||||
|
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
return type_str
|
||||||
def emit_type(type_str, indent):
|
def emit_type(type_str, indent):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
X(f"{indent}<Type/>")
|
X(f"{indent}<Type/>")
|
||||||
@@ -496,7 +646,7 @@ def emit_mltext(tag, text, indent):
|
|||||||
X(f"{indent}<{tag}>")
|
X(f"{indent}<{tag}>")
|
||||||
X(f"{indent}\t<v8:item>")
|
X(f"{indent}\t<v8:item>")
|
||||||
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
||||||
X(f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>")
|
X(f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>")
|
||||||
X(f"{indent}\t</v8:item>")
|
X(f"{indent}\t</v8:item>")
|
||||||
X(f"{indent}</{tag}>")
|
X(f"{indent}</{tag}>")
|
||||||
|
|
||||||
@@ -724,7 +874,7 @@ def emit_label(el, name, _id, indent):
|
|||||||
X(f'{inner}<Title formatted="{formatted}">')
|
X(f'{inner}<Title formatted="{formatted}">')
|
||||||
X(f"{inner}\t<v8:item>")
|
X(f"{inner}\t<v8:item>")
|
||||||
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
||||||
X(f"{inner}\t\t<v8:content>{esc_xml(str(el['title']))}</v8:content>")
|
X(f"{inner}\t\t<v8:content>{esc_xml_text(str(el['title']))}</v8:content>")
|
||||||
X(f"{inner}\t</v8:item>")
|
X(f"{inner}\t</v8:item>")
|
||||||
X(f"{inner}</Title>")
|
X(f"{inner}</Title>")
|
||||||
emit_common_flags(el, inner)
|
emit_common_flags(el, inner)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# form-info v1.5 — Analyze 1C managed form structure
|
# form-info v1.8 — Analyze 1C managed form 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=$true)]
|
[Parameter(Mandatory=$true, Position=0)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
[string]$FormPath,
|
[string]$FormPath,
|
||||||
[int]$Limit = 150,
|
[int]$Limit = 150,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-info v1.5 — Analyze 1C managed form structure
|
# form-info v1.8 — Analyze 1C managed form structure
|
||||||
# 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 map ---
|
# --- Namespace map ---
|
||||||
|
|
||||||
NSMAP = {
|
NSMAP = {
|
||||||
@@ -353,7 +375,7 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
def is_external_root(xml_path):
|
def _sg_is_external_root(xml_path):
|
||||||
if not os.path.isfile(xml_path):
|
if not os.path.isfile(xml_path):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
@@ -367,14 +389,14 @@ def get_support_status_for_path(target_path):
|
|||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
if is_external_root(rp):
|
if _sg_is_external_root(rp):
|
||||||
return None
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
if is_external_root(d + ".xml"):
|
if _sg_is_external_root(d + ".xml"):
|
||||||
return None
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
@@ -433,7 +455,7 @@ def main():
|
|||||||
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="Line offset for pagination")
|
parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
|
||||||
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
limit = args.Limit
|
limit = args.Limit
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ allowed-tools:
|
|||||||
| ObjectName | да | — | Имя объекта |
|
| ObjectName | да | — | Имя объекта |
|
||||||
| FormName | да | — | Имя формы для удаления |
|
| FormName | да | — | Имя формы для удаления |
|
||||||
| SrcDir | нет | `src` | Каталог исходников |
|
| SrcDir | нет | `src` | Каталог исходников |
|
||||||
|
| Force | нет | — | Удалить, даже если на форму ссылаются, и очистить ссылки |
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "<ObjectName>" -FormName "<FormName>" [-SrcDir "<SrcDir>"]
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "<ObjectName>" -FormName "<FormName>" [-SrcDir "<SrcDir>"] [-Force]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что удаляется
|
## Что удаляется
|
||||||
@@ -44,4 +45,4 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -O
|
|||||||
## Что модифицируется
|
## Что модифицируется
|
||||||
|
|
||||||
- `<SrcDir>/<ObjectName>.xml` — убирается `<Form>` из `ChildObjects`
|
- `<SrcDir>/<ObjectName>.xml` — убирается `<Form>` из `ChildObjects`
|
||||||
- Если удаляемая форма была DefaultForm — очищается значение DefaultForm
|
- Свойства объекта, указывавшие на удалённую форму — очищаются
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-remove v1.4 — Remove form from 1C object
|
# form-remove v1.10 — Remove form from 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -8,7 +8,9 @@ param(
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$FormName,
|
[string]$FormName,
|
||||||
|
|
||||||
[string]$SrcDir = "src"
|
[string]$SrcDir = "src",
|
||||||
|
|
||||||
|
[switch]$Force
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -33,6 +35,180 @@ if (-not (Test-Path $formMetaPath)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Загрузка корневого XML: вид и имя объекта ---
|
||||||
|
|
||||||
|
$rootXmlFull = Resolve-Path $rootXmlPath
|
||||||
|
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||||
|
$xmlDoc.PreserveWhitespace = $true
|
||||||
|
$xmlDoc.Load($rootXmlFull.Path)
|
||||||
|
|
||||||
|
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||||
|
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||||
|
|
||||||
|
$typeNode = $null
|
||||||
|
foreach ($c in $xmlDoc.DocumentElement.ChildNodes) {
|
||||||
|
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) { $typeNode = $c; break }
|
||||||
|
}
|
||||||
|
if (-not $typeNode) {
|
||||||
|
Write-Error "Не удалось определить вид объекта в $rootXmlPath"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$mdType = $typeNode.LocalName
|
||||||
|
$nameNode = $typeNode.SelectSingleNode("md:Properties/md:Name", $nsMgr)
|
||||||
|
$objMetaName = if ($nameNode -and $nameNode.InnerText.Trim()) { $nameNode.InnerText.Trim() } else { [System.IO.Path]::GetFileNameWithoutExtension($rootXmlPath) }
|
||||||
|
|
||||||
|
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||||
|
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||||
|
$formRef = "$mdType.$objMetaName.Form.$FormName"
|
||||||
|
|
||||||
|
# --- Чистка ссылок и сохранение в стиле файла-источника ---
|
||||||
|
|
||||||
|
# Каноничное «не задано» зависит от файла: в корневом XML объекта и в Configuration.xml
|
||||||
|
# пустой слот штатен (164 508 пустых на корпус), а внутри Ext/Form.xml пустых <ChoiceForm/>
|
||||||
|
# и <SettingsStorage/> нет ни одного — там свойство просто отсутствует.
|
||||||
|
function Clear-FormRefs {
|
||||||
|
param([System.Xml.XmlDocument]$doc, [string]$ref)
|
||||||
|
|
||||||
|
$isFormFile = $doc.DocumentElement -and $doc.DocumentElement.LocalName -eq "Form"
|
||||||
|
$touched = @()
|
||||||
|
foreach ($node in @($doc.SelectNodes("//*"))) {
|
||||||
|
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||||
|
if ($node.SelectNodes("*").Count -gt 0) { continue } # только листья
|
||||||
|
# Сравнение регистронезависимое — как у платформы (в py-порту .lower()).
|
||||||
|
if ($node.InnerText.Trim() -ne $ref) { continue }
|
||||||
|
|
||||||
|
$ln = $node.LocalName
|
||||||
|
$parent = $node.ParentNode
|
||||||
|
if ($ln -eq "Form" -and $parent -and $parent.LocalName -eq "Item") {
|
||||||
|
$touched += "$($parent.LocalName)/$ln"
|
||||||
|
Remove-NodeWithIndent $parent
|
||||||
|
} elseif ($isFormFile) {
|
||||||
|
$touched += $ln
|
||||||
|
Remove-NodeWithIndent $node
|
||||||
|
} else {
|
||||||
|
# IsEmpty, а не InnerText="": пустая строка сериализуется парой <Tag></Tag>, а
|
||||||
|
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||||
|
$touched += $ln
|
||||||
|
$node.IsEmpty = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $touched
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-NodeWithIndent {
|
||||||
|
param([System.Xml.XmlNode]$node)
|
||||||
|
$parent = $node.ParentNode
|
||||||
|
if (-not $parent) { return }
|
||||||
|
$prev = $node.PreviousSibling
|
||||||
|
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||||
|
$parent.RemoveChild($prev) | Out-Null
|
||||||
|
}
|
||||||
|
$parent.RemoveChild($node) | Out-Null
|
||||||
|
# Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару
|
||||||
|
# <ChildObjects>\n\t\t</ChildObjects>. Платформа пишет только <ChildObjects/>
|
||||||
|
# (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0).
|
||||||
|
if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-XmlPreservingStyle {
|
||||||
|
param([System.Xml.XmlDocument]$doc, [string]$path)
|
||||||
|
|
||||||
|
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
|
$settings.Encoding = $encBom
|
||||||
|
$settings.Indent = $false
|
||||||
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
|
|
||||||
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
|
$doc.Save($writer)
|
||||||
|
$writer.Flush(); $writer.Close()
|
||||||
|
|
||||||
|
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||||
|
$memStream.Close()
|
||||||
|
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||||
|
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<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 $path) -and ([System.IO.File]::ReadAllText($path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
|
[System.IO.File]::WriteAllText($path, $xmlText, $encBom)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Поиск ссылок на форму по всей конфигурации ---
|
||||||
|
|
||||||
|
# Get-Item, а не Resolve-Path: последний оставляет короткое имя 8.3 (NSHIRO~1), а
|
||||||
|
# Get-ChildItem отдаёт длинное (nshirokov) — сравнение путей молча не совпадало.
|
||||||
|
function Get-LongPath {
|
||||||
|
param([string]$path)
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) { return "" }
|
||||||
|
return (Get-Item -LiteralPath $path -Force).FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
# Корень конфигурации: обычно это сам SrcDir, но объект могут передать и из глубины.
|
||||||
|
$configDir = $null
|
||||||
|
$probe = Get-LongPath $SrcDir
|
||||||
|
for ($depth = 0; $depth -lt 4; $depth++) {
|
||||||
|
if (-not $probe) { break }
|
||||||
|
if (Test-Path (Join-Path $probe "Configuration.xml")) { $configDir = $probe; break }
|
||||||
|
$probe = Split-Path $probe
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootXmlLong = Get-LongPath $rootXmlFull.Path
|
||||||
|
$formMetaFull = Get-LongPath $formMetaPath
|
||||||
|
$formDirFull = Get-LongPath $formDir
|
||||||
|
|
||||||
|
$references = @()
|
||||||
|
if ($configDir) {
|
||||||
|
# Полный обход, как в meta-remove: ссылки лежат и внутри Ext/Form.xml (ChoiceForm,
|
||||||
|
# SettingsStorage), узкий скан по корневым XML их не видит.
|
||||||
|
# EnumerateFiles, а не Get-ChildItem -Recurse: на ERP (73 904 XML) обход обёртками
|
||||||
|
# занимает 180 с против 47 с — чтение файлов не узкое место, узкое место перечисление.
|
||||||
|
$refPattern = '<([A-Za-z0-9_.]+)>' + [regex]::Escape($formRef) + '</'
|
||||||
|
$scanSw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$scanned = 0
|
||||||
|
foreach ($fp in [System.IO.Directory]::EnumerateFiles($configDir, "*.xml", [System.IO.SearchOption]::AllDirectories)) {
|
||||||
|
if ($fp -eq $rootXmlLong) { continue } # свой файл чистится всегда
|
||||||
|
if ($fp -eq $formMetaFull) { continue } # файлы удаляемой формы
|
||||||
|
if ($formDirFull -and $fp.StartsWith($formDirFull)) { continue }
|
||||||
|
$scanned++
|
||||||
|
$content = [System.IO.File]::ReadAllText($fp, [System.Text.Encoding]::UTF8)
|
||||||
|
if (-not $content.Contains($formRef)) { continue }
|
||||||
|
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||||
|
$references += @{ Path = $fp; Rel = $fp.Substring($configDir.Length + 1); Tag = $m.Groups[1].Value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$scanSw.Stop()
|
||||||
|
if ($scanSw.Elapsed.TotalSeconds -ge 5) {
|
||||||
|
Write-Host "[INFO] Проверено ссылок в $scanned файлах за $([math]::Round($scanSw.Elapsed.TotalSeconds, 1)) c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($references.Count -gt 0) {
|
||||||
|
Write-Host "[WARN] На форму $formRef ссылаются $($references.Count) раз(а):"
|
||||||
|
foreach ($grp in ($references | Group-Object { "$($_.Rel)|$($_.Tag)" } | Sort-Object Name)) {
|
||||||
|
$parts = $grp.Name.Split("|")
|
||||||
|
$suffix = if ($grp.Count -gt 1) { " x$($grp.Count)" } else { "" }
|
||||||
|
Write-Host " $($parts[0]) — <$($parts[1])>$suffix"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
if (-not $Force) {
|
||||||
|
Write-Host "[ERROR] Удаление остановлено: форма используется."
|
||||||
|
Write-Host " Решает пользователь: убрать ссылки, отказаться от удаления или"
|
||||||
|
Write-Host " повторить с -Force — тогда ссылки будут очищены."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "[WARN] -Force: ссылки будут очищены"
|
||||||
|
Write-Host ""
|
||||||
|
} elseif (-not $configDir) {
|
||||||
|
Write-Host "[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Удаление файлов ---
|
# --- Удаление файлов ---
|
||||||
|
|
||||||
if (Test-Path $formDir) {
|
if (Test-Path $formDir) {
|
||||||
@@ -45,49 +221,35 @@ Write-Host "[OK] Удалён файл: $formMetaPath"
|
|||||||
|
|
||||||
# --- Модификация корневого XML ---
|
# --- Модификация корневого XML ---
|
||||||
|
|
||||||
$rootXmlFull = Resolve-Path $rootXmlPath
|
|
||||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
|
||||||
$xmlDoc.PreserveWhitespace = $true
|
|
||||||
$xmlDoc.Load($rootXmlFull.Path)
|
|
||||||
|
|
||||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
|
||||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
|
||||||
|
|
||||||
# Удалить <Form>FormName</Form> из ChildObjects
|
# Удалить <Form>FormName</Form> из ChildObjects
|
||||||
$formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr)
|
$formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr)
|
||||||
foreach ($node in $formNodes) {
|
foreach ($node in $formNodes) {
|
||||||
if ($node.InnerText -eq $FormName) {
|
if ($node.InnerText -eq $FormName) {
|
||||||
$parent = $node.ParentNode
|
Remove-NodeWithIndent $node
|
||||||
# Удалить предшествующий whitespace
|
|
||||||
$prev = $node.PreviousSibling
|
|
||||||
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
|
||||||
$parent.RemoveChild($prev) | Out-Null
|
|
||||||
}
|
|
||||||
$parent.RemoveChild($node) | Out-Null
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Очистить любые Default*/Auxiliary* form-слоты, указывавшие на удалённую форму
|
# Очистить слоты своего объекта, указывавшие на удалённую форму: Default*/Auxiliary*Form
|
||||||
# (form-add пишет свойство по назначению: DefaultObjectForm/DefaultListForm/
|
# (form-add пишет свойство по назначению) и ChoiceForm у реквизитов.
|
||||||
# DefaultChoiceForm/DefaultRecordForm/DefaultForm — не только generic DefaultForm).
|
Clear-FormRefs $xmlDoc $formRef | Out-Null
|
||||||
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
|
|
||||||
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
|
|
||||||
if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) {
|
|
||||||
$node.InnerText = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Сохранить с BOM
|
Save-XmlPreservingStyle $xmlDoc $rootXmlFull.Path
|
||||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
|
||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
|
||||||
$settings.Encoding = $encBom
|
|
||||||
$settings.Indent = $false
|
|
||||||
|
|
||||||
$stream = New-Object System.IO.FileStream($rootXmlFull.Path, [System.IO.FileMode]::Create)
|
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
|
||||||
$xmlDoc.Save($writer)
|
|
||||||
$writer.Close()
|
|
||||||
$stream.Close()
|
|
||||||
|
|
||||||
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
||||||
|
|
||||||
|
# --- Чистка ссылок в других файлах (только с -Force) ---
|
||||||
|
|
||||||
|
if ($references.Count -gt 0) {
|
||||||
|
foreach ($grp in ($references | Group-Object { $_.Path } | Sort-Object Name)) {
|
||||||
|
$path = $grp.Name
|
||||||
|
$doc = New-Object System.Xml.XmlDocument
|
||||||
|
$doc.PreserveWhitespace = $true
|
||||||
|
$doc.Load($path)
|
||||||
|
$touched = @(Clear-FormRefs $doc $formRef)
|
||||||
|
if ($touched.Count -eq 0) { continue }
|
||||||
|
Save-XmlPreservingStyle $doc $path
|
||||||
|
$rel = $path.Substring($configDir.Length + 1)
|
||||||
|
Write-Host "[OK] Очищена ссылка в $rel — $(($touched | Sort-Object -Unique) -join ', ')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# remove-form v1.4 — Remove form from 1C object
|
# form-remove v1.10 — Remove form from 1C object
|
||||||
# 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
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,21 +52,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
|
||||||
|
|
||||||
@@ -60,6 +83,69 @@ def save_xml_with_bom(tree, path):
|
|||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def long_path(path):
|
||||||
|
"""Полный путь в длинной форме. Зеркало Get-LongPath в PS: там Resolve-Path оставляет
|
||||||
|
короткое имя 8.3 (NSHIRO~1), а перечисление отдаёт длинное — сравнение молча не совпадало."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return ""
|
||||||
|
return os.path.realpath(path)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_node_with_indent(node):
|
||||||
|
"""Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать
|
||||||
|
самозакрывающимся. Зеркало Remove-NodeWithIndent в PS."""
|
||||||
|
parent = node.getparent()
|
||||||
|
if parent is None:
|
||||||
|
return
|
||||||
|
# В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а
|
||||||
|
# whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с
|
||||||
|
# узлом, поэтому его надо передать предшественнику — иначе `</Attributes></Form>`.
|
||||||
|
prev = node.getprevious()
|
||||||
|
if prev is not None:
|
||||||
|
prev.tail = node.tail
|
||||||
|
else:
|
||||||
|
parent.text = node.tail
|
||||||
|
parent.remove(node)
|
||||||
|
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||||||
|
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||||||
|
if len(parent) == 0 and not (parent.text or "").strip():
|
||||||
|
parent.text = None
|
||||||
|
|
||||||
|
|
||||||
|
def clear_form_refs(tree, ref):
|
||||||
|
"""Очистить ссылки на форму. Каноничное «не задано» зависит от файла: в корневом XML
|
||||||
|
объекта и в Configuration.xml пустой слот штатен (164 508 пустых на корпус), а внутри
|
||||||
|
Ext/Form.xml пустых <ChoiceForm/> и <SettingsStorage/> нет ни одного — там свойство
|
||||||
|
просто отсутствует. Зеркало Clear-FormRefs в PS."""
|
||||||
|
root = tree.getroot()
|
||||||
|
is_form_file = etree.QName(root).localname == "Form"
|
||||||
|
touched = []
|
||||||
|
ref_lc = ref.lower()
|
||||||
|
for el in list(root.iter()):
|
||||||
|
if not isinstance(el.tag, str):
|
||||||
|
continue
|
||||||
|
if len(el) > 0: # только листья
|
||||||
|
continue
|
||||||
|
# Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает).
|
||||||
|
if (el.text or "").strip().lower() != ref_lc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ln = etree.QName(el).localname
|
||||||
|
parent = el.getparent()
|
||||||
|
if ln == "Form" and parent is not None and etree.QName(parent).localname == "Item":
|
||||||
|
touched.append(f"{etree.QName(parent).localname}/{ln}")
|
||||||
|
remove_node_with_indent(parent)
|
||||||
|
elif is_form_file:
|
||||||
|
touched.append(ln)
|
||||||
|
remove_node_with_indent(el)
|
||||||
|
else:
|
||||||
|
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
|
||||||
|
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||||
|
touched.append(ln)
|
||||||
|
el.text = None
|
||||||
|
return touched
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
@@ -67,11 +153,13 @@ def main():
|
|||||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||||
parser.add_argument("-FormName", required=True)
|
parser.add_argument("-FormName", required=True)
|
||||||
parser.add_argument("-SrcDir", default="src")
|
parser.add_argument("-SrcDir", default="src")
|
||||||
args = parser.parse_args()
|
parser.add_argument("-Force", action="store_true")
|
||||||
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
object_name = args.ObjectName
|
object_name = args.ObjectName
|
||||||
form_name = args.FormName
|
form_name = args.FormName
|
||||||
src_dir = args.SrcDir
|
src_dir = args.SrcDir
|
||||||
|
force = args.Force
|
||||||
|
|
||||||
# --- Checks ---
|
# --- Checks ---
|
||||||
|
|
||||||
@@ -89,6 +177,91 @@ def main():
|
|||||||
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
|
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# --- Load root XML: kind and object name ---
|
||||||
|
|
||||||
|
root_xml_full = long_path(root_xml_path) or os.path.abspath(root_xml_path)
|
||||||
|
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||||
|
tree = etree.parse(root_xml_full, parser_xml)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
type_node = None
|
||||||
|
for c in root:
|
||||||
|
if isinstance(c.tag, str):
|
||||||
|
type_node = c
|
||||||
|
break
|
||||||
|
if type_node is None:
|
||||||
|
print(f"Не удалось определить вид объекта в {root_xml_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
md_type = etree.QName(type_node).localname
|
||||||
|
name_node = type_node.find("md:Properties/md:Name", NSMAP)
|
||||||
|
obj_meta_name = (name_node.text or "").strip() if name_node is not None else ""
|
||||||
|
if not obj_meta_name:
|
||||||
|
obj_meta_name = os.path.splitext(os.path.basename(root_xml_path))[0]
|
||||||
|
|
||||||
|
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||||
|
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||||
|
form_ref = f"{md_type}.{obj_meta_name}.Form.{form_name}"
|
||||||
|
|
||||||
|
# --- Find references across the configuration ---
|
||||||
|
|
||||||
|
config_dir = None
|
||||||
|
probe = long_path(src_dir) or os.path.abspath(src_dir)
|
||||||
|
for _ in range(4):
|
||||||
|
if not probe:
|
||||||
|
break
|
||||||
|
if os.path.exists(os.path.join(probe, "Configuration.xml")):
|
||||||
|
config_dir = probe
|
||||||
|
break
|
||||||
|
parent_probe = os.path.dirname(probe)
|
||||||
|
if parent_probe == probe:
|
||||||
|
break
|
||||||
|
probe = parent_probe
|
||||||
|
|
||||||
|
form_meta_full = long_path(form_meta_path)
|
||||||
|
form_dir_full = long_path(form_dir)
|
||||||
|
|
||||||
|
references = []
|
||||||
|
if config_dir:
|
||||||
|
ref_pattern = re.compile(r"<([A-Za-z0-9_.]+)>" + re.escape(form_ref) + r"</")
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(config_dir):
|
||||||
|
for fn in filenames:
|
||||||
|
if not fn.lower().endswith(".xml"):
|
||||||
|
continue
|
||||||
|
fp = os.path.join(dirpath, fn)
|
||||||
|
if fp == root_xml_full or fp == form_meta_full:
|
||||||
|
continue # свой файл и файлы удаляемой формы
|
||||||
|
if form_dir_full and fp.startswith(form_dir_full):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(fp, "r", encoding="utf-8-sig") as f:
|
||||||
|
content = f.read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if form_ref not in content:
|
||||||
|
continue
|
||||||
|
for m in ref_pattern.finditer(content):
|
||||||
|
references.append({"path": fp, "rel": os.path.relpath(fp, config_dir),
|
||||||
|
"tag": m.group(1)})
|
||||||
|
|
||||||
|
if references:
|
||||||
|
print(f"[WARN] На форму {form_ref} ссылаются {len(references)} раз(а):")
|
||||||
|
grouped = {}
|
||||||
|
for r in references:
|
||||||
|
grouped[(r["rel"], r["tag"])] = grouped.get((r["rel"], r["tag"]), 0) + 1
|
||||||
|
for (rel, tag) in sorted(grouped):
|
||||||
|
suffix = f" x{grouped[(rel, tag)]}" if grouped[(rel, tag)] > 1 else ""
|
||||||
|
print(f" {rel} — <{tag}>{suffix}")
|
||||||
|
print()
|
||||||
|
if not force:
|
||||||
|
print("[ERROR] Удаление остановлено: форма используется.")
|
||||||
|
print(" Решает пользователь: убрать ссылки, отказаться от удаления или")
|
||||||
|
print(" повторить с -Force — тогда ссылки будут очищены.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("[WARN] -Force: ссылки будут очищены")
|
||||||
|
print()
|
||||||
|
elif not config_dir:
|
||||||
|
print("[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены")
|
||||||
|
|
||||||
# --- Delete files ---
|
# --- Delete files ---
|
||||||
|
|
||||||
if os.path.isdir(form_dir):
|
if os.path.isdir(form_dir):
|
||||||
@@ -100,42 +273,31 @@ def main():
|
|||||||
|
|
||||||
# --- Modify root XML ---
|
# --- Modify root XML ---
|
||||||
|
|
||||||
root_xml_full = os.path.abspath(root_xml_path)
|
|
||||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
|
||||||
tree = etree.parse(root_xml_full, parser_xml)
|
|
||||||
root = tree.getroot()
|
|
||||||
|
|
||||||
# Remove <Form>FormName</Form> from ChildObjects
|
# Remove <Form>FormName</Form> from ChildObjects
|
||||||
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
|
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
|
||||||
if node.text and node.text.strip() == form_name:
|
if node.text and node.text.strip() == form_name:
|
||||||
parent = node.getparent()
|
remove_node_with_indent(node)
|
||||||
prev = node.getprevious()
|
|
||||||
if prev is not None:
|
|
||||||
# Whitespace is in prev.tail
|
|
||||||
if prev.tail and prev.tail.strip() == "":
|
|
||||||
prev.tail = ""
|
|
||||||
else:
|
|
||||||
# First child — whitespace is in parent.text
|
|
||||||
if parent.text and parent.text.strip() == "":
|
|
||||||
parent.text = ""
|
|
||||||
parent.remove(node)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
# Очистить слоты своего объекта: Default*/Auxiliary*Form и ChoiceForm у реквизитов.
|
||||||
# (form-add writes the purpose-specific property: DefaultObjectForm / DefaultListForm /
|
clear_form_refs(tree, form_ref)
|
||||||
# DefaultChoiceForm / DefaultRecordForm / DefaultForm — not just generic DefaultForm).
|
|
||||||
ref_re = re.compile(rf"Form\.{re.escape(form_name)}$")
|
|
||||||
for el in root.iter():
|
|
||||||
if not isinstance(el.tag, str):
|
|
||||||
continue
|
|
||||||
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
|
|
||||||
el.text = ""
|
|
||||||
|
|
||||||
# Save with BOM
|
# Save with BOM
|
||||||
save_xml_with_bom(tree, root_xml_full)
|
save_xml_with_bom(tree, root_xml_full)
|
||||||
|
|
||||||
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
|
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
|
||||||
|
|
||||||
|
# --- Clean references in other files (only with -Force) ---
|
||||||
|
|
||||||
|
for fp in sorted({r["path"] for r in references}):
|
||||||
|
other_tree = etree.parse(fp, parser_xml)
|
||||||
|
touched = clear_form_refs(other_tree, form_ref)
|
||||||
|
if not touched:
|
||||||
|
continue
|
||||||
|
save_xml_with_bom(other_tree, fp)
|
||||||
|
rel = os.path.relpath(fp, config_dir)
|
||||||
|
print(f"[OK] Очищена ссылка в {rel} — {', '.join(sorted(set(touched)))}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# form-validate v1.9 — Validate 1C managed form
|
# form-validate v1.18 — Validate 1C managed form
|
||||||
# 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]$FormPath,
|
[string]$FormPath,
|
||||||
|
|
||||||
@@ -56,9 +57,23 @@ try {
|
|||||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||||
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
||||||
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||||
|
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
|
||||||
$root = $xmlDoc.DocumentElement
|
$root = $xmlDoc.DocumentElement
|
||||||
|
|
||||||
|
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||||
|
# support-guard: is_external_root, авторитет — cf-edit).
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
# --- Detect context: config vs EPF/ERF ---
|
# --- Detect context: config vs EPF/ERF ---
|
||||||
# Walk up from FormPath looking for Configuration.xml → config context
|
# Walk up from FormPath looking for Configuration.xml → config context
|
||||||
# No Configuration.xml → external data processor / report (EPF/ERF)
|
# No Configuration.xml → external data processor / report (EPF/ERF)
|
||||||
@@ -66,13 +81,56 @@ $script:isConfigContext = $false
|
|||||||
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
||||||
for ($i = 0; $i -lt 15; $i++) {
|
for ($i = 0; $i -lt 15; $i++) {
|
||||||
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
||||||
|
# Порядок проверок тот же, что у Detect-FormatVersion: сначала корень автономной обработки,
|
||||||
|
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||||
|
# версию конфигурации.
|
||||||
|
$extRoot = "$walkDir.xml"
|
||||||
|
if (-not $script:versionAnchor) {
|
||||||
|
if (Test-ExternalObjectRoot $extRoot) {
|
||||||
|
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||||
|
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||||
|
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||||
|
$script:versionAnchor = $extRoot
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
||||||
$script:isConfigContext = $true
|
$script:isConfigContext = $true
|
||||||
|
$script:configXmlPath = Join-Path $walkDir "Configuration.xml"
|
||||||
|
if (-not $script:versionAnchor) { $script:versionAnchor = $script:configXmlPath }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
$walkDir = Split-Path $walkDir
|
$walkDir = Split-Path $walkDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||||
|
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Counters ---
|
# --- Counters ---
|
||||||
|
|
||||||
$errors = 0
|
$errors = 0
|
||||||
@@ -101,6 +159,19 @@ function Report-Warn {
|
|||||||
Write-Host "[WARN] $msg"
|
Write-Host "[WARN] $msg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- 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
|
||||||
|
}
|
||||||
|
|
||||||
# --- Form name from path ---
|
# --- Form name from path ---
|
||||||
|
|
||||||
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
||||||
@@ -127,13 +198,17 @@ if ($root.LocalName -ne "Form") {
|
|||||||
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
||||||
} else {
|
} else {
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
$versionRank = Get-FormatRank $version
|
||||||
if ($version -in @("2.17", "2.18", "2.19", "2.20")) {
|
if (-not $version) {
|
||||||
Report-OK "Root element: Form version=$version"
|
|
||||||
} elseif ($version) {
|
|
||||||
Report-Warn "Form version='$version' (expected 2.17-2.20)"
|
|
||||||
} else {
|
|
||||||
Report-Warn "Form version attribute missing"
|
Report-Warn "Form version attribute missing"
|
||||||
|
} elseif ($versionRank -eq 0) {
|
||||||
|
Report-Error "Malformed version '$version' (expected N.N)"
|
||||||
|
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||||
|
Report-Warn "Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
Report-Warn "Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} else {
|
||||||
|
Report-OK "Root element: Form version=$version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +219,15 @@ if (-not $stopped) {
|
|||||||
if ($acb) {
|
if ($acb) {
|
||||||
$acbName = $acb.GetAttribute("name")
|
$acbName = $acb.GetAttribute("name")
|
||||||
$acbId = $acb.GetAttribute("id")
|
$acbId = $acb.GetAttribute("id")
|
||||||
|
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||||
|
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||||
|
# предупреждение; ошибка — только если id вовсе не число.
|
||||||
if ($acbId -eq "-1") {
|
if ($acbId -eq "-1") {
|
||||||
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
||||||
|
} elseif ($acbId -match '^-?\d+$') {
|
||||||
|
Report-Warn "AutoCommandBar id='$acbId', usually '-1'"
|
||||||
} else {
|
} else {
|
||||||
Report-Error "AutoCommandBar id='$acbId', expected '-1'"
|
Report-Error "AutoCommandBar id='$acbId' is not a number"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Report-Error "AutoCommandBar element missing"
|
Report-Error "AutoCommandBar element missing"
|
||||||
@@ -427,11 +507,19 @@ if (-not $stopped) {
|
|||||||
$segments = $cleanPath -split '\.'
|
$segments = $cleanPath -split '\.'
|
||||||
$rootAttr = $segments[0]
|
$rootAttr = $segments[0]
|
||||||
|
|
||||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||||
if ($rootAttr -eq 'Items') {
|
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||||
|
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||||
|
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||||
|
$itemsHops = 0
|
||||||
|
$itemsBroken = $false
|
||||||
|
while ($rootAttr -eq 'Items') {
|
||||||
|
$itemsHops++
|
||||||
|
if ($itemsHops -gt 10) { $itemsBroken = $true; break } # страховка от кольца ссылок
|
||||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||||
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableName = $segments[1]
|
$tableName = $segments[1]
|
||||||
$tableEl = $null
|
$tableEl = $null
|
||||||
@@ -444,17 +532,21 @@ if (-not $stopped) {
|
|||||||
if (-not $tableEl) {
|
if (-not $tableEl) {
|
||||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
||||||
$pathErrors++
|
$pathErrors++
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||||
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||||
# Table without DataPath — can't resolve further, accept silently
|
# Table without DataPath — can't resolve further, accept silently
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||||
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||||
$rootAttr = ($tableDp -split '\.')[0]
|
$segments = $tableDp -split '\.'
|
||||||
|
$rootAttr = $segments[0]
|
||||||
}
|
}
|
||||||
|
if ($itemsBroken) { continue }
|
||||||
|
|
||||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
||||||
@@ -569,13 +661,18 @@ if (-not $stopped) {
|
|||||||
$actionErrors = 0
|
$actionErrors = 0
|
||||||
$actionChecked = 0
|
$actionChecked = 0
|
||||||
|
|
||||||
|
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||||
|
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||||
|
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||||
|
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||||
|
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||||
foreach ($cmd in $cmdNodes) {
|
foreach ($cmd in $cmdNodes) {
|
||||||
if ($stopped) { break }
|
if ($stopped) { break }
|
||||||
$cmdName = $cmd.GetAttribute("name")
|
$cmdName = $cmd.GetAttribute("name")
|
||||||
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
||||||
$actionChecked++
|
$actionChecked++
|
||||||
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
||||||
Report-Error "Command '$cmdName': missing or empty Action"
|
Report-Warn "Command '$cmdName': no Action — handler must be assigned at runtime, otherwise the command does nothing"
|
||||||
$actionErrors++
|
$actionErrors++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -742,6 +839,41 @@ if (-not $stopped -and $isExtension) {
|
|||||||
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 11d. Пути на основной реквизит, которого форма не объявляет.
|
||||||
|
# Check 5 такое пропускает: у заимствованной формы он не проверяет базовые элементы (id < 1000000),
|
||||||
|
# а привязки в <xr:Link> вообще вне его списка тегов. Между тем это ровно тот случай, на котором
|
||||||
|
# платформа отвергает загрузку: «Неверный путь к полю - Объект.X». Правило: если основной реквизит
|
||||||
|
# не объявлен в <Attributes> формы, любой путь с его корнем не разрешится.
|
||||||
|
# Корень берётся из основного реквизита BaseForm: «Объект» он только у формы объекта, у формы
|
||||||
|
# списка это «Список», у формы записи регистра «Запись». С зашитым «Объект» проверка на таких
|
||||||
|
# формах молча не срабатывала — валидатор рапортовал «чисто» на форме, которую платформа не примет.
|
||||||
|
$mainAttrDeclared = $false
|
||||||
|
foreach ($attr in $attrNodes) {
|
||||||
|
$maNode = $attr.SelectSingleNode("f:MainAttribute", $nsMgr)
|
||||||
|
if ($maNode -and $maNode.InnerText.Trim() -eq "true") { $mainAttrDeclared = $true; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $mainAttrDeclared) {
|
||||||
|
# Значения привязок ищем текстом: интересуют и обычные теги, и <xr:DataPath> внутри
|
||||||
|
# <ChoiceParameterLinks>, а те живут в чужом пространстве имён.
|
||||||
|
$rawForm = [System.IO.File]::ReadAllText($FormPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$mainBase = $baseFormNode.SelectSingleNode("f:Attributes/f:Attribute[f:MainAttribute='true']", $bfNs)
|
||||||
|
$rootName = if ($mainBase -and $mainBase.GetAttribute("name")) { $mainBase.GetAttribute("name") } else { "Объект" }
|
||||||
|
$rootPat = [regex]::Escape($rootName)
|
||||||
|
$danglingPaths = @{}
|
||||||
|
foreach ($m in [regex]::Matches($rawForm, "<(?:\w+:)?\w*DataPath[^>]*>(${rootPat}\.[^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||||
|
$danglingPaths[$m.Groups[1].Value] = $true
|
||||||
|
}
|
||||||
|
if ($danglingPaths.Count -gt 0) {
|
||||||
|
$shown = @($danglingPaths.Keys | Sort-Object)
|
||||||
|
$sample = ($shown | Select-Object -First 3) -join ", "
|
||||||
|
$suffix = if ($shown.Count -gt 3) { " (и ещё $($shown.Count - 3))" } else { "" }
|
||||||
|
Report-Error "Path(s) rooted at '${rootName}' but the form declares no MainAttribute: $sample$suffix"
|
||||||
|
} elseif ($mainBase) {
|
||||||
|
Report-OK "Object paths: none dangling (MainAttribute not declared)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check callType without BaseForm (structural warning)
|
# Check callType without BaseForm (structural warning)
|
||||||
@@ -844,6 +976,71 @@ if (-not $stopped) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||||
|
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||||
|
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||||
|
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||||
|
# считаем по узлу (GetNamespaceOfPrefix), а не по корню: локальная xmlns на элементе законна.
|
||||||
|
|
||||||
|
if (-not $stopped) {
|
||||||
|
$prefixErrors = 0
|
||||||
|
$prefixChecked = 0
|
||||||
|
|
||||||
|
$prefixPattern = '^([A-Za-z_][A-Za-z0-9_.-]*):.+$'
|
||||||
|
# Значения, где префикс обязан резолвиться: тип реквизита/колонки и xsi:type
|
||||||
|
# Только листовые узлы: под local-name()='Type' подходит и обёртка <Type>, и вложенный <v8:Type>,
|
||||||
|
# а InnerText обёртки — то же значение, иначе одна ошибка сообщалась бы дважды.
|
||||||
|
foreach ($node in $xmlDoc.SelectNodes("//*[local-name()='Type' or local-name()='TypeSet']", $nsMgr)) {
|
||||||
|
if ($node.SelectSingleNode("*")) { continue }
|
||||||
|
$val = $node.InnerText.Trim()
|
||||||
|
if (-not $val) { continue }
|
||||||
|
$m = [regex]::Match($val, $prefixPattern)
|
||||||
|
if (-not $m.Success) { continue }
|
||||||
|
$prefixChecked++
|
||||||
|
$pfx = $m.Groups[1].Value
|
||||||
|
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||||
|
Report-Error "13. Type '$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||||
|
$prefixErrors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($node in $xmlDoc.SelectNodes("//*[@xsi:type]", $nsMgr)) {
|
||||||
|
$val = $node.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
$m = [regex]::Match($val, $prefixPattern)
|
||||||
|
if (-not $m.Success) { continue }
|
||||||
|
$prefixChecked++
|
||||||
|
$pfx = $m.Groups[1].Value
|
||||||
|
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||||
|
Report-Error "13. xsi:type='$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||||
|
$prefixErrors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prefixChecked -eq 0) {
|
||||||
|
Report-OK "13. Namespace prefixes: nothing to check"
|
||||||
|
} elseif ($prefixErrors -eq 0) {
|
||||||
|
Report-OK "13. Namespace prefixes: $prefixChecked values, all declared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||||
|
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||||
|
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||||
|
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||||
|
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||||
|
|
||||||
|
if (-not $stopped -and $script:versionAnchor) {
|
||||||
|
$formVer = $root.GetAttribute("version")
|
||||||
|
$dumpVer = Detect-FormatVersion (Split-Path (Resolve-Path $FormPath) -Parent)
|
||||||
|
|
||||||
|
if (-not $formVer) {
|
||||||
|
Report-OK "14. Format version: not comparable"
|
||||||
|
} elseif ($formVer -ne $dumpVer) {
|
||||||
|
Report-Error "14. Format version $formVer differs from the dump ($dumpVer) — a dump carries one version, the platform refuses a file it cannot read"
|
||||||
|
} else {
|
||||||
|
Report-OK "14. Format version: $formVer, matches the dump"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Summary ---
|
# --- Summary ---
|
||||||
|
|
||||||
$checks = $script:okCount + $errors + $warnings
|
$checks = $script:okCount + $errors + $warnings
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-validate v1.9 — Validate 1C managed form
|
# form-validate v1.18 — Validate 1C managed form
|
||||||
# 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)
|
||||||
|
|
||||||
|
|
||||||
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
||||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||||
|
|
||||||
@@ -49,6 +71,64 @@ VALID_CFG_PREFIXES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||||
|
# support-guard: is_external_root, авторитет — cf-edit).
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||||
|
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
|
||||||
@@ -60,7 +140,7 @@ def main():
|
|||||||
parser.add_argument("-FormPath", "-Path", required=True)
|
parser.add_argument("-FormPath", "-Path", required=True)
|
||||||
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)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
detailed = args.Detailed
|
detailed = args.Detailed
|
||||||
@@ -106,13 +186,29 @@ def main():
|
|||||||
|
|
||||||
# Detect context: config vs EPF/ERF
|
# Detect context: config vs EPF/ERF
|
||||||
is_config_context = False
|
is_config_context = False
|
||||||
|
config_xml_path = ''
|
||||||
|
version_anchor = ''
|
||||||
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
parent = os.path.dirname(walk_dir)
|
parent = os.path.dirname(walk_dir)
|
||||||
if parent == walk_dir:
|
if parent == walk_dir:
|
||||||
break
|
break
|
||||||
|
# Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки,
|
||||||
|
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||||
|
# версию конфигурации.
|
||||||
|
ext_root = walk_dir + '.xml'
|
||||||
|
if not version_anchor:
|
||||||
|
if _sg_is_external_root(ext_root):
|
||||||
|
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||||
|
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||||
|
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||||
|
version_anchor = ext_root
|
||||||
|
break
|
||||||
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
||||||
is_config_context = True
|
is_config_context = True
|
||||||
|
config_xml_path = os.path.join(walk_dir, 'Configuration.xml')
|
||||||
|
if not version_anchor:
|
||||||
|
version_anchor = config_xml_path
|
||||||
break
|
break
|
||||||
walk_dir = parent
|
walk_dir = parent
|
||||||
|
|
||||||
@@ -161,13 +257,19 @@ def main():
|
|||||||
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
||||||
else:
|
else:
|
||||||
version = root.get("version", "")
|
version = root.get("version", "")
|
||||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
version_rank = format_rank(version)
|
||||||
if version in ("2.17", "2.18", "2.19", "2.20"):
|
if not version:
|
||||||
report_ok(f"Root element: Form version={version}")
|
|
||||||
elif version:
|
|
||||||
report_warn(f"Form version='{version}' (expected 2.17-2.20)")
|
|
||||||
else:
|
|
||||||
report_warn("Form version attribute missing")
|
report_warn("Form version attribute missing")
|
||||||
|
elif version_rank == 0:
|
||||||
|
report_error(f"Malformed version '{version}' (expected N.N)")
|
||||||
|
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||||
|
report_warn(f"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"Format version '{version}' is above the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
else:
|
||||||
|
report_ok(f"Root element: Form version={version}")
|
||||||
|
|
||||||
# --- Check 2: AutoCommandBar ---
|
# --- Check 2: AutoCommandBar ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -175,10 +277,15 @@ def main():
|
|||||||
if acb is not None:
|
if acb is not None:
|
||||||
acb_name = acb.get("name", "")
|
acb_name = acb.get("name", "")
|
||||||
acb_id = acb.get("id", "")
|
acb_id = acb.get("id", "")
|
||||||
|
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||||
|
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||||
|
# предупреждение; ошибка — только если id вовсе не число.
|
||||||
if acb_id == "-1":
|
if acb_id == "-1":
|
||||||
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
||||||
|
elif re.match(r'^-?\d+$', acb_id):
|
||||||
|
report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'")
|
||||||
else:
|
else:
|
||||||
report_error(f"AutoCommandBar id='{acb_id}', expected '-1'")
|
report_error(f"AutoCommandBar id='{acb_id}' is not a number")
|
||||||
else:
|
else:
|
||||||
report_error("AutoCommandBar element missing")
|
report_error("AutoCommandBar element missing")
|
||||||
|
|
||||||
@@ -430,11 +537,21 @@ def main():
|
|||||||
segments = clean_path.split(".")
|
segments = clean_path.split(".")
|
||||||
root_attr = segments[0]
|
root_attr = segments[0]
|
||||||
|
|
||||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||||
if root_attr == 'Items':
|
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||||
|
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||||
|
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||||
|
items_hops = 0
|
||||||
|
items_broken = False
|
||||||
|
while root_attr == 'Items':
|
||||||
|
items_hops += 1
|
||||||
|
if items_hops > 10: # страховка от кольца ссылок
|
||||||
|
items_broken = True
|
||||||
|
break
|
||||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||||
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_name = segments[1]
|
table_name = segments[1]
|
||||||
table_el = None
|
table_el = None
|
||||||
for candidate in all_elements:
|
for candidate in all_elements:
|
||||||
@@ -444,14 +561,19 @@ def main():
|
|||||||
if table_el is None:
|
if table_el is None:
|
||||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
||||||
path_errors += 1
|
path_errors += 1
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||||
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||||
if table_dp.startswith('~'):
|
if table_dp.startswith('~'):
|
||||||
table_dp = table_dp[1:]
|
table_dp = table_dp[1:]
|
||||||
root_attr = table_dp.split(".")[0]
|
segments = table_dp.split(".")
|
||||||
|
root_attr = segments[0]
|
||||||
|
if items_broken:
|
||||||
|
continue
|
||||||
|
|
||||||
if root_attr not in attr_map:
|
if root_attr not in attr_map:
|
||||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
||||||
@@ -465,6 +587,8 @@ def main():
|
|||||||
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
||||||
if path_errors == 0 and path_msg:
|
if path_errors == 0 and path_msg:
|
||||||
report_ok(f"Data bindings: {path_msg}")
|
report_ok(f"Data bindings: {path_msg}")
|
||||||
|
elif path_errors == 0:
|
||||||
|
report_ok("Data bindings: none")
|
||||||
|
|
||||||
# --- Check 6: Button command references ---
|
# --- Check 6: Button command references ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -499,6 +623,8 @@ def main():
|
|||||||
|
|
||||||
if cmd_errors == 0 and cmd_checked > 0:
|
if cmd_errors == 0 and cmd_checked > 0:
|
||||||
report_ok(f"Command references: {cmd_checked} buttons checked")
|
report_ok(f"Command references: {cmd_checked} buttons checked")
|
||||||
|
elif cmd_checked == 0:
|
||||||
|
report_ok("Command references: none")
|
||||||
|
|
||||||
# --- Check 7: Events have handler names ---
|
# --- Check 7: Events have handler names ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -538,12 +664,19 @@ def main():
|
|||||||
|
|
||||||
if event_errors == 0 and event_checked > 0:
|
if event_errors == 0 and event_checked > 0:
|
||||||
report_ok(f"Event handlers: {event_checked} events checked")
|
report_ok(f"Event handlers: {event_checked} events checked")
|
||||||
|
elif event_checked == 0:
|
||||||
|
report_ok("Event handlers: none")
|
||||||
|
|
||||||
# --- Check 8: Command actions ---
|
# --- Check 8: Command actions ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
action_errors = 0
|
action_errors = 0
|
||||||
action_checked = 0
|
action_checked = 0
|
||||||
|
|
||||||
|
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||||
|
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||||
|
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||||
|
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||||
|
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||||
for cmd in cmd_nodes:
|
for cmd in cmd_nodes:
|
||||||
if stopped:
|
if stopped:
|
||||||
break
|
break
|
||||||
@@ -551,11 +684,13 @@ def main():
|
|||||||
action_node = cmd.find(f"{{{F_NS}}}Action")
|
action_node = cmd.find(f"{{{F_NS}}}Action")
|
||||||
action_checked += 1
|
action_checked += 1
|
||||||
if action_node is None or not (action_node.text or "").strip():
|
if action_node is None or not (action_node.text or "").strip():
|
||||||
report_error(f"Command '{cmd_name}': missing or empty Action")
|
report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing")
|
||||||
action_errors += 1
|
action_errors += 1
|
||||||
|
|
||||||
if action_errors == 0 and action_checked > 0:
|
if action_errors == 0 and action_checked > 0:
|
||||||
report_ok(f"Command actions: {action_checked} commands checked")
|
report_ok(f"Command actions: {action_checked} commands checked")
|
||||||
|
elif action_checked == 0:
|
||||||
|
report_ok("Command actions: none")
|
||||||
|
|
||||||
# --- Check 9: MainAttribute count ---
|
# --- Check 9: MainAttribute count ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -686,6 +821,39 @@ def main():
|
|||||||
if (ext_attr_count + ext_cmd_count) > 0:
|
if (ext_attr_count + ext_cmd_count) > 0:
|
||||||
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
||||||
|
|
||||||
|
# 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442.
|
||||||
|
# Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000),
|
||||||
|
# \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 <xr:Link> \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c
|
||||||
|
# \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442
|
||||||
|
# \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 <Attributes> \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u0435\u0433\u043e \u043a\u043e\u0440\u043d\u0435\u043c \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f.
|
||||||
|
# \u041a\u043e\u0440\u0435\u043d\u044c \u0431\u0435\u0440\u0451\u0442\u0441\u044f \u0438\u0437 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 BaseForm: \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043e\u043d \u0442\u043e\u043b\u044c\u043a\u043e \u0443 \u0444\u043e\u0440\u043c\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430, \u0443 \u0444\u043e\u0440\u043c\u044b
|
||||||
|
# \u0441\u043f\u0438\u0441\u043a\u0430 \u044d\u0442\u043e \u00ab\u0421\u043f\u0438\u0441\u043e\u043a\u00bb, \u0443 \u0444\u043e\u0440\u043c\u044b \u0437\u0430\u043f\u0438\u0441\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u00ab\u0417\u0430\u043f\u0438\u0441\u044c\u00bb. \u0421 \u0437\u0430\u0448\u0438\u0442\u044b\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0430\u043a\u0438\u0445
|
||||||
|
# \u0444\u043e\u0440\u043c\u0430\u0445 \u043c\u043e\u043b\u0447\u0430 \u043d\u0435 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u0430.
|
||||||
|
main_attr_declared = False
|
||||||
|
for attr in attr_nodes:
|
||||||
|
ma_node = attr.find(f"{{{F_NS}}}MainAttribute")
|
||||||
|
if ma_node is not None and (ma_node.text or "").strip() == "true":
|
||||||
|
main_attr_declared = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not main_attr_declared:
|
||||||
|
# \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 <xr:DataPath> \u0432\u043d\u0443\u0442\u0440\u0438
|
||||||
|
# <ChoiceParameterLinks>, \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d.
|
||||||
|
with open(form_path, "r", encoding="utf-8-sig") as fh:
|
||||||
|
raw_form = fh.read()
|
||||||
|
main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']")
|
||||||
|
root_name = main_base.get("name") if main_base is not None and main_base.get("name") else "\u041e\u0431\u044a\u0435\u043a\u0442"
|
||||||
|
root_pat = re.escape(root_name)
|
||||||
|
dangling_paths = set(re.findall(
|
||||||
|
r'<(?:\w+:)?\w*DataPath[^>]*>(' + root_pat + r'\.[^<]+)</(?:\w+:)?\w*DataPath>', raw_form))
|
||||||
|
if dangling_paths:
|
||||||
|
shown = sorted(dangling_paths)
|
||||||
|
sample = ", ".join(shown[:3])
|
||||||
|
suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else ""
|
||||||
|
report_error(f"Path(s) rooted at '{root_name}' but the form declares no MainAttribute: {sample}{suffix}")
|
||||||
|
elif main_base is not None:
|
||||||
|
report_ok("Object paths: none dangling (MainAttribute not declared)")
|
||||||
|
|
||||||
# Check callType without BaseForm
|
# Check callType without BaseForm
|
||||||
if not stopped and not is_extension:
|
if not stopped and not is_extension:
|
||||||
call_type_without_base = False
|
call_type_without_base = False
|
||||||
@@ -748,6 +916,62 @@ def main():
|
|||||||
else:
|
else:
|
||||||
report_ok('12. Types: no type values to check')
|
report_ok('12. Types: no type values to check')
|
||||||
|
|
||||||
|
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||||
|
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||||
|
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||||
|
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||||
|
# считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна.
|
||||||
|
if not stopped:
|
||||||
|
prefix_errors = 0
|
||||||
|
prefix_checked = 0
|
||||||
|
prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$')
|
||||||
|
|
||||||
|
for node in root.iter():
|
||||||
|
if not isinstance(node.tag, str):
|
||||||
|
continue
|
||||||
|
ln = localname(node)
|
||||||
|
values = []
|
||||||
|
if ln in ('Type', 'TypeSet'):
|
||||||
|
values.append((node.text or '').strip())
|
||||||
|
xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type')
|
||||||
|
if xsi_type:
|
||||||
|
values.append(xsi_type.strip())
|
||||||
|
for val in values:
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
m = prefix_re.match(val)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
prefix_checked += 1
|
||||||
|
pfx = m.group(1)
|
||||||
|
if pfx not in node.nsmap:
|
||||||
|
kind = "xsi:type" if val == xsi_type else "Type"
|
||||||
|
report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared "
|
||||||
|
"— the platform cannot read the file (XDTO)")
|
||||||
|
prefix_errors += 1
|
||||||
|
|
||||||
|
if prefix_checked == 0:
|
||||||
|
report_ok('13. Namespace prefixes: nothing to check')
|
||||||
|
elif prefix_errors == 0:
|
||||||
|
report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared')
|
||||||
|
|
||||||
|
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||||
|
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||||
|
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||||
|
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||||
|
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||||
|
if not stopped and version_anchor:
|
||||||
|
form_ver = root.get('version', '')
|
||||||
|
dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path)))
|
||||||
|
|
||||||
|
if not form_ver:
|
||||||
|
report_ok('14. Format version: not comparable')
|
||||||
|
elif form_ver != dump_ver:
|
||||||
|
report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) '
|
||||||
|
'— a dump carries one version, the platform refuses a file it cannot read')
|
||||||
|
else:
|
||||||
|
report_ok(f'14. Format version: {form_ver}, matches the dump')
|
||||||
|
|
||||||
# --- Finalize ---
|
# --- Finalize ---
|
||||||
checks = ok_count + errors + warnings
|
checks = ok_count + errors + warnings
|
||||||
if errors == 0 and warnings == 0 and not detailed:
|
if errors == 0 and warnings == 0 and not detailed:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# help-add v1.9 — Add built-in help to 1C object
|
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -149,10 +149,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
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"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||||
|
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||||
|
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
}
|
}
|
||||||
$parent = Split-Path $d -Parent
|
$parent = Split-Path $d -Parent
|
||||||
@@ -195,7 +205,18 @@ $helpXml = @"
|
|||||||
</Help>
|
</Help>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
#
|
||||||
|
# HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||||
|
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 $helpXmlPath $helpXml $encBom
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
# --- 2. Help/<lang>.html ---
|
||||||
|
|
||||||
@@ -255,11 +276,26 @@ if (Test-Path $formsDir) {
|
|||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = $encBom
|
$settings.Encoding = $encBom
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
$xmlDoc.Save($writer)
|
$xmlDoc.Save($writer)
|
||||||
$writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
$stream.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 $formMeta.FullName) -and ([System.IO.File]::ReadAllText($formMeta.FullName) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
|
[System.IO.File]::WriteAllText($formMeta.FullName, $xmlText, $encBom)
|
||||||
|
|
||||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# add-help v1.9 — Add built-in help to 1C object
|
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# 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
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
||||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||||
|
|
||||||
|
|
||||||
@@ -191,6 +213,16 @@ def assert_edit_allowed(target_path, require):
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while 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")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -222,21 +254,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
|
||||||
|
|
||||||
@@ -252,10 +285,24 @@ def save_xml_with_bom(tree, path):
|
|||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|
||||||
def write_text_with_bom(path, text):
|
def write_utf8_bom(path, content):
|
||||||
"""Write text to file with UTF-8 BOM."""
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
with open(path, "w", encoding="utf-8-sig") as f:
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
f.write(text)
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
|
||||||
|
HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -265,7 +312,7 @@ def main():
|
|||||||
parser.add_argument("-ObjectName", required=True)
|
parser.add_argument("-ObjectName", required=True)
|
||||||
parser.add_argument("-Lang", default="ru")
|
parser.add_argument("-Lang", default="ru")
|
||||||
parser.add_argument("-SrcDir", default="src")
|
parser.add_argument("-SrcDir", default="src")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
object_name = args.ObjectName
|
object_name = args.ObjectName
|
||||||
lang = args.Lang
|
lang = args.Lang
|
||||||
@@ -301,7 +348,7 @@ def main():
|
|||||||
'</Help>'
|
'</Help>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(help_xml_path, help_xml)
|
write_xml_file(help_xml_path, help_xml)
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
# --- 2. Help/<lang>.html ---
|
||||||
|
|
||||||
@@ -324,7 +371,7 @@ def main():
|
|||||||
'</html>'
|
'</html>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(help_html_path, help_html)
|
write_utf8_bom(help_html_path, help_html)
|
||||||
|
|
||||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
# --- 3. Check IncludeHelpInContents in form metadata ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# img-grid v1.1 — Overlay numbered grid on image
|
# img-grid v1.2 — Overlay numbered grid on image
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
||||||
|
|
||||||
@@ -16,6 +16,28 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с 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)
|
||||||
|
|
||||||
|
|
||||||
MARGIN_TOP = 20
|
MARGIN_TOP = 20
|
||||||
MARGIN_LEFT = 24
|
MARGIN_LEFT = 24
|
||||||
|
|
||||||
@@ -30,7 +52,7 @@ def main():
|
|||||||
parser.add_argument("-r", "--rows", type=int, default=0,
|
parser.add_argument("-r", "--rows", type=int, default=0,
|
||||||
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
||||||
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
if args.cols <= 0:
|
if args.cols <= 0:
|
||||||
parser.error("--cols must be greater than 0")
|
parser.error("--cols must be greater than 0")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
# interface-edit v1.22 — Edit 1C CommandInterface.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]$CIPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||||
[string]$DefinitionFile,
|
[string]$DefinitionFile,
|
||||||
@@ -17,6 +18,70 @@ $ErrorActionPreference = "Stop"
|
|||||||
if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 }
|
if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 }
|
||||||
if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 }
|
if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 }
|
||||||
|
|
||||||
|
# --- Разбор пользовательского 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Resolve path ---
|
# --- Resolve path ---
|
||||||
if (-not [System.IO.Path]::IsPathRooted($CIPath)) {
|
if (-not [System.IO.Path]::IsPathRooted($CIPath)) {
|
||||||
$CIPath = Join-Path (Get-Location).Path $CIPath
|
$CIPath = Join-Path (Get-Location).Path $CIPath
|
||||||
@@ -162,6 +227,14 @@ Assert-EditAllowed $CIPath 'editable'
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
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"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
@@ -202,7 +275,12 @@ if (-not (Test-Path $CIPath)) {
|
|||||||
</CommandInterface>
|
</CommandInterface>
|
||||||
"@
|
"@
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI, $utf8Bom)
|
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF, без перевода строки в конце.
|
||||||
|
# (Правка существующего файла, наоборот, наследует его стиль — это делает
|
||||||
|
# основной путь сохранения ниже.) Нормализация нужна потому, что here-string
|
||||||
|
# берёт переводы строк из самого .ps1, а он в репозитории хранится с LF.
|
||||||
|
$emptyCI = ($emptyCI -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($CIPath, $emptyCI.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
||||||
} else {
|
} else {
|
||||||
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
||||||
@@ -338,10 +416,10 @@ function Ensure-Section([string]$sectionName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Parse value: string or JSON array ---
|
# --- Parse value: string or JSON array ---
|
||||||
function Parse-ValueList([string]$val) {
|
function Parse-ValueList([string]$val, [string]$opName) {
|
||||||
$val = $val.Trim()
|
$val = $val.Trim()
|
||||||
if ($val.StartsWith("[")) {
|
if ($val.StartsWith("[")) {
|
||||||
$arr = $val | ConvertFrom-Json
|
$arr = ConvertFrom-JsonInput $val "-Value for operation '$opName'" "a JSON array of command names" -Inline
|
||||||
$result = @(); foreach ($item in $arr) { $result += "$item" }
|
$result = @(); foreach ($item in $arr) { $result += "$item" }
|
||||||
return ,$result
|
return ,$result
|
||||||
}
|
}
|
||||||
@@ -383,6 +461,10 @@ $script:typeNormMap = @{
|
|||||||
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
||||||
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
||||||
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
||||||
|
"РегистрРасчёта"="CalculationRegister"; "РегистрРасчета"="CalculationRegister"
|
||||||
|
"ПланВидовРасчёта"="ChartOfCalculationTypes"; "ПланВидовРасчета"="ChartOfCalculationTypes"
|
||||||
|
"Роль"="Role"; "ОбщийМакет"="CommonTemplate"; "ЭлементСтиля"="StyleItem"
|
||||||
|
"ОбщийРеквизит"="CommonAttribute"; "ГруппаКоманд"="CommandGroup"
|
||||||
# Russian plural
|
# Russian plural
|
||||||
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
||||||
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
||||||
@@ -392,6 +474,10 @@ $script:typeNormMap = @{
|
|||||||
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
||||||
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
||||||
"Подсистемы"="Subsystem"
|
"Подсистемы"="Subsystem"
|
||||||
|
"РегистрыРасчёта"="CalculationRegister"; "РегистрыРасчета"="CalculationRegister"
|
||||||
|
"ПланыВидовРасчёта"="ChartOfCalculationTypes"; "ПланыВидовРасчета"="ChartOfCalculationTypes"
|
||||||
|
"Роли"="Role"; "ОбщиеМакеты"="CommonTemplate"; "ЭлементыСтиля"="StyleItem"
|
||||||
|
"ОбщиеРеквизиты"="CommonAttribute"; "ГруппыКоманд"="CommandGroup"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-CmdName([string]$name) {
|
function Normalize-CmdName([string]$name) {
|
||||||
@@ -498,7 +584,7 @@ function Do-Show([string[]]$commands) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Do-Place([string]$jsonVal) {
|
function Do-Place([string]$jsonVal) {
|
||||||
$def = $jsonVal | ConvertFrom-Json
|
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'place'" "a JSON object {command, group}" -Inline
|
||||||
$cmdName = Normalize-CmdName "$($def.command)"
|
$cmdName = Normalize-CmdName "$($def.command)"
|
||||||
$groupName = "$($def.group)"
|
$groupName = "$($def.group)"
|
||||||
if (-not $cmdName -or -not $groupName) { Write-Error "place requires {command, group}"; exit 1 }
|
if (-not $cmdName -or -not $groupName) { Write-Error "place requires {command, group}"; exit 1 }
|
||||||
@@ -531,7 +617,7 @@ function Do-Place([string]$jsonVal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Do-Order([string]$jsonVal) {
|
function Do-Order([string]$jsonVal) {
|
||||||
$def = $jsonVal | ConvertFrom-Json
|
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'order'" "a JSON object {group, commands:[...]}" -Inline
|
||||||
$groupName = "$($def.group)"
|
$groupName = "$($def.group)"
|
||||||
$commands = @($def.commands | ForEach-Object { Normalize-CmdName "$_" })
|
$commands = @($def.commands | ForEach-Object { Normalize-CmdName "$_" })
|
||||||
if (-not $groupName -or $commands.Count -eq 0) { Write-Error "order requires {group, commands:[...]}"; exit 1 }
|
if (-not $groupName -or $commands.Count -eq 0) { Write-Error "order requires {group, commands:[...]}"; exit 1 }
|
||||||
@@ -569,7 +655,7 @@ function Do-Order([string]$jsonVal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Do-SubsystemOrder([string]$jsonVal) {
|
function Do-SubsystemOrder([string]$jsonVal) {
|
||||||
$parsed = $jsonVal | ConvertFrom-Json
|
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'subsystem-order'" "a JSON array of subsystem paths" -Inline
|
||||||
$subsystems = @(); foreach ($s in $parsed) { $subsystems += "$s" }
|
$subsystems = @(); foreach ($s in $parsed) { $subsystems += "$s" }
|
||||||
if ($subsystems.Count -eq 0) { Write-Error "subsystem-order requires array of subsystem paths"; exit 1 }
|
if ($subsystems.Count -eq 0) { Write-Error "subsystem-order requires array of subsystem paths"; exit 1 }
|
||||||
|
|
||||||
@@ -597,7 +683,7 @@ function Do-SubsystemOrder([string]$jsonVal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Do-GroupOrder([string]$jsonVal) {
|
function Do-GroupOrder([string]$jsonVal) {
|
||||||
$parsed = $jsonVal | ConvertFrom-Json
|
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'group-order'" "a JSON array of group names" -Inline
|
||||||
$groups = @(); foreach ($g in $parsed) { $groups += "$g" }
|
$groups = @(); foreach ($g in $parsed) { $groups += "$g" }
|
||||||
if ($groups.Count -eq 0) { Write-Error "group-order requires array of group names"; exit 1 }
|
if ($groups.Count -eq 0) { Write-Error "group-order requires array of group names"; exit 1 }
|
||||||
|
|
||||||
@@ -630,8 +716,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 {
|
||||||
@@ -648,8 +734,8 @@ foreach ($op in $operations) {
|
|||||||
$opValue = if ($opValueRaw -is [string]) { $opValueRaw } else { $opValueRaw | ConvertTo-Json -Compress }
|
$opValue = if ($opValueRaw -is [string]) { $opValueRaw } else { $opValueRaw | ConvertTo-Json -Compress }
|
||||||
|
|
||||||
switch ($opName) {
|
switch ($opName) {
|
||||||
"hide" { Do-Hide (Parse-ValueList $opValue) }
|
"hide" { Do-Hide (Parse-ValueList $opValue $opName) }
|
||||||
"show" { Do-Show (Parse-ValueList $opValue) }
|
"show" { Do-Show (Parse-ValueList $opValue $opName) }
|
||||||
"place" { Do-Place $opValue }
|
"place" { Do-Place $opValue }
|
||||||
"order" { Do-Order $opValue }
|
"order" { Do-Order $opValue }
|
||||||
"subsystem-order" { Do-SubsystemOrder $opValue }
|
"subsystem-order" { Do-SubsystemOrder $opValue }
|
||||||
@@ -674,8 +760,16 @@ $memStream.Close()
|
|||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||||
Info "Saved: $resolvedPath"
|
Info "Saved: $resolvedPath"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
# interface-edit v1.22 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||||
# 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,65 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
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
|
||||||
@@ -189,6 +248,16 @@ def assert_edit_allowed(target_path, require):
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while 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")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -279,10 +348,71 @@ def import_ci_fragment(xml_string):
|
|||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
def parse_value_list(val):
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_value_list(val, op_name):
|
||||||
val = val.strip()
|
val = val.strip()
|
||||||
if val.startswith("["):
|
if val.startswith("["):
|
||||||
arr = json.loads(val)
|
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of command names", inline=True))
|
||||||
return [str(item) for item in arr]
|
return [str(item) for item in arr]
|
||||||
return [val]
|
return [val]
|
||||||
|
|
||||||
@@ -304,21 +434,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
|
||||||
|
|
||||||
@@ -357,6 +488,10 @@ TYPE_NORM_MAP = {
|
|||||||
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
||||||
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
||||||
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
||||||
|
'РегистрРасчёта': 'CalculationRegister', 'РегистрРасчета': 'CalculationRegister',
|
||||||
|
'ПланВидовРасчёта': 'ChartOfCalculationTypes', 'ПланВидовРасчета': 'ChartOfCalculationTypes',
|
||||||
|
'Роль': 'Role', 'ОбщийМакет': 'CommonTemplate', 'ЭлементСтиля': 'StyleItem',
|
||||||
|
'ОбщийРеквизит': 'CommonAttribute', 'ГруппаКоманд': 'CommandGroup',
|
||||||
# Russian plural
|
# Russian plural
|
||||||
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
||||||
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
||||||
@@ -366,6 +501,10 @@ TYPE_NORM_MAP = {
|
|||||||
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
||||||
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
||||||
'Подсистемы': 'Subsystem',
|
'Подсистемы': 'Subsystem',
|
||||||
|
'РегистрыРасчёта': 'CalculationRegister', 'РегистрыРасчета': 'CalculationRegister',
|
||||||
|
'ПланыВидовРасчёта': 'ChartOfCalculationTypes', 'ПланыВидовРасчета': 'ChartOfCalculationTypes',
|
||||||
|
'Роли': 'Role', 'ОбщиеМакеты': 'CommonTemplate', 'ЭлементыСтиля': 'StyleItem',
|
||||||
|
'ОбщиеРеквизиты': 'CommonAttribute', 'ГруппыКоманд': 'CommandGroup',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +540,7 @@ def main():
|
|||||||
parser.add_argument("-Value", default=None)
|
parser.add_argument("-Value", default=None)
|
||||||
parser.add_argument("-CreateIfMissing", action="store_true")
|
parser.add_argument("-CreateIfMissing", action="store_true")
|
||||||
parser.add_argument("-NoValidate", action="store_true")
|
parser.add_argument("-NoValidate", action="store_true")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
# --- Mode validation ---
|
# --- Mode validation ---
|
||||||
if args.DefinitionFile and args.Operation:
|
if args.DefinitionFile and args.Operation:
|
||||||
@@ -438,7 +577,12 @@ def main():
|
|||||||
f'\tversion="{format_version}">\n'
|
f'\tversion="{format_version}">\n'
|
||||||
f'</CommandInterface>'
|
f'</CommandInterface>'
|
||||||
)
|
)
|
||||||
with open(ci_path, "w", encoding="utf-8-sig") as fh:
|
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF в разделителях. (Правка
|
||||||
|
# существующего файла, наоборот, наследует его стиль — это делает
|
||||||
|
# save_xml_bom через _detect_xml_style.) newline="" обязателен: без него
|
||||||
|
# текстовый режим дал бы CRLF на Windows и LF на macOS.
|
||||||
|
empty_ci = empty_ci.replace("\r\n", "\n").replace("\n", "\r\n").rstrip("\r\n")
|
||||||
|
with open(ci_path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
fh.write(empty_ci)
|
fh.write(empty_ci)
|
||||||
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
||||||
else:
|
else:
|
||||||
@@ -564,7 +708,8 @@ def main():
|
|||||||
|
|
||||||
def do_place(json_val):
|
def do_place(json_val):
|
||||||
nonlocal add_count, modify_count
|
nonlocal add_count, modify_count
|
||||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||||
|
json_val, "-Value for operation 'place'", "a JSON object {command, group}", inline=True))
|
||||||
cmd_name = normalize_cmd_name(str(defn["command"]))
|
cmd_name = normalize_cmd_name(str(defn["command"]))
|
||||||
group_name = str(defn["group"])
|
group_name = str(defn["group"])
|
||||||
if not cmd_name or not group_name:
|
if not cmd_name or not group_name:
|
||||||
@@ -592,7 +737,8 @@ def main():
|
|||||||
|
|
||||||
def do_order(json_val):
|
def do_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||||
|
json_val, "-Value for operation 'order'", "a JSON object {group, commands:[...]}", inline=True))
|
||||||
group_name = str(defn["group"])
|
group_name = str(defn["group"])
|
||||||
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
||||||
if not group_name or not commands:
|
if not group_name or not commands:
|
||||||
@@ -626,7 +772,8 @@ def main():
|
|||||||
|
|
||||||
def do_subsystem_order(json_val):
|
def do_subsystem_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||||
|
json_val, "-Value for operation 'subsystem-order'", "a JSON array of subsystem paths", inline=True))
|
||||||
subsystems = [str(s) for s in parsed]
|
subsystems = [str(s) for s in parsed]
|
||||||
if not subsystems:
|
if not subsystems:
|
||||||
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
||||||
@@ -651,7 +798,8 @@ def main():
|
|||||||
|
|
||||||
def do_group_order(json_val):
|
def do_group_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||||
|
json_val, "-Value for operation 'group-order'", "a JSON array of group names", inline=True))
|
||||||
groups = [str(g) for g in parsed]
|
groups = [str(g) for g in parsed]
|
||||||
if not groups:
|
if not groups:
|
||||||
print("group-order requires array of group names", file=sys.stderr)
|
print("group-order requires array of group names", file=sys.stderr)
|
||||||
@@ -680,8 +828,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:
|
||||||
@@ -691,19 +838,21 @@ 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 == "hide":
|
if op_key == "hide":
|
||||||
do_hide(parse_value_list(op_value))
|
do_hide(parse_value_list(op_value, op_name))
|
||||||
elif op_name == "show":
|
elif op_key == "show":
|
||||||
do_show(parse_value_list(op_value))
|
do_show(parse_value_list(op_value, op_name))
|
||||||
elif op_name == "place":
|
elif op_key == "place":
|
||||||
do_place(op_value)
|
do_place(op_value)
|
||||||
elif op_name == "order":
|
elif op_key == "order":
|
||||||
do_order(op_value)
|
do_order(op_value)
|
||||||
elif op_name == "subsystem-order":
|
elif op_key == "subsystem-order":
|
||||||
do_subsystem_order(op_value)
|
do_subsystem_order(op_value)
|
||||||
elif op_name == "group-order":
|
elif op_key == "group-order":
|
||||||
do_group_order(op_value)
|
do_group_order(op_value)
|
||||||
else:
|
else:
|
||||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
# interface-validate v1.4 — Validate 1C CommandInterface.xml 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)][Alias('Path')][string]$CIPath,
|
[Parameter(Mandatory, Position=0)][Alias('Path')][string]$CIPath,
|
||||||
[switch]$Detailed,
|
[switch]$Detailed,
|
||||||
[int]$MaxErrors = 30,
|
[int]$MaxErrors = 30,
|
||||||
[string]$OutFile
|
[string]$OutFile
|
||||||
@@ -51,16 +52,21 @@ $script:output = New-Object System.Text.StringBuilder 8192
|
|||||||
$script:allCommandNames = @()
|
$script:allCommandNames = @()
|
||||||
|
|
||||||
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
||||||
function Report-OK([string]$msg) {
|
function Report-OK {
|
||||||
|
param([string]$msg)
|
||||||
$script:okCount++
|
$script:okCount++
|
||||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||||
}
|
}
|
||||||
function Report-Error([string]$msg) {
|
function Report-Error {
|
||||||
|
param([string]$msg)
|
||||||
$script:errors++
|
$script:errors++
|
||||||
Out-Line "[ERROR] $msg"
|
Out-Line "[ERROR] $msg"
|
||||||
if ($script:errors -ge $MaxErrors) { $script:stopped = $true }
|
if ($script:errors -ge $MaxErrors) {
|
||||||
|
$script:stopped = $true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function Report-Warn([string]$msg) {
|
function Report-Warn {
|
||||||
|
param([string]$msg)
|
||||||
$script:warnings++
|
$script:warnings++
|
||||||
Out-Line "[WARN] $msg"
|
Out-Line "[WARN] $msg"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
# interface-validate v1.4 — Validate 1C CommandInterface.xml structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
||||||
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_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
NS_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
||||||
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
||||||
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||||
@@ -83,7 +105,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)
|
||||||
|
|
||||||
ci_path = args.CIPath
|
ci_path = args.CIPath
|
||||||
detailed = args.Detailed
|
detailed = args.Detailed
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# meta-compile v1.76 — Compile 1C metadata object from JSON
|
# meta-compile v1.102 — Compile 1C metadata object from JSON
|
||||||
# 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]$JsonPath,
|
[string]$JsonPath,
|
||||||
@@ -9,6 +10,70 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$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
|
||||||
|
|
||||||
# --- 1. Load and validate JSON ---
|
# --- 1. Load and validate JSON ---
|
||||||
@@ -18,8 +83,8 @@ if (-not (Test-Path $JsonPath)) {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = Get-Content -Raw -Encoding UTF8 $JsonPath
|
$json = Read-JsonInputFile $JsonPath
|
||||||
$def = $json | ConvertFrom-Json
|
$def = ConvertFrom-JsonInput $json $JsonPath
|
||||||
|
|
||||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||||
@@ -161,7 +226,10 @@ if ($def -is [array] -or ($null -ne $def -and $def.GetType().BaseType.Name -eq '
|
|||||||
$idx = 0
|
$idx = 0
|
||||||
foreach ($item in $def) {
|
foreach ($item in $def) {
|
||||||
$idx++
|
$idx++
|
||||||
$tmpJson = Join-Path ([System.IO.Path]::GetTempPath()) "meta-compile-batch-$idx.json"
|
# Имя с GUID, а не "batch-$idx": фиксированное имя в общем %TEMP% сталкивало
|
||||||
|
# два параллельных запуска навыка на одной машине — Set-Content падал с
|
||||||
|
# «file is being used by another process». py-порт уже брал mkstemp.
|
||||||
|
$tmpJson = Join-Path ([System.IO.Path]::GetTempPath()) "meta-compile-batch-$idx-$([guid]::NewGuid().ToString('N')).json"
|
||||||
try {
|
try {
|
||||||
$item | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $tmpJson
|
$item | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $tmpJson
|
||||||
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$PSCommandPath`" -JsonPath `"$tmpJson`" -OutputDir `"$OutputDir`"" -NoNewWindow -Wait -PassThru
|
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$PSCommandPath`" -JsonPath `"$tmpJson`" -OutputDir `"$OutputDir`"" -NoNewWindow -Wait -PassThru
|
||||||
@@ -405,6 +473,11 @@ $validTypes = @("Catalog","Document","Enum","Constant","InformationRegister","Ac
|
|||||||
"Sequence","FilterCriterion","DocumentNumerator","SettingsStorage","CommonForm",
|
"Sequence","FilterCriterion","DocumentNumerator","SettingsStorage","CommonForm",
|
||||||
"SessionParameter","CommonCommand","CommandGroup","CommonAttribute","FunctionalOptionsParameter","WSReference",
|
"SessionParameter","CommonCommand","CommandGroup","CommonAttribute","FunctionalOptionsParameter","WSReference",
|
||||||
"CommonPicture","CommonTemplate")
|
"CommonPicture","CommonTemplate")
|
||||||
|
# -notin регистронезависим, поэтому "catalog" проходил проверку и дальше шёл в ИМЯ ТЕГА и в
|
||||||
|
# Configuration.xml как есть — выгрузка получалась с <catalog>, которую платформа не принимает.
|
||||||
|
# Прощаем регистр, но приводим к канону списка.
|
||||||
|
$canonType = $validTypes | Where-Object { $_ -eq $objType } | Select-Object -First 1
|
||||||
|
if ($canonType) { $objType = $canonType }
|
||||||
if ($objType -notin $validTypes) {
|
if ($objType -notin $validTypes) {
|
||||||
Write-Error "Unsupported type: $objType. Valid: $($validTypes -join ', ')"
|
Write-Error "Unsupported type: $objType. Valid: $($validTypes -join ', ')"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -544,6 +617,10 @@ $script:typeNamespaceMap = @{
|
|||||||
}
|
}
|
||||||
# Типы current-config пространства (cfg:, объявлено в корне): объектные (CatalogObject.X/DataProcessorObject.X/…)
|
# Типы current-config пространства (cfg:, объявлено в корне): объектные (CatalogObject.X/DataProcessorObject.X/…)
|
||||||
# и голые (ConstantsSet/ReportBuilder). Ссылочные (*Ref.X/DefinedType.X) идут ОТДЕЛЬНО через локальный d5p1 (§memory).
|
# и голые (ConstantsSet/ReportBuilder). Ссылочные (*Ref.X/DefinedType.X) идут ОТДЕЛЬНО через локальный d5p1 (§memory).
|
||||||
|
# Префикс current-config для ссылочных типов. 'cfg' — для файлов, чья шапка его объявляет
|
||||||
|
# (объектный XML, Ext/Form.xml общей формы). $null на время сборки Ext/Predefined.xml, чья
|
||||||
|
# шапка его НЕ объявляет: там и платформа уходит на локальное объявление.
|
||||||
|
$script:cfgPrefix = 'cfg'
|
||||||
$script:cfgBareTypes = @("ConstantsSet", "ReportBuilder", "FilterCriterion")
|
$script:cfgBareTypes = @("ConstantsSet", "ReportBuilder", "FilterCriterion")
|
||||||
$script:cfgObjectKinds = @("Catalog","Document","Enum","ChartOfAccounts","ChartOfCharacteristicTypes",
|
$script:cfgObjectKinds = @("Catalog","Document","Enum","ChartOfAccounts","ChartOfCharacteristicTypes",
|
||||||
"ChartOfCalculationTypes","ExchangePlan","BusinessProcess","Task","InformationRegister","AccumulationRegister",
|
"ChartOfCalculationTypes","ExchangePlan","BusinessProcess","Task","InformationRegister","AccumulationRegister",
|
||||||
@@ -574,7 +651,20 @@ function Resolve-TypeStr {
|
|||||||
param([string]$typeStr)
|
param([string]$typeStr)
|
||||||
if (-not $typeStr) { return $typeStr }
|
if (-not $typeStr) { return $typeStr }
|
||||||
|
|
||||||
# Check for parameterized types: Number(15,2), Строка(100), etc.
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if ($typeStr.StartsWith('cfg:')) {
|
||||||
|
$typeStr = $typeStr.Substring(4)
|
||||||
|
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||||
|
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||||
$baseName = $Matches[1].Trim()
|
$baseName = $Matches[1].Trim()
|
||||||
$params = $Matches[2]
|
$params = $Matches[2]
|
||||||
@@ -583,7 +673,7 @@ function Resolve-TypeStr {
|
|||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for reference types: СправочникСсылка.Организации → CatalogRef.Организации
|
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||||
if ($typeStr.Contains('.')) {
|
if ($typeStr.Contains('.')) {
|
||||||
$dotIdx = $typeStr.IndexOf('.')
|
$dotIdx = $typeStr.IndexOf('.')
|
||||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||||
@@ -593,10 +683,9 @@ function Resolve-TypeStr {
|
|||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
# Simple name lookup
|
# Простое имя
|
||||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||||
if ($resolved) { return $resolved }
|
if ($resolved) { return $resolved }
|
||||||
|
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,10 +694,44 @@ function Emit-TypeContent {
|
|||||||
if (-not $typeStr) { return }
|
if (-not $typeStr) { return }
|
||||||
|
|
||||||
# Composite type: "Type1 + Type2 + Type3"
|
# Composite type: "Type1 + Type2 + Type3"
|
||||||
|
# Платформа пишет сначала ВСЕ <v8:Type>/<v8:TypeSet>, и только потом блоки
|
||||||
|
# квалификаторов — а рекурсия ниже печатала бы каждую часть целиком (тип вместе со
|
||||||
|
# своими квалификаторами). На одиночном типе оба порядка совпадают, поэтому
|
||||||
|
# расхождение вылезало только на составном.
|
||||||
|
# Порядок самих блоков квалификаторов тоже канонический и НЕ зеркалит порядок типов:
|
||||||
|
# Number, String, Date (корпус acc+erp, контрпримеров нет — при типах
|
||||||
|
# boolean,string,dateTime,decimal квалификаторы идут Number,String,Date).
|
||||||
|
# Порядок типов при этом сохраняем как в DSL: он и есть порядок источника.
|
||||||
if ($typeStr.Contains(' + ')) {
|
if ($typeStr.Contains(' + ')) {
|
||||||
$parts = $typeStr -split '\s*\+\s*'
|
$parts = $typeStr -split '\s*\+\s*'
|
||||||
|
$typeLines = New-Object System.Collections.ArrayList
|
||||||
|
$qualBlocks = @{} # 'Number'|'String'|'Date' → строки блока
|
||||||
foreach ($part in $parts) {
|
foreach ($part in $parts) {
|
||||||
|
# X пишет в StringBuilder, поэтому «перехват» — это запомнить длину, вызвать
|
||||||
|
# эмиттер и откатить добавленное. В py-порту X добавляет в список, и там тот
|
||||||
|
# же алгоритм выражен срезом — различие рантаймов, не логики.
|
||||||
|
$before = $script:xml.Length
|
||||||
Emit-TypeContent $indent $part.Trim()
|
Emit-TypeContent $indent $part.Trim()
|
||||||
|
$chunk = $script:xml.ToString($before, $script:xml.Length - $before)
|
||||||
|
[void]$script:xml.Remove($before, $script:xml.Length - $before)
|
||||||
|
$curQual = $null
|
||||||
|
foreach ($line in ($chunk -split "`r?`n")) {
|
||||||
|
if ($line -eq '') { continue }
|
||||||
|
if ($line -match '<v8:(String|Number|Date)Qualifiers>') {
|
||||||
|
$curQual = $Matches[1]
|
||||||
|
$qualBlocks[$curQual] = New-Object System.Collections.ArrayList
|
||||||
|
}
|
||||||
|
if ($curQual) {
|
||||||
|
[void]$qualBlocks[$curQual].Add($line)
|
||||||
|
if ($line -match '</v8:(String|Number|Date)Qualifiers>') { $curQual = $null }
|
||||||
|
} else {
|
||||||
|
[void]$typeLines.Add($line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($line in $typeLines) { X $line }
|
||||||
|
foreach ($q in @('Number', 'String', 'Date')) {
|
||||||
|
if ($qualBlocks.ContainsKey($q)) { foreach ($line in $qualBlocks[$q]) { X $line } }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -723,9 +846,22 @@ function Emit-TypeContent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
# Reference types — use local xmlns declaration for 1C compatibility
|
# Ссылочные типы — корневой cfg:, как пишет платформа. Раньше здесь объявлялся
|
||||||
|
# ЛОКАЛЬНЫЙ xmlns:d5p1 на тот же URI, что уже объявлен в шапке ($script:xmlnsDecl):
|
||||||
|
# формально эквивалентно (значим URI, не префикс) и платформой принималось, но
|
||||||
|
# первый же цикл «загрузить в базу → выгрузить» переписывал каждый ссылочный тип
|
||||||
|
# в cfg: — то есть давал diff-шум на ровном месте. Форма пришла из СКД, где cfg:
|
||||||
|
# действительно не работает; в метаданных такого ограничения нет.
|
||||||
|
# NB: локальная xmlns остаётся законной для ЧУЖИХ пространств — см. $script:typeNamespaceMap.
|
||||||
|
# $script:cfgPrefix = $null означает «пишем файл, корень которого cfg НЕ объявляет»
|
||||||
|
# (Ext/Predefined.xml — его шапка это predef/v8/xr/xs/xsi). Там платформа сама уходит
|
||||||
|
# на локальную форму: в корпусе `<v8:Type xmlns:d6p1="…current-config">d6p1:CatalogRef.Валюты`.
|
||||||
if ($typeStr -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$') {
|
if ($typeStr -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$') {
|
||||||
|
if ($script:cfgPrefix) {
|
||||||
|
X "$indent<v8:Type>$($script:cfgPrefix):$typeStr</v8:Type>"
|
||||||
|
} else {
|
||||||
X "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$typeStr</v8:Type>"
|
X "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$typeStr</v8:Type>"
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,15 +1381,21 @@ $script:standardAttributesByType = @{
|
|||||||
"Document" = @("Posted","Ref","DeletionMark","Date","Number")
|
"Document" = @("Posted","Ref","DeletionMark","Date","Number")
|
||||||
"Enum" = @("Order","Ref")
|
"Enum" = @("Order","Ref")
|
||||||
"InformationRegister" = @("Active","LineNumber","Recorder","Period")
|
"InformationRegister" = @("Active","LineNumber","Recorder","Period")
|
||||||
"AccumulationRegister" = @("Active","LineNumber","Recorder","Period")
|
"AccumulationRegister" = @("RecordType","Active","LineNumber","Recorder","Period")
|
||||||
"AccountingRegister" = @("Active","Period","Recorder","LineNumber","Account")
|
"AccountingRegister" = @("PeriodAdjustment","Account","RecordType","Active","LineNumber","Recorder","Period")
|
||||||
"CalculationRegister" = @("Active","Recorder","LineNumber","RegistrationPeriod","CalculationType","ReversingEntry")
|
"CalculationRegister" = @("RegistrationPeriod","ReversingEntry","Active","EndOfBasePeriod","BegOfBasePeriod","EndOfActionPeriod","BegOfActionPeriod","ActionPeriod","CalculationType","LineNumber","Recorder")
|
||||||
"ChartOfAccounts" = @("PredefinedDataName","Order","OffBalance","Type","Description","Code","Parent","Predefined","DeletionMark","Ref")
|
"ChartOfAccounts" = @("PredefinedDataName","Order","OffBalance","Type","Description","Code","Parent","Predefined","DeletionMark","Ref")
|
||||||
"ChartOfCharacteristicTypes" = @("PredefinedDataName","Predefined","Ref","DeletionMark","Description","Code","Parent","ValueType")
|
"ChartOfCharacteristicTypes" = @("PredefinedDataName","ValueType","Description","Code","IsFolder","Parent","Predefined","DeletionMark","Ref")
|
||||||
"ChartOfCalculationTypes" = @("PredefinedDataName","Predefined","Ref","DeletionMark","ActionPeriodIsBasic","Description","Code")
|
"ChartOfCalculationTypes" = @("PredefinedDataName","Predefined","Ref","DeletionMark","ActionPeriodIsBasic","Description","Code")
|
||||||
"BusinessProcess" = @("Ref","DeletionMark","Date","Number","Started","Completed","HeadTask")
|
"BusinessProcess" = @("Started","HeadTask","Completed","Ref","DeletionMark","Date","Number")
|
||||||
"Task" = @("Ref","DeletionMark","Date","Number","Executed","Description","RoutePoint","BusinessProcess")
|
"Task" = @("Executed","Description","RoutePoint","BusinessProcess","Ref","DeletionMark","Date","Number")
|
||||||
"ExchangePlan" = @("Ref","DeletionMark","Code","Description","ThisNode","SentNo","ReceivedNo")
|
# Порядок в каждом списке — канон выгрузки, снят с корпуса acc+erp (внутри типа разброса нет).
|
||||||
|
# Условные члены перечислены в $script:stdAttrConditions — позицию они берут отсюда, а
|
||||||
|
# присутствие определяется свойствами объекта.
|
||||||
|
# У ПВХ IsFolder входит в фикс-список: он есть у всех 23 объектов корпуса с этим блоком.
|
||||||
|
# У регистра расчёта список безусловен: реквизиты периода действия и базового периода
|
||||||
|
# платформа пишет при любых ActionPeriod/BasePeriod/Periodicity (синтетика, все 4 комбинации).
|
||||||
|
"ExchangePlan" = @("ThisNode","ReceivedNo","SentNo","Ref","DeletionMark","Description","Code")
|
||||||
"DocumentJournal" = @("Type","Ref","Date","Posted","DeletionMark","Number")
|
"DocumentJournal" = @("Type","Ref","Date","Posted","DeletionMark","Number")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,6 +1514,55 @@ function Emit-StandardAttribute {
|
|||||||
# - stdAttrProfile[тип]: профиль материализованного блока (пусто = schema-дефолт), поверх — DSL-override.
|
# - stdAttrProfile[тип]: профиль материализованного блока (пусто = schema-дефолт), поверх — DSL-override.
|
||||||
# Миграция типа = добавить его в stdAttrConditionalTypes + stdAttrProfile и переснять снэпшоты; КОД НЕ ТРОГАЕМ.
|
# Миграция типа = добавить его в stdAttrConditionalTypes + stdAttrProfile и переснять снэпшоты; КОД НЕ ТРОГАЕМ.
|
||||||
$script:stdAttrConditionalTypes = @('Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document')
|
$script:stdAttrConditionalTypes = @('Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document')
|
||||||
|
|
||||||
|
# Условные члены списка типа: позиция берётся из $script:standardAttributesByType, а присутствие —
|
||||||
|
# из свойств самого объекта, как у платформы. Предикат ОБЯЗАН принимать определение параметром:
|
||||||
|
# scriptblock не видит $def вызывающей функции, и отказ был бы молчаливым.
|
||||||
|
$script:stdAttrConditions = @{
|
||||||
|
"AccountingRegister" = @{
|
||||||
|
"PeriodAdjustment" = { param($d) $v = 0; if ($null -ne $d.periodAdjustmentLength) { $v = [int]"$($d.periodAdjustmentLength)" }; $v -gt 0 }
|
||||||
|
"RecordType" = { param($d) -not ($d.correspondence -eq $true) }
|
||||||
|
}
|
||||||
|
"AccumulationRegister" = @{
|
||||||
|
"RecordType" = { param($d) $raw = if ($d.registerType) { "$($d.registerType)" } else { "Balance" }; (Normalize-EnumValue "RegisterType" $raw) -eq "Balance" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Хвостовая группа: реквизиты, которых нет в списке типа и которые идут ПОСЛЕ него.
|
||||||
|
# У бухрегистра это пары субконто. Именами их не перечислить: их количество задаётся
|
||||||
|
# свойством MaxExtDimensionCount плана счетов, а не константой (в корпусе везде 3, но
|
||||||
|
# это однородность выборки, а не правило). Поэтому — шаблон, а не список.
|
||||||
|
$script:stdAttrTailPattern = @{
|
||||||
|
"AccountingRegister" = '^ExtDimension(Type)?\d+$'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Состав хвоста задаёт не DSL, а объект, на который регистр ссылается: пар субконто столько,
|
||||||
|
# сколько у плана счетов MaxExtDimensionCount. Читаем его из выгрузки — как версию формата из
|
||||||
|
# Configuration.xml, — чтобы регистр, описанный неполным DSL, совпал с тем, что материализует
|
||||||
|
# платформа. План не найден → хвост не генерируем и говорим об этом в выводе.
|
||||||
|
$script:stdAttrTailHint = $null
|
||||||
|
$script:stdAttrTailDerived = @{
|
||||||
|
"AccountingRegister" = {
|
||||||
|
param($d, $objectName, $outDir)
|
||||||
|
$ref = "$($d.chartOfAccounts)"
|
||||||
|
if (-not $ref) { return @() }
|
||||||
|
$chartName = $ref -replace '^.*\.', '' # ссылка вида ChartOfAccounts.X (имя объекта точек не содержит)
|
||||||
|
$path = Join-Path (Join-Path $outDir "ChartsOfAccounts") "$chartName.xml"
|
||||||
|
if (-not (Test-Path -LiteralPath $path)) {
|
||||||
|
$script:stdAttrTailHint = "ChartOfAccounts '$chartName' not found in dump — ExtDimension pairs not generated (platform will add them on load)"
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$n = 0
|
||||||
|
if ([System.IO.File]::ReadAllText($path) -match '<MaxExtDimensionCount>(\d+)</MaxExtDimensionCount>') { $n = [int]$matches[1] }
|
||||||
|
$out = @()
|
||||||
|
for ($i = 1; $i -le $n; $i++) {
|
||||||
|
# ExtDimensionN связан с Account через LinkByType (LinkItem = номер), ExtDimensionTypeN — нет.
|
||||||
|
$out += @{ name = "ExtDimension$i"; ov = @{ LinkByType = @{ dataPath = "AccountingRegister.$objectName.StandardAttribute.Account"; linkItem = $i } } }
|
||||||
|
$out += @{ name = "ExtDimensionType$i"; ov = @{} }
|
||||||
|
}
|
||||||
|
return $out
|
||||||
|
}
|
||||||
|
}
|
||||||
function Emit-StandardAttributes {
|
function Emit-StandardAttributes {
|
||||||
param([string]$indent, [string]$objectType)
|
param([string]$indent, [string]$objectType)
|
||||||
$attrs = $script:standardAttributesByType[$objectType]
|
$attrs = $script:standardAttributesByType[$objectType]
|
||||||
@@ -1381,14 +1572,45 @@ function Emit-StandardAttributes {
|
|||||||
if ($conditional -and $null -eq $sa) { return } # условный тип без кастомизации → блока нет
|
if ($conditional -and $null -eq $sa) { return } # условный тип без кастомизации → блока нет
|
||||||
if ($sa -is [string] -and $sa -eq '') { return } # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок — правило не выводимо)
|
if ($sa -is [string] -and $sa -eq '') { return } # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок — правило не выводимо)
|
||||||
$profile = $script:stdAttrProfile[$objectType]; if (-not $profile) { $profile = @{} }
|
$profile = $script:stdAttrProfile[$objectType]; if (-not $profile) { $profile = @{} }
|
||||||
# Доп. (опциональные) стандартные реквизиты вне фикс-списка типа — напр. ExchangeDate у части ПланОбмена
|
# Список типа задаёт ПОРЯДОК всех известных стандартных реквизитов, включая условные:
|
||||||
# (легаси, присутствие не выводится из свойств). Эмитим по факту наличия ключа в DSL, ПЕРЕД фикс-списком (их позиция).
|
# их позиция бывает и до, и после обязательных (у бухрегистра PeriodAdjustment идёт
|
||||||
$extra = @()
|
# перед Account, RecordType — после, а ExtDimension1..3/ExtDimensionType1..3 — после Period),
|
||||||
if ($sa) { foreach ($k in $sa.PSObject.Properties.Name) { if ($attrs -notcontains $k) { $extra += $k } } }
|
# поэтому «условные скопом вперёд» не выражает канон.
|
||||||
|
$cond = $script:stdAttrConditions[$objectType]
|
||||||
|
# Ключи, которых нет в списке типа ВООБЩЕ. По умолчанию их позиция — ПЕРЕД списком
|
||||||
|
# (легаси вроде ExchangeDate у части планов обмена). Подходящие под хвостовой шаблон
|
||||||
|
# типа идут ПОСЛЕ, в порядке номера, а внутри номера — сначала ExtDimensionN, затем
|
||||||
|
# ExtDimensionTypeN (порядок платформы).
|
||||||
|
$tailRe = $script:stdAttrTailPattern[$objectType]
|
||||||
|
$extra = @(); $tail = @()
|
||||||
|
if ($sa) {
|
||||||
|
foreach ($k in $sa.PSObject.Properties.Name) {
|
||||||
|
if ($attrs -contains $k) { continue }
|
||||||
|
if ($tailRe -and $k -match $tailRe) { $tail += $k } else { $extra += $k }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Хвост, выведенный из связанного объекта: дополняет DSL, а не заменяет его — лишнее из DSL
|
||||||
|
# остаётся (прощаем), недостающее добавляется вместе со своими значениями по умолчанию.
|
||||||
|
$derivedOv = @{}
|
||||||
|
$gen = $script:stdAttrTailDerived[$objectType]
|
||||||
|
if ($gen) {
|
||||||
|
foreach ($e in @(& $gen $def $objName $OutputDir)) {
|
||||||
|
$derivedOv[$e.name] = $e.ov
|
||||||
|
if ($tail -notcontains $e.name) { $tail += $e.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$tail = @($tail | Sort-Object @{e={[int]([regex]::Match($_, '\d+').Value)}}, @{e={ if ($_ -match 'Type\d+$') { 1 } else { 0 } }})
|
||||||
X "$indent<StandardAttributes>"
|
X "$indent<StandardAttributes>"
|
||||||
foreach ($a in ($extra + $attrs)) {
|
foreach ($a in ($extra + $attrs + $tail)) {
|
||||||
|
# Условный реквизит: эмитим, если так велят свойства объекта ЛИБО если ключ есть в DSL.
|
||||||
|
# Дизъюнкция страхует роундтрип — декомпилятор перечисляет все имена блока.
|
||||||
|
if ($cond -and $cond.ContainsKey($a)) {
|
||||||
|
$present = ($sa -and $sa.PSObject.Properties[$a]) -or (& $cond[$a] $def)
|
||||||
|
if (-not $present) { continue }
|
||||||
|
}
|
||||||
$ov = @{}
|
$ov = @{}
|
||||||
if ($profile.ContainsKey($a)) { foreach ($k in $profile[$a].Keys) { $ov[$k] = $profile[$a][$k] } }
|
if ($profile.ContainsKey($a)) { foreach ($k in $profile[$a].Keys) { $ov[$k] = $profile[$a][$k] } }
|
||||||
|
if ($derivedOv.ContainsKey($a)) { foreach ($k in $derivedOv[$a].Keys) { $ov[$k] = $derivedOv[$a][$k] } }
|
||||||
if ($sa) { # DSL-override применяем всегда при наличии ключа (для не-условных типов тоже, напр. ExchangePlan)
|
if ($sa) { # DSL-override применяем всегда при наличии ключа (для не-условных типов тоже, напр. ExchangePlan)
|
||||||
$d = $sa.$a
|
$d = $sa.$a
|
||||||
if ($d) {
|
if ($d) {
|
||||||
@@ -1946,9 +2168,12 @@ function Emit-Attribute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use — only for catalog top-level attributes
|
# Use — у реквизитов справочника и ПВХ. Позиция РАЗНАЯ: справочник пишет Use ПЕРЕД
|
||||||
if ($context -eq "catalog") {
|
# Indexing, ПВХ — ПОСЛЕ него (корпус acc+erp: Catalog `Use,Indexing,FullTextSearch`,
|
||||||
|
# ПВХ `Indexing,Use,FullTextSearch,DataHistory`). Отсюда отдельный контекст "cct":
|
||||||
|
# структурно реквизит ПВХ совпадает со справочником, расходится только этим порядком.
|
||||||
$use = if ($parsed.use) { $parsed.use } else { "ForItem" }
|
$use = if ($parsed.use) { $parsed.use } else { "ForItem" }
|
||||||
|
if ($context -eq "catalog") {
|
||||||
X "$indent`t`t<Use>$use</Use>"
|
X "$indent`t`t<Use>$use</Use>"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1964,6 +2189,7 @@ function Emit-Attribute {
|
|||||||
if ($parsed.indexing) { $indexing = $parsed.indexing }
|
if ($parsed.indexing) { $indexing = $parsed.indexing }
|
||||||
X "$indent`t`t<Indexing>$indexing</Indexing>"
|
X "$indent`t`t<Indexing>$indexing</Indexing>"
|
||||||
}
|
}
|
||||||
|
if ($context -eq "cct") { X "$indent`t`t<Use>$use</Use>" }
|
||||||
|
|
||||||
# Реквизит адресации задачи: AddressingDimension (ссылка на измерение регистра исполнителей), между Indexing и FullTextSearch.
|
# Реквизит адресации задачи: AddressingDimension (ссылка на измерение регистра исполнителей), между Indexing и FullTextSearch.
|
||||||
if ($context -eq "task-addressing" -and $elemTag -eq "AddressingAttribute") {
|
if ($context -eq "task-addressing" -and $elemTag -eq "AddressingAttribute") {
|
||||||
@@ -2131,6 +2357,11 @@ function Emit-EnumValue {
|
|||||||
X "$indent`t`t<Name>$(Esc-XmlText $parsed.name)</Name>"
|
X "$indent`t`t<Name>$(Esc-XmlText $parsed.name)</Name>"
|
||||||
Emit-MLText "$indent`t`t" "Synonym" $parsed.synonym
|
Emit-MLText "$indent`t`t" "Synonym" $parsed.synonym
|
||||||
if ($parsed.comment) { X "$indent`t`t<Comment>$(Esc-XmlText $parsed.comment)</Comment>" } else { X "$indent`t`t<Comment/>" }
|
if ($parsed.comment) { X "$indent`t`t<Comment>$(Esc-XmlText $parsed.comment)</Comment>" } else { X "$indent`t`t<Comment/>" }
|
||||||
|
# Цвет значения перечисления — свойство формата 2.21 (8.5), последним в Properties.
|
||||||
|
if ($script:isFormat221) {
|
||||||
|
$color = if ($parsed.color) { "$($parsed.color)" } else { "auto" }
|
||||||
|
X "$indent`t`t<Color>$(Esc-XmlText $color)</Color>"
|
||||||
|
}
|
||||||
X "$indent`t</Properties>"
|
X "$indent`t</Properties>"
|
||||||
X "$indent</EnumValue>"
|
X "$indent</EnumValue>"
|
||||||
}
|
}
|
||||||
@@ -2797,6 +3028,11 @@ function Emit-CommonFormProperties {
|
|||||||
} else {
|
} else {
|
||||||
X "$i<UsePurposes/>"
|
X "$i<UsePurposes/>"
|
||||||
}
|
}
|
||||||
|
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||||
|
# между UsePurposes и UseStandardCommands.
|
||||||
|
if ($script:isFormat221) {
|
||||||
|
X "$i<UseInInterfaceCompatibilityMode>$(Get-EnumProp 'UseInInterfaceCompatibilityMode' 'useInInterfaceCompatibilityMode' 'Any')</UseInInterfaceCompatibilityMode>"
|
||||||
|
}
|
||||||
$useStdCmds = if (Get-BoolProp "useStandardCommands" $false) { "true" } else { "false" }
|
$useStdCmds = if (Get-BoolProp "useStandardCommands" $false) { "true" } else { "false" }
|
||||||
X "$i<UseStandardCommands>$useStdCmds</UseStandardCommands>"
|
X "$i<UseStandardCommands>$useStdCmds</UseStandardCommands>"
|
||||||
Emit-MLText $i "ExtendedPresentation" $def.extendedPresentation
|
Emit-MLText $i "ExtendedPresentation" $def.extendedPresentation
|
||||||
@@ -3040,7 +3276,9 @@ function Emit-ScheduledJobProperties {
|
|||||||
if ($description) { X "$i<Description>$(Esc-XmlText $description)</Description>" } else { X "$i<Description/>" }
|
if ($description) { X "$i<Description>$(Esc-XmlText $description)</Description>" } else { X "$i<Description/>" }
|
||||||
|
|
||||||
$key = if ($def.key) { "$($def.key)" } else { "" }
|
$key = if ($def.key) { "$($def.key)" } else { "" }
|
||||||
X "$i<Key>$(Esc-XmlText $key)</Key>"
|
# Пустое значение → самозакрывающийся, как у <Description> выше: Конфигуратор
|
||||||
|
# не пишет пустых пар.
|
||||||
|
if ($key) { X "$i<Key>$(Esc-XmlText $key)</Key>" } else { X "$i<Key/>" }
|
||||||
|
|
||||||
$use = if ($def.use -eq $true) { "true" } else { "false" }
|
$use = if ($def.use -eq $true) { "true" } else { "false" }
|
||||||
X "$i<Use>$use</Use>"
|
X "$i<Use>$use</Use>"
|
||||||
@@ -3105,6 +3343,8 @@ function Emit-ReportProperties {
|
|||||||
Emit-VerbatimRef $i "DefaultSettingsForm" $def.defaultSettingsForm
|
Emit-VerbatimRef $i "DefaultSettingsForm" $def.defaultSettingsForm
|
||||||
Emit-VerbatimRef $i "AuxiliarySettingsForm" $def.auxiliarySettingsForm
|
Emit-VerbatimRef $i "AuxiliarySettingsForm" $def.auxiliarySettingsForm
|
||||||
Emit-VerbatimRef $i "DefaultVariantForm" $def.defaultVariantForm
|
Emit-VerbatimRef $i "DefaultVariantForm" $def.defaultVariantForm
|
||||||
|
# Вспомогательная форма варианта отчёта — свойство формата 2.21 (8.5).
|
||||||
|
if ($script:isFormat221) { Emit-VerbatimRef $i "AuxiliaryVariantForm" $def.auxiliaryVariantForm }
|
||||||
Emit-VerbatimRef $i "VariantsStorage" $def.variantsStorage
|
Emit-VerbatimRef $i "VariantsStorage" $def.variantsStorage
|
||||||
Emit-VerbatimRef $i "SettingsStorage" $def.settingsStorage
|
Emit-VerbatimRef $i "SettingsStorage" $def.settingsStorage
|
||||||
$inclHelp = if (Get-BoolProp "includeHelpInContents" $false) { "true" } else { "false" }
|
$inclHelp = if (Get-BoolProp "includeHelpInContents" $false) { "true" } else { "false" }
|
||||||
@@ -3840,7 +4080,8 @@ function Emit-WebServiceProperties {
|
|||||||
if ($def.comment) { X "$i<Comment>$(Esc-XmlText "$($def.comment)")</Comment>" } else { X "$i<Comment/>" }
|
if ($def.comment) { X "$i<Comment>$(Esc-XmlText "$($def.comment)")</Comment>" } else { X "$i<Comment/>" }
|
||||||
|
|
||||||
$namespace = if ($def.namespace) { "$($def.namespace)" } else { "" }
|
$namespace = if ($def.namespace) { "$($def.namespace)" } else { "" }
|
||||||
X "$i<Namespace>$(Esc-XmlText $namespace)</Namespace>"
|
# Пустое значение → самозакрывающийся, как у <Comment> выше.
|
||||||
|
if ($namespace) { X "$i<Namespace>$(Esc-XmlText $namespace)</Namespace>" } else { X "$i<Namespace/>" }
|
||||||
|
|
||||||
# XDTOPackages — СПИСОК элементов, а не скаляр: значение либо ссылка на пакет конфигурации
|
# XDTOPackages — СПИСОК элементов, а не скаляр: значение либо ссылка на пакет конфигурации
|
||||||
# (xr:MDObjectRef "XDTOPackage.Имя"), либо URI внешнего пространства имён (xs:string).
|
# (xr:MDObjectRef "XDTOPackage.Имя"), либо URI внешнего пространства имён (xs:string).
|
||||||
@@ -4082,6 +4323,14 @@ $script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
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"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
@@ -4143,6 +4392,15 @@ $script:compatMode = Detect-CompatibilityMode $OutputDir
|
|||||||
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
||||||
$script:isFormat218 = (Get-FormatRank $script:formatVersion) -ge 218
|
$script:isFormat218 = (Get-FormatRank $script:formatVersion) -ge 218
|
||||||
$script:isFormat220 = (Get-FormatRank $script:formatVersion) -ge 220
|
$script:isFormat220 = (Get-FormatRank $script:formatVersion) -ge 220
|
||||||
|
$script:isFormat221 = (Get-FormatRank $script:formatVersion) -ge 221
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту.
|
||||||
|
# Только для шапок MetaDataObject и Form — в файлах с корнем extrnprops
|
||||||
|
# (Ext/ClientApplicationInterface.xml и т.п.) платформа его не пишет.
|
||||||
|
if ($script:isFormat221) {
|
||||||
|
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||||
|
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', "$palNs xmlns:style="
|
||||||
|
}
|
||||||
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
||||||
$script:lineNumberLengthDefault = if ((Get-CompatModeRank $script:compatMode) -ge 80327) { 9 } else { 5 }
|
$script:lineNumberLengthDefault = if ((Get-CompatModeRank $script:compatMode) -ge 80327) { 9 } else { 5 }
|
||||||
|
|
||||||
@@ -4268,7 +4526,7 @@ if ($objType -in $typesWithAttrTS) {
|
|||||||
"Catalog" { "catalog" }
|
"Catalog" { "catalog" }
|
||||||
"Document" { "document" }
|
"Document" { "document" }
|
||||||
{ $_ -in @("DataProcessor","Report") } { "processor" }
|
{ $_ -in @("DataProcessor","Report") } { "processor" }
|
||||||
"ChartOfCharacteristicTypes" { "catalog" } # реквизиты ПВХ структурно как у справочника (Use/FillFromFillingValue/DataHistory)
|
"ChartOfCharacteristicTypes" { "cct" } # как catalog (Use/FillFromFillingValue/DataHistory), но Use ПОСЛЕ Indexing
|
||||||
{ $_ -in @("ChartOfAccounts","ChartOfCalculationTypes") } { "account" } # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
{ $_ -in @("ChartOfAccounts","ChartOfCalculationTypes") } { "account" } # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
||||||
default { "object" }
|
default { "object" }
|
||||||
}
|
}
|
||||||
@@ -4356,17 +4614,32 @@ if ($objType -in @("InformationRegister","AccumulationRegister","AccountingRegis
|
|||||||
$regCtx = switch ($objType) { "InformationRegister" { "register-info" } "CalculationRegister" { "register-calc" } default { "register-other" } }
|
$regCtx = switch ($objType) { "InformationRegister" { "register-info" } "CalculationRegister" { "register-calc" } default { "register-other" } }
|
||||||
# Все семейства регистров: ресурсы/измерения — через богатый Emit-Attribute (общий слой object-свойств).
|
# Все семейства регистров: ресурсы/измерения — через богатый Emit-Attribute (общий слой object-свойств).
|
||||||
$dimResCtx = switch ($objType) { "InformationRegister" { "register-info" } "AccumulationRegister" { "register-accum" } "CalculationRegister" { "register-calc" } "AccountingRegister" { "register-account" } default { $null } }
|
$dimResCtx = switch ($objType) { "InformationRegister" { "register-info" } "AccumulationRegister" { "register-accum" } "CalculationRegister" { "register-calc" } "AccountingRegister" { "register-account" } default { $null } }
|
||||||
|
# Порядок видов детей — канон выгрузки, снят с корпуса (acc+erp, разброса внутри
|
||||||
|
# типа нет): у большинства регистров Resource, Attribute, Dimension, а у
|
||||||
|
# бухгалтерского — Dimension, Resource, Attribute. Команды у платформы идут
|
||||||
|
# последними, как и здесь.
|
||||||
|
$kindOrder = if ($objType -eq "AccountingRegister") { @('dim','res','attr') } else { @('res','attr','dim') }
|
||||||
|
foreach ($kind in $kindOrder) {
|
||||||
|
switch ($kind) {
|
||||||
|
'res' {
|
||||||
foreach ($r in $resources) {
|
foreach ($r in $resources) {
|
||||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $r $dimResCtx "Resource" }
|
if ($dimResCtx) { Emit-Attribute "`t`t`t" $r $dimResCtx "Resource" }
|
||||||
else { Emit-Resource "`t`t`t" $r $objType }
|
else { Emit-Resource "`t`t`t" $r $objType }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
'dim' {
|
||||||
foreach ($d in $dims) {
|
foreach ($d in $dims) {
|
||||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $d $dimResCtx "Dimension" }
|
if ($dimResCtx) { Emit-Attribute "`t`t`t" $d $dimResCtx "Dimension" }
|
||||||
else { Emit-Dimension "`t`t`t" $d $objType }
|
else { Emit-Dimension "`t`t`t" $d $objType }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
'attr' {
|
||||||
foreach ($a in $regAttrs) {
|
foreach ($a in $regAttrs) {
|
||||||
Emit-Attribute "`t`t`t" $a $regCtx
|
Emit-Attribute "`t`t`t" $a $regCtx
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
foreach ($cmd in $regCommands) {
|
foreach ($cmd in $regCommands) {
|
||||||
Emit-Command "`t`t`t" $cmd.name $cmd.def
|
Emit-Command "`t`t`t" $cmd.name $cmd.def
|
||||||
}
|
}
|
||||||
@@ -4609,9 +4882,13 @@ function Build-PredefinedXml {
|
|||||||
$sb = New-Object System.Text.StringBuilder
|
$sb = New-Object System.Text.StringBuilder
|
||||||
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
||||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"$xsiType`" version=`"$($script:formatVersion)`">`n")
|
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"$xsiType`" version=`"$($script:formatVersion)`">`n")
|
||||||
foreach ($it in $items) { Emit-PredefItem $sb $it "`t" $codeType }
|
# Шапка Predefined.xml не объявляет cfg (predef/v8/xr/xs/xsi) — на время сборки этого
|
||||||
|
# файла ссылочный тип уходит на локальную форму, как делает и платформа.
|
||||||
|
$savedCfgPrefix = $script:cfgPrefix; $script:cfgPrefix = $null
|
||||||
|
try { foreach ($it in $items) { Emit-PredefItem $sb $it "`t" $codeType } }
|
||||||
|
finally { $script:cfgPrefix = $savedCfgPrefix }
|
||||||
[void]$sb.Append("</PredefinedData>`n")
|
[void]$sb.Append("</PredefinedData>`n")
|
||||||
return $sb.ToString()
|
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
||||||
@@ -4697,9 +4974,12 @@ function Build-PredefinedAccountXml {
|
|||||||
$sb = New-Object System.Text.StringBuilder
|
$sb = New-Object System.Text.StringBuilder
|
||||||
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
||||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"ChartOfAccountsPredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"ChartOfAccountsPredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
||||||
foreach ($it in $items) { Emit-PredefAccount $sb $it "`t" $objName $acctFlagNames $extDimFlagNames $extDimTypesRef }
|
# См. Build-PredefinedXml: шапка этого файла cfg не объявляет.
|
||||||
|
$savedCfgPrefix = $script:cfgPrefix; $script:cfgPrefix = $null
|
||||||
|
try { foreach ($it in $items) { Emit-PredefAccount $sb $it "`t" $objName $acctFlagNames $extDimFlagNames $extDimTypesRef } }
|
||||||
|
finally { $script:cfgPrefix = $savedCfgPrefix }
|
||||||
[void]$sb.Append("</PredefinedData>`n")
|
[void]$sb.Append("</PredefinedData>`n")
|
||||||
return $sb.ToString()
|
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase). Строка "(Код) Имя [Наим]"
|
# --- Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase). Строка "(Код) Имя [Наим]"
|
||||||
@@ -4726,7 +5006,7 @@ function Build-PredefinedCalcTypeXml {
|
|||||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"CalculationTypePredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"CalculationTypePredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
||||||
foreach ($it in $items) { Emit-PredefCalcType $sb $it "`t" }
|
foreach ($it in $items) { Emit-PredefCalcType $sb $it "`t" }
|
||||||
[void]$sb.Append("</PredefinedData>`n")
|
[void]$sb.Append("</PredefinedData>`n")
|
||||||
return $sb.ToString()
|
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
}
|
}
|
||||||
|
|
||||||
$extDir = Join-Path $objSubDir "Ext"
|
$extDir = Join-Path $objSubDir "Ext"
|
||||||
@@ -4741,7 +5021,20 @@ if ($objType -notin $typesNoSubDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($mainXmlPath, $metadataXml, $enc)
|
|
||||||
|
# Единая точка записи XML. Конфигуратор не пишет перевод строки в конце файла —
|
||||||
|
# последний байт `>`; сборка через AppendLine добавляла лишний.
|
||||||
|
# KeepEol в имени — отличие от одноимённой функции в скелетных навыках
|
||||||
|
# (cf-init и др.): та ЕЩЁ и нормализует EOL к CRLF, а здесь этого делать
|
||||||
|
# НЕЛЬЗЯ — в объектном XML бывают многострочные текстовые узлы (запрос,
|
||||||
|
# синоним, значение заполнения), и сплошная нормализация меняла бы содержимое.
|
||||||
|
# Разделители тут и так CRLF: документ собран через AppendLine.
|
||||||
|
# Модули .bsl сюда НЕ идут: у них свой хвост.
|
||||||
|
function Write-XmlFileKeepEol([string]$path, [string]$text, $encoding) {
|
||||||
|
[System.IO.File]::WriteAllText($path, $text.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFileKeepEol $mainXmlPath $metadataXml $enc
|
||||||
|
|
||||||
# Module files
|
# Module files
|
||||||
$modulesCreated = @()
|
$modulesCreated = @()
|
||||||
@@ -4822,8 +5115,10 @@ if ($objType -eq "CommonForm") {
|
|||||||
$cfFormXmlPath = Join-Path $extDir "Form.xml"
|
$cfFormXmlPath = Join-Path $extDir "Form.xml"
|
||||||
if (-not (Test-Path $cfFormXmlPath)) {
|
if (-not (Test-Path $cfFormXmlPath)) {
|
||||||
$cfFormNs = '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"'
|
$cfFormNs = '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"'
|
||||||
$cfFormXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n<Form $cfFormNs version=`"$($script:formatVersion)`">`n`t<AutoCommandBar name=`"ФормаКоманднаяПанель`" id=`"-1`">`n`t`t<Autofill>true</Autofill>`n`t</AutoCommandBar>`n`t<ChildItems/>`n</Form>`n"
|
# Шапка Form на 2.21 тоже несёт палитру — см. комментарий у $script:xmlnsDecl.
|
||||||
[System.IO.File]::WriteAllText($cfFormXmlPath, $cfFormXml, $enc)
|
if ($script:isFormat221) { $cfFormNs = $cfFormNs -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=' }
|
||||||
|
$cfFormXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<Form $cfFormNs version=`"$($script:formatVersion)`">`r`n`t<AutoCommandBar name=`"ФормаКоманднаяПанель`" id=`"-1`">`r`n`t`t<Autofill>true</Autofill>`r`n`t</AutoCommandBar>`r`n`t<ChildItems/>`r`n</Form>`r`n"
|
||||||
|
Write-XmlFileKeepEol $cfFormXmlPath $cfFormXml $enc
|
||||||
$modulesCreated += $cfFormXmlPath
|
$modulesCreated += $cfFormXmlPath
|
||||||
}
|
}
|
||||||
$cfModuleDir = Join-Path $extDir "Form"
|
$cfModuleDir = Join-Path $extDir "Form"
|
||||||
@@ -4876,7 +5171,7 @@ if ($objType -eq "ExchangePlan") {
|
|||||||
[void]$sbC.Append("`t</Item>`r`n")
|
[void]$sbC.Append("`t</Item>`r`n")
|
||||||
}
|
}
|
||||||
[void]$sbC.Append("</ExchangePlanContent>`r`n")
|
[void]$sbC.Append("</ExchangePlanContent>`r`n")
|
||||||
[System.IO.File]::WriteAllText($contentPath, $sbC.ToString(), $enc)
|
Write-XmlFileKeepEol $contentPath $sbC.ToString() $enc
|
||||||
$modulesCreated += $contentPath
|
$modulesCreated += $contentPath
|
||||||
} elseif (-not (Test-Path $contentPath)) {
|
} elseif (-not (Test-Path $contentPath)) {
|
||||||
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
||||||
@@ -4884,7 +5179,7 @@ if ($objType -eq "ExchangePlan") {
|
|||||||
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
||||||
Ensure-ExtDir
|
Ensure-ExtDir
|
||||||
$contentXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<ExchangePlanContent $xepNs version=`"$($script:formatVersion)`"/>`r`n"
|
$contentXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<ExchangePlanContent $xepNs version=`"$($script:formatVersion)`"/>`r`n"
|
||||||
[System.IO.File]::WriteAllText($contentPath, $contentXml, $enc)
|
Write-XmlFileKeepEol $contentPath $contentXml $enc
|
||||||
$modulesCreated += $contentPath
|
$modulesCreated += $contentPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4893,7 +5188,7 @@ if ($objType -eq "BusinessProcess") {
|
|||||||
if (-not (Test-Path $flowchartPath)) {
|
if (-not (Test-Path $flowchartPath)) {
|
||||||
Ensure-ExtDir
|
Ensure-ExtDir
|
||||||
$flowchartXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<Flowchart xmlns=`"http://v8.1c.ru/8.3/MDClasses`" version=`"$($script:formatVersion)`"/>`r`n"
|
$flowchartXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<Flowchart xmlns=`"http://v8.1c.ru/8.3/MDClasses`" version=`"$($script:formatVersion)`"/>`r`n"
|
||||||
[System.IO.File]::WriteAllText($flowchartPath, $flowchartXml, $enc)
|
Write-XmlFileKeepEol $flowchartPath $flowchartXml $enc
|
||||||
$modulesCreated += $flowchartPath
|
$modulesCreated += $flowchartPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4908,20 +5203,20 @@ if ($objType -eq 'ChartOfAccounts' -and $def.predefined -and @($def.predefined).
|
|||||||
$edtRef = if ($def.extDimensionTypes) { Resolve-TypePrefixSyn "$($def.extDimensionTypes)" } else { '' }
|
$edtRef = if ($def.extDimensionTypes) { Resolve-TypePrefixSyn "$($def.extDimensionTypes)" } else { '' }
|
||||||
$predefXml = Build-PredefinedAccountXml @($def.predefined) $objName $afNames $edfNames $edtRef
|
$predefXml = Build-PredefinedAccountXml @($def.predefined) $objName $afNames $edfNames $edtRef
|
||||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||||
$modulesCreated += $predefPath
|
$modulesCreated += $predefPath
|
||||||
} elseif ($objType -eq 'ChartOfCalculationTypes' -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
} elseif ($objType -eq 'ChartOfCalculationTypes' -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
||||||
Ensure-ExtDir
|
Ensure-ExtDir
|
||||||
$predefXml = Build-PredefinedCalcTypeXml @($def.predefined)
|
$predefXml = Build-PredefinedCalcTypeXml @($def.predefined)
|
||||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||||
$modulesCreated += $predefPath
|
$modulesCreated += $predefPath
|
||||||
} elseif ($predefRootByType.ContainsKey($objType) -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
} elseif ($predefRootByType.ContainsKey($objType) -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
||||||
Ensure-ExtDir
|
Ensure-ExtDir
|
||||||
$catCodeType = if ($def.codeType) { "$($def.codeType)" } else { 'String' }
|
$catCodeType = if ($def.codeType) { "$($def.codeType)" } else { 'String' }
|
||||||
$predefXml = Build-PredefinedXml @($def.predefined) $predefRootByType[$objType] $catCodeType
|
$predefXml = Build-PredefinedXml @($def.predefined) $predefRootByType[$objType] $catCodeType
|
||||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||||
$modulesCreated += $predefPath
|
$modulesCreated += $predefPath
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4939,75 +5234,195 @@ if ($commands -and $commands.Count -gt 0) {
|
|||||||
|
|
||||||
# --- 17. Register in Configuration.xml ---
|
# --- 17. Register in Configuration.xml ---
|
||||||
|
|
||||||
$configXmlPath = Join-Path $OutputDir "Configuration.xml"
|
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||||
$regResult = $null
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# Регистрация объекта в <ChildObjects> родительского XML. Общая реализация: эталон —
|
||||||
|
# meta-compile, копии — role-compile, xdto-compile. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
# Возвращает исход: added | already | no-childobj | no-config.
|
||||||
|
# Канонический порядок видов в <ChildObjects> — эталон в docs/1c-configuration-spec.md,
|
||||||
|
# таблица «Порядок типов в ChildObjects». Нужен, чтобы новая группа вида вставала на своё
|
||||||
|
# место: иначе платформа переставит её при первой же выгрузке и даст диф на ровном месте.
|
||||||
|
# Реестр карт: tests/skills/check-type-maps.mjs.
|
||||||
|
$childObjectTypes = @(
|
||||||
|
"Language","Subsystem","StyleItem","Style",
|
||||||
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
|
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||||
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
|
"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
|
||||||
|
"Constant","CommonForm","Catalog","Document",
|
||||||
|
"DocumentNumerator","Sequence","DocumentJournal","Enum",
|
||||||
|
"Report","DataProcessor","InformationRegister","AccumulationRegister",
|
||||||
|
"ChartOfCharacteristicTypes","ChartOfAccounts","AccountingRegister",
|
||||||
|
"ChartOfCalculationTypes","CalculationRegister",
|
||||||
|
"BusinessProcess","Task","IntegrationService"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Register-InChildObjects([string]$ParentXmlPath, [string]$ParentTag, [string]$ChildTag, [string]$ChildName) {
|
||||||
|
if (-not (Test-Path $ParentXmlPath)) { return "no-config" }
|
||||||
|
|
||||||
|
$doc = New-Object System.Xml.XmlDocument
|
||||||
|
$doc.PreserveWhitespace = $true
|
||||||
|
$doc.Load($ParentXmlPath)
|
||||||
|
|
||||||
|
$nsMgr = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
|
||||||
|
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||||
|
|
||||||
|
$childObjects = $doc.SelectSingleNode("//md:$ParentTag/md:ChildObjects", $nsMgr)
|
||||||
|
if (-not $childObjects) { return "no-childobj" }
|
||||||
|
|
||||||
|
$existing = $childObjects.SelectNodes("md:$ChildTag", $nsMgr)
|
||||||
|
foreach ($e in $existing) {
|
||||||
|
if ($e.InnerText -eq $ChildName) { return "already" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Правка по сырому тексту, зеркально py-порту: сериализация DOM переписала бы файл целиком
|
||||||
|
# (регистр encoding, `<a />` вместо `<a/>`), а текстовая вставка хранит его байт-в-байт —
|
||||||
|
# дельта ровно в одну строку. Правим чужой файл, значит наследуем его стиль (#44/#46/#47).
|
||||||
|
# DOM выше — только на чтение: найти ChildObjects и отсечь дубликат.
|
||||||
|
$configContent = [System.IO.File]::ReadAllText($ParentXmlPath, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
$eol = if ($configContent.Contains("`r`n")) { "`r`n" } else { "`n" }
|
||||||
|
$entry = "<$ChildTag>$(Esc-XmlText $ChildName)</$ChildTag>"
|
||||||
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
|
||||||
|
$block = [regex]::Match($configContent, '(?s)<ChildObjects\s*>.*?</ChildObjects>')
|
||||||
|
if (-not $block.Success) {
|
||||||
|
# Самозакрытый <ChildObjects/> раскрываем первой записью
|
||||||
|
$empty = [regex]::Match($configContent, '<ChildObjects\s*/>')
|
||||||
|
if (-not $empty.Success) { return "no-childobj" }
|
||||||
|
$replacement = "<ChildObjects>$eol`t`t`t$entry$eol`t`t</ChildObjects>"
|
||||||
|
$newContent = $configContent.Substring(0, $empty.Index) + $replacement + $configContent.Substring($empty.Index + $empty.Length)
|
||||||
|
[System.IO.File]::WriteAllText($ParentXmlPath, $newContent, $enc)
|
||||||
|
return "added"
|
||||||
|
}
|
||||||
|
|
||||||
|
# byName: перед первым объектом того же вида, чьё имя больше нового.
|
||||||
|
# Виды с осмысленным порядком в дереве пропускаем — см. Test-OrderSensitiveType.
|
||||||
|
if (-not (Test-OrderSensitiveType $ChildTag) -and (Get-NewObjectPosition ([System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($ParentXmlPath)))) -eq "byName") {
|
||||||
|
$lineRx = [regex]"(?m)^([ \t]*)<$ChildTag>([^<]*)</$ChildTag>"
|
||||||
|
$m = $lineRx.Match($configContent, $block.Index, $block.Length)
|
||||||
|
while ($m.Success) {
|
||||||
|
if ((Compare-MetadataNames $m.Groups[2].Value $ChildName) -gt 0) {
|
||||||
|
$newContent = $configContent.Substring(0, $m.Index) + $m.Groups[1].Value + $entry + $eol + $configContent.Substring($m.Index)
|
||||||
|
[System.IO.File]::WriteAllText($ParentXmlPath, $newContent, $enc)
|
||||||
|
return "added"
|
||||||
|
}
|
||||||
|
$m = $m.NextMatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$closeSame = "</$ChildTag>"
|
||||||
|
$blockEnd = $block.Index + $block.Length
|
||||||
|
$lastSame = $configContent.LastIndexOf($closeSame, $blockEnd - 1, $block.Length, [System.StringComparison]::Ordinal)
|
||||||
|
if ($lastSame -ge 0) {
|
||||||
|
# После последнего объекта того же вида (группы по видам сохраняются)
|
||||||
|
$insertAt = $lastSame + $closeSame.Length
|
||||||
|
$newContent = $configContent.Substring(0, $insertAt) + "$eol`t`t`t$entry" + $configContent.Substring($insertAt)
|
||||||
|
} else {
|
||||||
|
# Группы своего вида ещё нет: ставим её в канонический порядок видов — перед первой
|
||||||
|
# группой вида старше по $childObjectTypes. Дописать в конец блока нельзя: платформа
|
||||||
|
# переставит группу при первой же выгрузке и даст диф на ровном месте.
|
||||||
|
$ownIdx = $childObjectTypes.IndexOf($ChildTag)
|
||||||
|
$anchor = $null
|
||||||
|
if ($ownIdx -ge 0) {
|
||||||
|
$typeRx = [regex]"(?m)^([ \t]*)<(\w+)>[^<]*</\2>"
|
||||||
|
$tm = $typeRx.Match($configContent, $block.Index, $block.Length)
|
||||||
|
while ($tm.Success) {
|
||||||
|
$otherIdx = $childObjectTypes.IndexOf($tm.Groups[2].Value)
|
||||||
|
if ($otherIdx -gt $ownIdx) { $anchor = $tm; break }
|
||||||
|
$tm = $tm.NextMatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($anchor) {
|
||||||
|
$newContent = $configContent.Substring(0, $anchor.Index) + $anchor.Groups[1].Value + $entry + $eol + $configContent.Substring($anchor.Index)
|
||||||
|
} else {
|
||||||
|
# Видов старше в файле нет — новая строка перед </ChildObjects>,
|
||||||
|
# отступ закрывающего тега переиспользуется
|
||||||
|
$closeAt = $configContent.LastIndexOf("</ChildObjects>", $blockEnd - 1, $block.Length, [System.StringComparison]::Ordinal)
|
||||||
|
$newContent = $configContent.Substring(0, $closeAt) + "`t$entry$eol`t`t" + $configContent.Substring($closeAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[System.IO.File]::WriteAllText($ParentXmlPath, $newContent, $enc)
|
||||||
|
return "added"
|
||||||
|
}
|
||||||
|
|
||||||
# XML tag name for Configuration.xml ChildObjects
|
# XML tag name for Configuration.xml ChildObjects
|
||||||
$childTag = $objType
|
$childTag = $objType
|
||||||
|
|
||||||
if (Test-Path $configXmlPath) {
|
$configXmlPath = Join-Path $OutputDir "Configuration.xml"
|
||||||
$configDoc = New-Object System.Xml.XmlDocument
|
$regResult = Register-InChildObjects $configXmlPath "Configuration" $childTag $objName
|
||||||
$configDoc.PreserveWhitespace = $true
|
|
||||||
$configDoc.Load($configXmlPath)
|
|
||||||
|
|
||||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($configDoc.NameTable)
|
|
||||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
|
||||||
|
|
||||||
$childObjects = $configDoc.SelectSingleNode("//md:Configuration/md:ChildObjects", $nsMgr)
|
|
||||||
if ($childObjects) {
|
|
||||||
$existing = $childObjects.SelectNodes("md:$childTag", $nsMgr)
|
|
||||||
$alreadyExists = $false
|
|
||||||
foreach ($e in $existing) {
|
|
||||||
if ($e.InnerText -eq $objName) {
|
|
||||||
$alreadyExists = $true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($alreadyExists) {
|
|
||||||
$regResult = "already"
|
|
||||||
} else {
|
|
||||||
$newElem = $configDoc.CreateElement($childTag, "http://v8.1c.ru/8.3/MDClasses")
|
|
||||||
$newElem.InnerText = $objName
|
|
||||||
|
|
||||||
if ($existing.Count -gt 0) {
|
|
||||||
# Insert after last existing element of same type
|
|
||||||
$lastElem = $existing[$existing.Count - 1]
|
|
||||||
$newWs = $configDoc.CreateWhitespace("`n`t`t`t")
|
|
||||||
$childObjects.InsertAfter($newWs, $lastElem) | Out-Null
|
|
||||||
$childObjects.InsertAfter($newElem, $newWs) | Out-Null
|
|
||||||
} else {
|
|
||||||
# No existing elements of this type — insert before closing whitespace
|
|
||||||
$lastChild = $childObjects.LastChild
|
|
||||||
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
|
||||||
$newWs = $configDoc.CreateWhitespace("`n`t`t`t")
|
|
||||||
$childObjects.InsertBefore($newWs, $lastChild) | Out-Null
|
|
||||||
$childObjects.InsertBefore($newElem, $lastChild) | Out-Null
|
|
||||||
} else {
|
|
||||||
$childObjects.AppendChild($configDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
|
|
||||||
$childObjects.AppendChild($newElem) | Out-Null
|
|
||||||
$childObjects.AppendChild($configDoc.CreateWhitespace("`n`t`t")) | Out-Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Save
|
|
||||||
$cfgSettings = New-Object System.Xml.XmlWriterSettings
|
|
||||||
$cfgSettings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
|
||||||
$cfgSettings.Indent = $false
|
|
||||||
$stream = New-Object System.IO.FileStream($configXmlPath, [System.IO.FileMode]::Create)
|
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $cfgSettings)
|
|
||||||
$configDoc.Save($writer)
|
|
||||||
$writer.Close()
|
|
||||||
$stream.Close()
|
|
||||||
|
|
||||||
$regResult = "added"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$regResult = "no-childobj"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$regResult = "no-config"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- 18. Summary ---
|
# --- 18. Summary ---
|
||||||
|
|
||||||
@@ -5059,6 +5474,9 @@ switch ($regResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Cross-reference hints
|
# Cross-reference hints
|
||||||
|
if ($script:stdAttrTailHint) {
|
||||||
|
Write-Host "[HINT] $($script:stdAttrTailHint)"
|
||||||
|
}
|
||||||
if ($objType -eq "AccountingRegister" -and -not $def.chartOfAccounts) {
|
if ($objType -eq "AccountingRegister" -and -not $def.chartOfAccounts) {
|
||||||
Write-Host "[HINT] AccountingRegister requires ChartOfAccounts reference:"
|
Write-Host "[HINT] AccountingRegister requires ChartOfAccounts reference:"
|
||||||
Write-Host " /meta-edit -Operation modify-property -Value `"ChartOfAccounts=ChartOfAccounts.XXX`""
|
Write-Host " /meta-edit -Operation modify-property -Value `"ChartOfAccounts=ChartOfAccounts.XXX`""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# meta-compile v1.76 — Compile 1C metadata object from JSON
|
# meta-compile v1.102 — Compile 1C metadata object from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -16,6 +16,131 @@ 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 регистр не значим нигде, куда
|
||||||
|
# смотрит пользовательский ввод: свойства объекта из ConvertFrom-Json, ключи Hashtable,
|
||||||
|
# -eq/-contains, имена параметров, ValidateSet. В Python совпадение точное, поэтому порт
|
||||||
|
# молча терял свойства DSL, написанные в другом регистре. Обёртки ниже выравнивают поведение.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||||
@@ -206,9 +331,22 @@ 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_keep_eol(path, content):
|
||||||
|
# Единая точка записи XML. Конфигуратор не пишет перевод строки в конце файла —
|
||||||
|
# последний байт `>`.
|
||||||
|
# keep_eol в имени — отличие от одноимённой функции в скелетных навыках
|
||||||
|
# (cf-init и др.): та ЕЩЁ и нормализует EOL к CRLF, а здесь этого делать
|
||||||
|
# НЕЛЬЗЯ — в объектном XML бывают многострочные текстовые узлы (запрос,
|
||||||
|
# синоним, значение заполнения). Разделители и так CRLF — их даёт join строк.
|
||||||
|
# Модули .bsl сюда НЕ идут: у них свой хвост.
|
||||||
|
write_utf8_bom(path, content.rstrip('\r\n'))
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# XML builder (lines list)
|
# XML builder (lines list)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -289,7 +427,7 @@ def split_camel_case(name):
|
|||||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||||
parser.add_argument('-JsonPath', required=True)
|
parser.add_argument('-JsonPath', required=True)
|
||||||
parser.add_argument('-OutputDir', required=True)
|
parser.add_argument('-OutputDir', required=True)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
json_path = args.JsonPath
|
json_path = args.JsonPath
|
||||||
output_dir = args.OutputDir
|
output_dir = args.OutputDir
|
||||||
@@ -298,10 +436,9 @@ if not os.path.isfile(json_path):
|
|||||||
print(f'File not found: {json_path}', file=sys.stderr)
|
print(f'File not found: {json_path}', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(json_path, 'r', encoding='utf-8-sig') as f:
|
json_text = read_json_file(json_path)
|
||||||
json_text = f.read()
|
|
||||||
|
|
||||||
defn = json.loads(json_text)
|
defn = ci_json(parse_json_input(json_text, json_path))
|
||||||
|
|
||||||
assert_edit_allowed(output_dir, "editable")
|
assert_edit_allowed(output_dir, "editable")
|
||||||
|
|
||||||
@@ -395,6 +532,10 @@ enum_value_aliases = {
|
|||||||
'ИндексироватьСДопУпорядочиванием': 'IndexWithAdditionalOrder',
|
'ИндексироватьСДопУпорядочиванием': 'IndexWithAdditionalOrder',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Словари, по которым ищут ПОЛЬЗОВАТЕЛЬСКИЙ ввод, — регистронезависимы, как хеш-таблицы PS1.
|
||||||
|
object_type_synonyms = CIDict(object_type_synonyms)
|
||||||
|
enum_value_aliases = CIDict(enum_value_aliases)
|
||||||
|
|
||||||
# Valid enum values per property (from meta-validate)
|
# Valid enum values per property (from meta-validate)
|
||||||
valid_enum_values = {
|
valid_enum_values = {
|
||||||
'RegisterType': ['Balance', 'Turnovers'],
|
'RegisterType': ['Balance', 'Turnovers'],
|
||||||
@@ -553,6 +694,8 @@ valid_types = [
|
|||||||
'SessionParameter', 'CommonCommand', 'CommandGroup', 'CommonAttribute', 'FunctionalOptionsParameter', 'WSReference',
|
'SessionParameter', 'CommonCommand', 'CommandGroup', 'CommonAttribute', 'FunctionalOptionsParameter', 'WSReference',
|
||||||
'CommonPicture', 'CommonTemplate',
|
'CommonPicture', 'CommonTemplate',
|
||||||
]
|
]
|
||||||
|
# Регистр имени вида — как в PS (-contains регистронезависим): приводим к канону списка
|
||||||
|
obj_type = next((t for t in valid_types if t.lower() == obj_type.lower()), obj_type)
|
||||||
if obj_type not in valid_types:
|
if obj_type not in valid_types:
|
||||||
print(f"Unsupported type: {obj_type}. Valid: {', '.join(valid_types)}", file=sys.stderr)
|
print(f"Unsupported type: {obj_type}. Valid: {', '.join(valid_types)}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -627,47 +770,103 @@ type_namespace_map = {
|
|||||||
"SpreadsheetDocument": {"ns": "http://v8.1c.ru/8.2/data/spreadsheet", "prefix": "mxl"},
|
"SpreadsheetDocument": {"ns": "http://v8.1c.ru/8.2/data/spreadsheet", "prefix": "mxl"},
|
||||||
}
|
}
|
||||||
# Типы current-config пространства (cfg:, объявлено в корне): голые и объектные. Ссылочные — отдельно (d5p1).
|
# Типы current-config пространства (cfg:, объявлено в корне): голые и объектные. Ссылочные — отдельно (d5p1).
|
||||||
|
# Префикс current-config для ссылочных типов. 'cfg' — для файлов, чья шапка его объявляет
|
||||||
|
# (объектный XML, Ext/Form.xml общей формы). None на время сборки Ext/Predefined.xml, чья
|
||||||
|
# шапка его НЕ объявляет: там и платформа уходит на локальное объявление.
|
||||||
|
cfg_prefix = 'cfg'
|
||||||
cfg_bare_types = {"ConstantsSet", "ReportBuilder", "FilterCriterion"}
|
cfg_bare_types = {"ConstantsSet", "ReportBuilder", "FilterCriterion"}
|
||||||
cfg_object_kinds = {"Catalog", "Document", "Enum", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
cfg_object_kinds = {"Catalog", "Document", "Enum", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||||
"ChartOfCalculationTypes", "ExchangePlan", "BusinessProcess", "Task", "InformationRegister",
|
"ChartOfCalculationTypes", "ExchangePlan", "BusinessProcess", "Task", "InformationRegister",
|
||||||
"AccumulationRegister", "AccountingRegister", "CalculationRegister", "DataProcessor", "Report",
|
"AccumulationRegister", "AccountingRegister", "CalculationRegister", "DataProcessor", "Report",
|
||||||
"DocumentJournal", "Constant", "ConstantValue", "Sequence", "Recalculation"}
|
"DocumentJournal", "Constant", "ConstantValue", "Sequence", "Recalculation"}
|
||||||
|
|
||||||
|
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||||
|
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||||
|
type_synonyms = CIDict(type_synonyms)
|
||||||
|
TYPE_SYNONYMS = type_synonyms
|
||||||
|
|
||||||
|
|
||||||
def resolve_type_str(type_str):
|
def resolve_type_str(type_str):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
return type_str
|
return type_str
|
||||||
# Parameterized types: Number(15,2), Строка(100), etc.
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if type_str.startswith('cfg:'):
|
||||||
|
type_str = type_str[4:]
|
||||||
|
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||||
|
type_str = type_str[type_str.index(':') + 1:]
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||||
if m:
|
if m:
|
||||||
base_name = m.group(1).strip()
|
base_name = m.group(1).strip()
|
||||||
params = m.group(2)
|
params = m.group(2)
|
||||||
resolved = type_synonyms.get(base_name.lower())
|
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||||
if resolved:
|
if resolved:
|
||||||
return f'{resolved}({params})'
|
return f'{resolved}({params})'
|
||||||
return type_str
|
return type_str
|
||||||
# Reference types: СправочникСсылка.Организации -> CatalogRef.Организации
|
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||||
if '.' in type_str:
|
if '.' in type_str:
|
||||||
dot_idx = type_str.index('.')
|
dot_idx = type_str.index('.')
|
||||||
prefix = type_str[:dot_idx]
|
prefix = type_str[:dot_idx]
|
||||||
suffix = type_str[dot_idx:] # includes the dot
|
suffix = type_str[dot_idx:] # includes the dot
|
||||||
resolved = type_synonyms.get(prefix.lower())
|
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||||
if resolved:
|
if resolved:
|
||||||
return f'{resolved}{suffix}'
|
return f'{resolved}{suffix}'
|
||||||
return type_str
|
return type_str
|
||||||
# Simple name lookup
|
# Простое имя
|
||||||
resolved = type_synonyms.get(type_str.lower())
|
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||||
if resolved:
|
if resolved:
|
||||||
return resolved
|
return resolved
|
||||||
return type_str
|
return type_str
|
||||||
|
|
||||||
def emit_type_content(indent, type_str):
|
def emit_type_content(indent, type_str):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
return
|
return
|
||||||
# Composite type: "Type1 + Type2 + Type3"
|
# Composite type: "Type1 + Type2 + Type3"
|
||||||
|
# Платформа пишет сначала ВСЕ <v8:Type>/<v8:TypeSet>, и только потом блоки
|
||||||
|
# квалификаторов — а рекурсия ниже печатала бы каждую часть целиком (тип вместе со
|
||||||
|
# своими квалификаторами). На одиночном типе оба порядка совпадают, поэтому
|
||||||
|
# расхождение вылезало только на составном.
|
||||||
|
# Порядок самих блоков квалификаторов тоже канонический и НЕ зеркалит порядок типов:
|
||||||
|
# Number, String, Date (корпус acc+erp, контрпримеров нет — при типах
|
||||||
|
# boolean,string,dateTime,decimal квалификаторы идут Number,String,Date).
|
||||||
|
# Порядок типов при этом сохраняем как в DSL: он и есть порядок источника.
|
||||||
if ' + ' in type_str:
|
if ' + ' in type_str:
|
||||||
parts = [p.strip() for p in type_str.split('+')]
|
parts = [p.strip() for p in type_str.split('+')]
|
||||||
|
type_lines = []
|
||||||
|
qual_blocks = {}
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
# X добавляет в список lines, поэтому «перехват» — это срез и откат хвоста.
|
||||||
|
# В PS-порту X пишет в StringBuilder и тот же алгоритм выражен через
|
||||||
|
# Length/Remove — различие рантаймов, не логики.
|
||||||
|
before = len(lines)
|
||||||
emit_type_content(indent, part)
|
emit_type_content(indent, part)
|
||||||
|
chunk = lines[before:]
|
||||||
|
del lines[before:]
|
||||||
|
cur_qual = None
|
||||||
|
for line in chunk:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
m = re.search(r'<v8:(String|Number|Date)Qualifiers>', line)
|
||||||
|
if m:
|
||||||
|
cur_qual = m.group(1)
|
||||||
|
qual_blocks[cur_qual] = []
|
||||||
|
if cur_qual:
|
||||||
|
qual_blocks[cur_qual].append(line)
|
||||||
|
if re.search(r'</v8:(String|Number|Date)Qualifiers>', line):
|
||||||
|
cur_qual = None
|
||||||
|
else:
|
||||||
|
type_lines.append(line)
|
||||||
|
for line in type_lines:
|
||||||
|
X(line)
|
||||||
|
for q in ('Number', 'String', 'Date'):
|
||||||
|
if q in qual_blocks:
|
||||||
|
for line in qual_blocks[q]:
|
||||||
|
X(line)
|
||||||
return
|
return
|
||||||
type_str = resolve_type_str(type_str)
|
type_str = resolve_type_str(type_str)
|
||||||
# Boolean
|
# Boolean
|
||||||
@@ -760,9 +959,21 @@ def emit_type_content(indent, type_str):
|
|||||||
X(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
|
X(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Reference types — use local xmlns declaration for 1C compatibility
|
# Ссылочные типы — корневой cfg:, как пишет платформа. Раньше здесь объявлялся
|
||||||
|
# ЛОКАЛЬНЫЙ xmlns:d5p1 на тот же URI, что уже объявлен в шапке: формально
|
||||||
|
# эквивалентно (значим URI, не префикс) и платформой принималось, но первый же
|
||||||
|
# цикл «загрузить в базу → выгрузить» переписывал каждый ссылочный тип в cfg: —
|
||||||
|
# то есть давал diff-шум на ровном месте. Форма пришла из СКД, где cfg:
|
||||||
|
# действительно не работает; в метаданных такого ограничения нет.
|
||||||
|
# NB: локальная xmlns остаётся законной для ЧУЖИХ пространств — см. type_namespace_map.
|
||||||
|
# cfg_prefix = None означает «пишем файл, корень которого cfg НЕ объявляет»
|
||||||
|
# (Ext/Predefined.xml — его шапка это predef/v8/xr/xs/xsi). Там платформа сама уходит
|
||||||
|
# на локальную форму: в корпусе `<v8:Type xmlns:d6p1="…current-config">d6p1:CatalogRef.Валюты`.
|
||||||
m = re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$', type_str)
|
m = re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$', type_str)
|
||||||
if m:
|
if m:
|
||||||
|
if cfg_prefix:
|
||||||
|
X(f'{indent}<v8:Type>{cfg_prefix}:{type_str}</v8:Type>')
|
||||||
|
else:
|
||||||
X(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
X(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
||||||
return
|
return
|
||||||
# Fallback
|
# Fallback
|
||||||
@@ -1270,15 +1481,24 @@ standard_attributes_by_type = {
|
|||||||
'Document': ['Posted', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
'Document': ['Posted', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||||
'Enum': ['Order', 'Ref'],
|
'Enum': ['Order', 'Ref'],
|
||||||
'InformationRegister': ['Active', 'LineNumber', 'Recorder', 'Period'],
|
'InformationRegister': ['Active', 'LineNumber', 'Recorder', 'Period'],
|
||||||
'AccumulationRegister': ['Active', 'LineNumber', 'Recorder', 'Period'],
|
'AccumulationRegister': ['RecordType', 'Active', 'LineNumber', 'Recorder', 'Period'],
|
||||||
'AccountingRegister': ['Active', 'Period', 'Recorder', 'LineNumber', 'Account'],
|
'AccountingRegister': ['PeriodAdjustment', 'Account', 'RecordType', 'Active', 'LineNumber', 'Recorder', 'Period'],
|
||||||
'CalculationRegister': ['Active', 'Recorder', 'LineNumber', 'RegistrationPeriod', 'CalculationType', 'ReversingEntry'],
|
'CalculationRegister': ['RegistrationPeriod', 'ReversingEntry', 'Active', 'EndOfBasePeriod', 'BegOfBasePeriod', 'EndOfActionPeriod', 'BegOfActionPeriod', 'ActionPeriod', 'CalculationType', 'LineNumber', 'Recorder'],
|
||||||
'ChartOfAccounts': ['PredefinedDataName', 'Order', 'OffBalance', 'Type', 'Description', 'Code', 'Parent', 'Predefined', 'DeletionMark', 'Ref'],
|
'ChartOfAccounts': ['PredefinedDataName', 'Order', 'OffBalance', 'Type', 'Description', 'Code', 'Parent', 'Predefined', 'DeletionMark', 'Ref'],
|
||||||
'ChartOfCharacteristicTypes': ['PredefinedDataName', 'Predefined', 'Ref', 'DeletionMark', 'Description', 'Code', 'Parent', 'ValueType'],
|
'ChartOfCharacteristicTypes': ['PredefinedDataName', 'ValueType', 'Description', 'Code', 'IsFolder', 'Parent', 'Predefined', 'DeletionMark', 'Ref'],
|
||||||
'ChartOfCalculationTypes': ['PredefinedDataName', 'Predefined', 'Ref', 'DeletionMark', 'ActionPeriodIsBasic', 'Description', 'Code'],
|
'ChartOfCalculationTypes': ['PredefinedDataName', 'Predefined', 'Ref', 'DeletionMark', 'ActionPeriodIsBasic', 'Description', 'Code'],
|
||||||
'BusinessProcess': ['Ref', 'DeletionMark', 'Date', 'Number', 'Started', 'Completed', 'HeadTask'],
|
# Порядок в каждом списке — канон выгрузки, снят с корпуса acc+erp (внутри типа разброса нет).
|
||||||
'Task': ['Ref', 'DeletionMark', 'Date', 'Number', 'Executed', 'Description', 'RoutePoint', 'BusinessProcess'],
|
# Условные члены перечислены в std_attr_conditions — позицию они берут отсюда, а
|
||||||
'ExchangePlan': ['Ref', 'DeletionMark', 'Code', 'Description', 'ThisNode', 'SentNo', 'ReceivedNo'],
|
# присутствие определяется свойствами объекта.
|
||||||
|
# У ПВХ IsFolder входит в фикс-список: он есть у всех 23 объектов корпуса с этим блоком.
|
||||||
|
# У регистра расчёта список безусловен: реквизиты периода действия и базового периода
|
||||||
|
# платформа пишет при любых ActionPeriod/BasePeriod/Periodicity (синтетика, все 4 комбинации).
|
||||||
|
'BusinessProcess': ['Started', 'HeadTask', 'Completed', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||||
|
'Task': ['Executed', 'Description', 'RoutePoint', 'BusinessProcess', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||||
|
# Порядок снят с выгрузки: у плана обмена блок начинается с ThisNode, а не с Ref
|
||||||
|
# (acc+erp, 8 объектов, разброса нет). Прочие типы в этой таблице совпадают с
|
||||||
|
# платформой — расхождений порядка по ним корпусный раундтрип не показал.
|
||||||
|
'ExchangePlan': ['ThisNode', 'ReceivedNo', 'SentNo', 'Ref', 'DeletionMark', 'Description', 'Code'],
|
||||||
'DocumentJournal': ['Type', 'Ref', 'Date', 'Posted', 'DeletionMark', 'Number'],
|
'DocumentJournal': ['Type', 'Ref', 'Date', 'Posted', 'DeletionMark', 'Number'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1398,6 +1618,64 @@ def emit_standard_attribute(indent, attr_name, ov=None):
|
|||||||
# std_attr_conditional_types: типы, где блок только при кастомизации (DSL-ключ standardAttributes).
|
# std_attr_conditional_types: типы, где блок только при кастомизации (DSL-ключ standardAttributes).
|
||||||
# Прочие типы → блок всегда (текущее поведение). Миграция типа = +строчка в оба справочника + снэпшоты.
|
# Прочие типы → блок всегда (текущее поведение). Миграция типа = +строчка в оба справочника + снэпшоты.
|
||||||
std_attr_conditional_types = {'Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document'}
|
std_attr_conditional_types = {'Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document'}
|
||||||
|
|
||||||
|
# Условные члены списка типа: позиция берётся из standard_attributes_by_type, а присутствие —
|
||||||
|
# из свойств самого объекта, как у платформы. Предикат принимает определение параметром
|
||||||
|
# (зеркало .ps1, где scriptblock не видит $def вызывающей функции).
|
||||||
|
def _period_adjustment_used(d):
|
||||||
|
v = d.get('periodAdjustmentLength')
|
||||||
|
return v is not None and int(str(v)) > 0
|
||||||
|
|
||||||
|
std_attr_conditions = {
|
||||||
|
'AccountingRegister': {
|
||||||
|
'PeriodAdjustment': _period_adjustment_used,
|
||||||
|
'RecordType': lambda d: d.get('correspondence') is not True,
|
||||||
|
},
|
||||||
|
'AccumulationRegister': {
|
||||||
|
'RecordType': lambda d: normalize_enum_value('RegisterType', str(d.get('registerType') or 'Balance')) == 'Balance',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Хвостовая группа: реквизиты, которых нет в списке типа и которые идут ПОСЛЕ него.
|
||||||
|
# У бухрегистра это пары субконто. Именами их не перечислить: их количество задаётся
|
||||||
|
# свойством MaxExtDimensionCount плана счетов, а не константой (в корпусе везде 3, но
|
||||||
|
# это однородность выборки, а не правило). Поэтому — шаблон, а не список.
|
||||||
|
std_attr_tail_pattern = {
|
||||||
|
'AccountingRegister': r'^ExtDimension(Type)?\d+$',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Состав хвоста задаёт не DSL, а объект, на который регистр ссылается: пар субконто столько,
|
||||||
|
# сколько у плана счетов MaxExtDimensionCount. Читаем его из выгрузки — как версию формата из
|
||||||
|
# Configuration.xml, — чтобы регистр, описанный неполным DSL, совпал с тем, что материализует
|
||||||
|
# платформа. План не найден → хвост не генерируем и говорим об этом в выводе.
|
||||||
|
std_attr_tail_hint = None
|
||||||
|
|
||||||
|
def _acc_register_ext_dimension_tail(d, object_name, out_dir):
|
||||||
|
global std_attr_tail_hint
|
||||||
|
ref = str(d.get('chartOfAccounts') or '')
|
||||||
|
if not ref:
|
||||||
|
return []
|
||||||
|
chart_name = re.sub(r'^.*\.', '', ref) # ссылка вида ChartOfAccounts.X (имя объекта точек не содержит)
|
||||||
|
path = os.path.join(out_dir, 'ChartsOfAccounts', chart_name + '.xml')
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
std_attr_tail_hint = ("ChartOfAccounts '%s' not found in dump — ExtDimension pairs not generated "
|
||||||
|
"(platform will add them on load)" % chart_name)
|
||||||
|
return []
|
||||||
|
with open(path, encoding='utf-8-sig') as f:
|
||||||
|
m = re.search(r'<MaxExtDimensionCount>(\d+)</MaxExtDimensionCount>', f.read())
|
||||||
|
n = int(m.group(1)) if m else 0
|
||||||
|
out = []
|
||||||
|
for i in range(1, n + 1):
|
||||||
|
# ExtDimensionN связан с Account через LinkByType (LinkItem = номер), ExtDimensionTypeN — нет.
|
||||||
|
out.append(('ExtDimension%d' % i,
|
||||||
|
{'LinkByType': {'dataPath': 'AccountingRegister.%s.StandardAttribute.Account' % object_name,
|
||||||
|
'linkItem': i}}))
|
||||||
|
out.append(('ExtDimensionType%d' % i, {}))
|
||||||
|
return out
|
||||||
|
|
||||||
|
std_attr_tail_derived = {
|
||||||
|
'AccountingRegister': _acc_register_ext_dimension_tail,
|
||||||
|
}
|
||||||
def emit_standard_attributes(indent, object_type):
|
def emit_standard_attributes(indent, object_type):
|
||||||
attrs = standard_attributes_by_type.get(object_type)
|
attrs = standard_attributes_by_type.get(object_type)
|
||||||
if not attrs:
|
if not attrs:
|
||||||
@@ -1409,12 +1687,45 @@ def emit_standard_attributes(indent, object_type):
|
|||||||
if isinstance(sa, str) and sa == '':
|
if isinstance(sa, str) and sa == '':
|
||||||
return # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок)
|
return # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок)
|
||||||
profile = std_attr_profile.get(object_type, {})
|
profile = std_attr_profile.get(object_type, {})
|
||||||
# Доп. (опциональные) стандартные реквизиты вне фикс-списка — напр. ExchangeDate у части ПланОбмена
|
# Список типа задаёт ПОРЯДОК всех известных стандартных реквизитов, включая условные:
|
||||||
# (легаси, присутствие не выводится). Эмитим по факту ключа в DSL, ПЕРЕД фикс-списком (их позиция).
|
# их позиция бывает и до, и после обязательных (у бухрегистра PeriodAdjustment идёт
|
||||||
extra = [k for k in sa if k not in attrs] if isinstance(sa, dict) else []
|
# перед Account, RecordType — после, а ExtDimension1..3/ExtDimensionType1..3 — после Period),
|
||||||
|
# поэтому «условные скопом вперёд» не выражает канон.
|
||||||
|
cond = std_attr_conditions.get(object_type)
|
||||||
|
# Ключи, которых нет в списке типа ВООБЩЕ. По умолчанию их позиция — ПЕРЕД списком
|
||||||
|
# (легаси вроде ExchangeDate у части планов обмена). Подходящие под хвостовой шаблон
|
||||||
|
# типа идут ПОСЛЕ, в порядке номера, а внутри номера — сначала ExtDimensionN, затем
|
||||||
|
# ExtDimensionTypeN (порядок платформы).
|
||||||
|
tail_re = std_attr_tail_pattern.get(object_type)
|
||||||
|
extra, tail = [], []
|
||||||
|
if isinstance(sa, dict):
|
||||||
|
for k in sa:
|
||||||
|
if k in attrs:
|
||||||
|
continue
|
||||||
|
if tail_re and re.match(tail_re, k):
|
||||||
|
tail.append(k)
|
||||||
|
else:
|
||||||
|
extra.append(k)
|
||||||
|
# Хвост, выведенный из связанного объекта: дополняет DSL, а не заменяет его — лишнее из DSL
|
||||||
|
# остаётся (прощаем), недостающее добавляется вместе со своими значениями по умолчанию.
|
||||||
|
derived_ov = {}
|
||||||
|
gen = std_attr_tail_derived.get(object_type)
|
||||||
|
if gen:
|
||||||
|
for name, dov in gen(defn, obj_name, output_dir):
|
||||||
|
derived_ov[name] = dov
|
||||||
|
if name not in tail:
|
||||||
|
tail.append(name)
|
||||||
|
tail.sort(key=lambda k: (int(re.search(r'\d+', k).group()), 1 if re.search(r'Type\d+$', k) else 0))
|
||||||
X(f'{indent}<StandardAttributes>')
|
X(f'{indent}<StandardAttributes>')
|
||||||
for a in extra + list(attrs):
|
for a in extra + list(attrs) + tail:
|
||||||
|
# Условный реквизит: эмитим, если так велят свойства объекта ЛИБО если ключ есть в DSL.
|
||||||
|
# Дизъюнкция страхует роундтрип — декомпилятор перечисляет все имена блока.
|
||||||
|
if cond and a in cond:
|
||||||
|
present = (isinstance(sa, dict) and a in sa) or cond[a](defn)
|
||||||
|
if not present:
|
||||||
|
continue
|
||||||
ov = dict(profile.get(a, {}))
|
ov = dict(profile.get(a, {}))
|
||||||
|
ov.update(derived_ov.get(a, {}))
|
||||||
if isinstance(sa, dict):
|
if isinstance(sa, dict):
|
||||||
d = sa.get(a)
|
d = sa.get(a)
|
||||||
if d:
|
if d:
|
||||||
@@ -2016,8 +2327,13 @@ def emit_attribute(indent, parsed, context, elem_tag='Attribute'):
|
|||||||
X(f'{indent}\t\t<ExtDimensionAccountingFlag>{esc_xml_text(str(parsed["extDimensionAccountingFlag"]))}</ExtDimensionAccountingFlag>')
|
X(f'{indent}\t\t<ExtDimensionAccountingFlag>{esc_xml_text(str(parsed["extDimensionAccountingFlag"]))}</ExtDimensionAccountingFlag>')
|
||||||
else:
|
else:
|
||||||
X(f'{indent}\t\t<ExtDimensionAccountingFlag/>')
|
X(f'{indent}\t\t<ExtDimensionAccountingFlag/>')
|
||||||
|
# Use — у реквизитов справочника и ПВХ. Позиция РАЗНАЯ: справочник пишет Use ПЕРЕД
|
||||||
|
# Indexing, ПВХ — ПОСЛЕ него (корпус acc+erp: Catalog `Use,Indexing,FullTextSearch`,
|
||||||
|
# ПВХ `Indexing,Use,FullTextSearch,DataHistory`). Отсюда отдельный контекст 'cct':
|
||||||
|
# структурно реквизит ПВХ совпадает со справочником, расходится только этим порядком.
|
||||||
|
use_value = parsed.get("use") or "ForItem"
|
||||||
if context == 'catalog':
|
if context == 'catalog':
|
||||||
X(f'{indent}\t\t<Use>{parsed.get("use") or "ForItem"}</Use>')
|
X(f'{indent}\t\t<Use>{use_value}</Use>')
|
||||||
if context not in ('processor', 'processor-tabular'):
|
if context not in ('processor', 'processor-tabular'):
|
||||||
# Признаки учёта ПС (account-flag) не имеют <Indexing>/<FullTextSearch>, но имеют <DataHistory>.
|
# Признаки учёта ПС (account-flag) не имеют <Indexing>/<FullTextSearch>, но имеют <DataHistory>.
|
||||||
if context != 'account-flag':
|
if context != 'account-flag':
|
||||||
@@ -2031,6 +2347,8 @@ def emit_attribute(indent, parsed, context, elem_tag='Attribute'):
|
|||||||
if parsed.get('indexing'):
|
if parsed.get('indexing'):
|
||||||
indexing = parsed['indexing']
|
indexing = parsed['indexing']
|
||||||
X(f'{indent}\t\t<Indexing>{indexing}</Indexing>')
|
X(f'{indent}\t\t<Indexing>{indexing}</Indexing>')
|
||||||
|
if context == 'cct':
|
||||||
|
X(f'{indent}\t\t<Use>{use_value}</Use>')
|
||||||
# Реквизит адресации задачи: AddressingDimension (между Indexing и FullTextSearch).
|
# Реквизит адресации задачи: AddressingDimension (между Indexing и FullTextSearch).
|
||||||
if context == 'task-addressing' and elem_tag == 'AddressingAttribute':
|
if context == 'task-addressing' and elem_tag == 'AddressingAttribute':
|
||||||
if parsed.get('addressingDimension'):
|
if parsed.get('addressingDimension'):
|
||||||
@@ -2187,6 +2505,10 @@ def emit_enum_value(indent, parsed):
|
|||||||
X(f'{indent}\t\t<Comment>{esc_xml_text(parsed["comment"])}</Comment>')
|
X(f'{indent}\t\t<Comment>{esc_xml_text(parsed["comment"])}</Comment>')
|
||||||
else:
|
else:
|
||||||
X(f'{indent}\t\t<Comment/>')
|
X(f'{indent}\t\t<Comment/>')
|
||||||
|
# Цвет значения перечисления — свойство формата 2.21 (8.5), последним в Properties.
|
||||||
|
if is_format_221:
|
||||||
|
color = str(parsed['color']) if parsed.get('color') else 'auto'
|
||||||
|
X(f'{indent}\t\t<Color>{esc_xml_text(color)}</Color>')
|
||||||
X(f'{indent}\t</Properties>')
|
X(f'{indent}\t</Properties>')
|
||||||
X(f'{indent}</EnumValue>')
|
X(f'{indent}</EnumValue>')
|
||||||
|
|
||||||
@@ -2811,6 +3133,12 @@ def emit_common_form_properties(indent):
|
|||||||
X(f'{i}</UsePurposes>')
|
X(f'{i}</UsePurposes>')
|
||||||
else:
|
else:
|
||||||
X(f'{i}<UsePurposes/>')
|
X(f'{i}<UsePurposes/>')
|
||||||
|
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||||
|
# между UsePurposes и UseStandardCommands.
|
||||||
|
if is_format_221:
|
||||||
|
X(f'{i}<UseInInterfaceCompatibilityMode>'
|
||||||
|
f'{get_enum_prop("UseInInterfaceCompatibilityMode", "useInInterfaceCompatibilityMode", "Any")}'
|
||||||
|
f'</UseInInterfaceCompatibilityMode>')
|
||||||
use_std_cmds = 'true' if get_bool_prop('useStandardCommands', False) else 'false'
|
use_std_cmds = 'true' if get_bool_prop('useStandardCommands', False) else 'false'
|
||||||
X(f'{i}<UseStandardCommands>{use_std_cmds}</UseStandardCommands>')
|
X(f'{i}<UseStandardCommands>{use_std_cmds}</UseStandardCommands>')
|
||||||
emit_mltext(i, 'ExtendedPresentation', defn.get('extendedPresentation'))
|
emit_mltext(i, 'ExtendedPresentation', defn.get('extendedPresentation'))
|
||||||
@@ -3069,7 +3397,12 @@ def emit_scheduled_job_properties(indent):
|
|||||||
else:
|
else:
|
||||||
X(f'{i}<Description/>')
|
X(f'{i}<Description/>')
|
||||||
key = str(defn['key']) if defn.get('key') else ''
|
key = str(defn['key']) if defn.get('key') else ''
|
||||||
|
# Пустое значение → самозакрывающийся, как у <Description> выше: Конфигуратор
|
||||||
|
# не пишет пустых пар.
|
||||||
|
if key:
|
||||||
X(f'{i}<Key>{esc_xml_text(key)}</Key>')
|
X(f'{i}<Key>{esc_xml_text(key)}</Key>')
|
||||||
|
else:
|
||||||
|
X(f'{i}<Key/>')
|
||||||
use = 'true' if defn.get('use') is True else 'false'
|
use = 'true' if defn.get('use') is True else 'false'
|
||||||
X(f'{i}<Use>{use}</Use>')
|
X(f'{i}<Use>{use}</Use>')
|
||||||
predefined = 'true' if defn.get('predefined') is True else 'false'
|
predefined = 'true' if defn.get('predefined') is True else 'false'
|
||||||
@@ -3123,6 +3456,9 @@ def emit_report_properties(indent):
|
|||||||
emit_verbatim_ref(i, 'DefaultSettingsForm', defn.get('defaultSettingsForm'))
|
emit_verbatim_ref(i, 'DefaultSettingsForm', defn.get('defaultSettingsForm'))
|
||||||
emit_verbatim_ref(i, 'AuxiliarySettingsForm', defn.get('auxiliarySettingsForm'))
|
emit_verbatim_ref(i, 'AuxiliarySettingsForm', defn.get('auxiliarySettingsForm'))
|
||||||
emit_verbatim_ref(i, 'DefaultVariantForm', defn.get('defaultVariantForm'))
|
emit_verbatim_ref(i, 'DefaultVariantForm', defn.get('defaultVariantForm'))
|
||||||
|
# Вспомогательная форма варианта отчёта — свойство формата 2.21 (8.5).
|
||||||
|
if is_format_221:
|
||||||
|
emit_verbatim_ref(i, 'AuxiliaryVariantForm', defn.get('auxiliaryVariantForm'))
|
||||||
emit_verbatim_ref(i, 'VariantsStorage', defn.get('variantsStorage'))
|
emit_verbatim_ref(i, 'VariantsStorage', defn.get('variantsStorage'))
|
||||||
emit_verbatim_ref(i, 'SettingsStorage', defn.get('settingsStorage'))
|
emit_verbatim_ref(i, 'SettingsStorage', defn.get('settingsStorage'))
|
||||||
incl_help = 'true' if get_bool_prop('includeHelpInContents', False) else 'false'
|
incl_help = 'true' if get_bool_prop('includeHelpInContents', False) else 'false'
|
||||||
@@ -3760,7 +4096,8 @@ def emit_web_service_properties(indent):
|
|||||||
emit_mltext(i, 'Synonym', synonym)
|
emit_mltext(i, 'Synonym', synonym)
|
||||||
X(f'{i}<Comment>{esc_xml_text(str(defn["comment"]))}</Comment>' if defn.get('comment') else f'{i}<Comment/>')
|
X(f'{i}<Comment>{esc_xml_text(str(defn["comment"]))}</Comment>' if defn.get('comment') else f'{i}<Comment/>')
|
||||||
namespace = str(defn['namespace']) if defn.get('namespace') else ''
|
namespace = str(defn['namespace']) if defn.get('namespace') else ''
|
||||||
X(f'{i}<Namespace>{esc_xml_text(namespace)}</Namespace>')
|
# Пустое значение → самозакрывающийся, как у <Comment> выше.
|
||||||
|
X(f'{i}<Namespace>{esc_xml_text(namespace)}</Namespace>' if namespace else f'{i}<Namespace/>')
|
||||||
# XDTOPackages — СПИСОК элементов: ссылка на пакет конфигурации (xr:MDObjectRef) либо URI
|
# XDTOPackages — СПИСОК элементов: ссылка на пакет конфигурации (xr:MDObjectRef) либо URI
|
||||||
# внешнего пространства имён (xs:string). Presentation пуст, CheckState 0 (корпус: 19/19).
|
# внешнего пространства имён (xs:string). Presentation пуст, CheckState 0 (корпус: 19/19).
|
||||||
pkgs = defn.get('xdtoPackages') or []
|
pkgs = defn.get('xdtoPackages') or []
|
||||||
@@ -3982,6 +4319,16 @@ xmlns_decl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while 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")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -4034,6 +4381,15 @@ compat_mode = detect_compatibility_mode(output_dir)
|
|||||||
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
||||||
is_format_218 = format_rank(format_version) >= 218
|
is_format_218 = format_rank(format_version) >= 218
|
||||||
is_format_220 = format_rank(format_version) >= 220
|
is_format_220 = format_rank(format_version) >= 220
|
||||||
|
is_format_221 = format_rank(format_version) >= 221
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту.
|
||||||
|
# Только для шапок MetaDataObject и Form — в файлах с корнем extrnprops
|
||||||
|
# (Ext/ClientApplicationInterface.xml и т.п.) платформа его не пишет.
|
||||||
|
if is_format_221:
|
||||||
|
xmlns_decl = xmlns_decl.replace(
|
||||||
|
' xmlns:style=',
|
||||||
|
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||||
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
||||||
line_number_length_default = 9 if compat_mode_rank(compat_mode) >= 80327 else 5
|
line_number_length_default = 9 if compat_mode_rank(compat_mode) >= 80327 else 5
|
||||||
|
|
||||||
@@ -4174,7 +4530,7 @@ if obj_type in types_with_attr_ts:
|
|||||||
elif obj_type in ('DataProcessor', 'Report'):
|
elif obj_type in ('DataProcessor', 'Report'):
|
||||||
context = 'processor'
|
context = 'processor'
|
||||||
elif obj_type == 'ChartOfCharacteristicTypes':
|
elif obj_type == 'ChartOfCharacteristicTypes':
|
||||||
context = 'catalog' # реквизиты ПВХ структурно как у справочника (Use/FillFromFillingValue/DataHistory)
|
context = 'cct' # как catalog (Use/FillFromFillingValue/DataHistory), но Use ПОСЛЕ Indexing
|
||||||
elif obj_type in ('ChartOfAccounts', 'ChartOfCalculationTypes'):
|
elif obj_type in ('ChartOfAccounts', 'ChartOfCalculationTypes'):
|
||||||
context = 'account' # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
context = 'account' # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
||||||
else:
|
else:
|
||||||
@@ -4245,16 +4601,25 @@ if obj_type in ('InformationRegister', 'AccumulationRegister', 'AccountingRegist
|
|||||||
# Все семейства регистров: ресурсы/измерения — через богатый emit_attribute (общий слой object-свойств).
|
# Все семейства регистров: ресурсы/измерения — через богатый emit_attribute (общий слой object-свойств).
|
||||||
dim_res_ctx = {'InformationRegister': 'register-info', 'AccumulationRegister': 'register-accum',
|
dim_res_ctx = {'InformationRegister': 'register-info', 'AccumulationRegister': 'register-accum',
|
||||||
'CalculationRegister': 'register-calc', 'AccountingRegister': 'register-account'}.get(obj_type)
|
'CalculationRegister': 'register-calc', 'AccountingRegister': 'register-account'}.get(obj_type)
|
||||||
|
# Порядок видов детей — канон выгрузки, снят с корпуса (acc+erp, разброса внутри
|
||||||
|
# типа нет): у большинства регистров Resource, Attribute, Dimension, а у
|
||||||
|
# бухгалтерского — Dimension, Resource, Attribute. Команды у платформы идут
|
||||||
|
# последними, как и здесь.
|
||||||
|
kind_order = ['dim', 'res', 'attr'] if obj_type == 'AccountingRegister' else ['res', 'attr', 'dim']
|
||||||
|
for kind in kind_order:
|
||||||
|
if kind == 'res':
|
||||||
for r in resources:
|
for r in resources:
|
||||||
if dim_res_ctx:
|
if dim_res_ctx:
|
||||||
emit_attribute('\t\t\t', r, dim_res_ctx, 'Resource')
|
emit_attribute('\t\t\t', r, dim_res_ctx, 'Resource')
|
||||||
else:
|
else:
|
||||||
emit_resource('\t\t\t', r, obj_type)
|
emit_resource('\t\t\t', r, obj_type)
|
||||||
|
elif kind == 'dim':
|
||||||
for d in dims:
|
for d in dims:
|
||||||
if dim_res_ctx:
|
if dim_res_ctx:
|
||||||
emit_attribute('\t\t\t', d, dim_res_ctx, 'Dimension')
|
emit_attribute('\t\t\t', d, dim_res_ctx, 'Dimension')
|
||||||
else:
|
else:
|
||||||
emit_dimension('\t\t\t', d, obj_type)
|
emit_dimension('\t\t\t', d, obj_type)
|
||||||
|
else:
|
||||||
for a in reg_attrs:
|
for a in reg_attrs:
|
||||||
emit_attribute('\t\t\t', a, reg_ctx)
|
emit_attribute('\t\t\t', a, reg_ctx)
|
||||||
for cmd in reg_commands:
|
for cmd in reg_commands:
|
||||||
@@ -4360,7 +4725,7 @@ if obj_type == 'WebService':
|
|||||||
X(f'\t</{obj_type}>')
|
X(f'\t</{obj_type}>')
|
||||||
X('</MetaDataObject>')
|
X('</MetaDataObject>')
|
||||||
|
|
||||||
metadata_xml = '\n'.join(lines) + '\n'
|
metadata_xml = '\r\n'.join(lines)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 16. Write files
|
# 16. Write files
|
||||||
@@ -4422,7 +4787,7 @@ os.makedirs(type_dir, exist_ok=True)
|
|||||||
if obj_type not in types_no_sub_dir:
|
if obj_type not in types_no_sub_dir:
|
||||||
os.makedirs(obj_sub_dir, exist_ok=True)
|
os.makedirs(obj_sub_dir, exist_ok=True)
|
||||||
|
|
||||||
write_utf8_bom(main_xml_path, metadata_xml)
|
write_xml_file_keep_eol(main_xml_path, metadata_xml)
|
||||||
|
|
||||||
# Module files
|
# Module files
|
||||||
modules_created = []
|
modules_created = []
|
||||||
@@ -4498,10 +4863,14 @@ if obj_type == 'CommonForm':
|
|||||||
'xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" '
|
'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: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"')
|
'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"')
|
||||||
cf_form_xml = ('<?xml version="1.0" encoding="UTF-8"?>\n<Form ' + cf_ns + ' version="' + format_version + '">\n'
|
# Шапка Form на 2.21 тоже несёт палитру — см. комментарий у xmlns_decl.
|
||||||
'\t<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">\n\t\t<Autofill>true</Autofill>\n\t</AutoCommandBar>\n'
|
if is_format_221:
|
||||||
'\t<ChildItems/>\n</Form>\n')
|
cf_ns = cf_ns.replace(' xmlns:style=',
|
||||||
write_utf8_bom(cf_form_xml_path, cf_form_xml)
|
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||||
|
cf_form_xml = ('<?xml version="1.0" encoding="UTF-8"?>\r\n<Form ' + cf_ns + ' version="' + format_version + '">\r\n'
|
||||||
|
'\t<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">\r\n\t\t<Autofill>true</Autofill>\r\n\t</AutoCommandBar>\r\n'
|
||||||
|
'\t<ChildItems/>\r\n</Form>\r\n')
|
||||||
|
write_xml_file_keep_eol(cf_form_xml_path, cf_form_xml)
|
||||||
modules_created.append(cf_form_xml_path)
|
modules_created.append(cf_form_xml_path)
|
||||||
cf_module_dir = os.path.join(ext_dir, 'Form')
|
cf_module_dir = os.path.join(ext_dir, 'Form')
|
||||||
os.makedirs(cf_module_dir, exist_ok=True)
|
os.makedirs(cf_module_dir, exist_ok=True)
|
||||||
@@ -4589,10 +4958,18 @@ def emit_predef_item(out, val, indent, code_type):
|
|||||||
def build_predefined_xml(items, xsi_type, code_type):
|
def build_predefined_xml(items, xsi_type, code_type):
|
||||||
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
||||||
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="{xsi_type}" version="{format_version}">')
|
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="{xsi_type}" version="{format_version}">')
|
||||||
|
# Шапка Predefined.xml не объявляет cfg (predef/v8/xr/xs/xsi) — на время сборки этого
|
||||||
|
# файла ссылочный тип уходит на локальную форму, как делает и платформа.
|
||||||
|
global cfg_prefix
|
||||||
|
saved_cfg_prefix = cfg_prefix
|
||||||
|
cfg_prefix = None
|
||||||
|
try:
|
||||||
for it in items:
|
for it in items:
|
||||||
emit_predef_item(out, it, '\t', code_type)
|
emit_predef_item(out, it, '\t', code_type)
|
||||||
|
finally:
|
||||||
|
cfg_prefix = saved_cfg_prefix
|
||||||
out.append('</PredefinedData>')
|
out.append('</PredefinedData>')
|
||||||
return '\n'.join(out) + '\n'
|
return '\r\n'.join(out)
|
||||||
|
|
||||||
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
||||||
# ExtDimensionTypes/ChildItems). Флаги перечисляем по def-порядку признаков плана; в DSL — только TRUE. ---
|
# ExtDimensionTypes/ChildItems). Флаги перечисляем по def-порядку признаков плана; в DSL — только TRUE. ---
|
||||||
@@ -4687,10 +5064,17 @@ def emit_predef_account(out, val, indent, obj_nm, acct_flag_names, ext_dim_flag_
|
|||||||
def build_predefined_account_xml(items, obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref=''):
|
def build_predefined_account_xml(items, obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref=''):
|
||||||
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
||||||
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="ChartOfAccountsPredefinedItems" version="{format_version}">')
|
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="ChartOfAccountsPredefinedItems" version="{format_version}">')
|
||||||
|
# См. build_predefined_xml: шапка этого файла cfg не объявляет.
|
||||||
|
global cfg_prefix
|
||||||
|
saved_cfg_prefix = cfg_prefix
|
||||||
|
cfg_prefix = None
|
||||||
|
try:
|
||||||
for it in items:
|
for it in items:
|
||||||
emit_predef_account(out, it, '\t', obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref)
|
emit_predef_account(out, it, '\t', obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref)
|
||||||
|
finally:
|
||||||
|
cfg_prefix = saved_cfg_prefix
|
||||||
out.append('</PredefinedData>')
|
out.append('</PredefinedData>')
|
||||||
return '\n'.join(out) + '\n'
|
return '\r\n'.join(out)
|
||||||
|
|
||||||
# Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase).
|
# Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase).
|
||||||
def emit_predef_calc_type(out, val, indent):
|
def emit_predef_calc_type(out, val, indent):
|
||||||
@@ -4712,7 +5096,7 @@ def build_predefined_calc_type_xml(items):
|
|||||||
for it in items:
|
for it in items:
|
||||||
emit_predef_calc_type(out, it, '\t')
|
emit_predef_calc_type(out, it, '\t')
|
||||||
out.append('</PredefinedData>')
|
out.append('</PredefinedData>')
|
||||||
return '\n'.join(out) + '\n'
|
return '\r\n'.join(out)
|
||||||
|
|
||||||
# Special files
|
# Special files
|
||||||
# --- Состав плана обмена (ExchangePlan, Ext/Content.xml). Ключ `content`/`Состав`:
|
# --- Состав плана обмена (ExchangePlan, Ext/Content.xml). Ключ `content`/`Состав`:
|
||||||
@@ -4766,7 +5150,7 @@ if obj_type == 'ExchangePlan':
|
|||||||
parts.append(f'\t\t<AutoRecord>{it["autoRecord"]}</AutoRecord>\r\n')
|
parts.append(f'\t\t<AutoRecord>{it["autoRecord"]}</AutoRecord>\r\n')
|
||||||
parts.append('\t</Item>\r\n')
|
parts.append('\t</Item>\r\n')
|
||||||
parts.append('</ExchangePlanContent>\r\n')
|
parts.append('</ExchangePlanContent>\r\n')
|
||||||
write_utf8_bom(content_path, ''.join(parts))
|
write_xml_file_keep_eol(content_path, ''.join(parts))
|
||||||
modules_created.append(content_path)
|
modules_created.append(content_path)
|
||||||
elif not os.path.isfile(content_path):
|
elif not os.path.isfile(content_path):
|
||||||
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
||||||
@@ -4774,7 +5158,7 @@ if obj_type == 'ExchangePlan':
|
|||||||
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
||||||
ensure_ext_dir()
|
ensure_ext_dir()
|
||||||
content_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<ExchangePlanContent {xep_ns} version="{format_version}"/>\r\n'
|
content_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<ExchangePlanContent {xep_ns} version="{format_version}"/>\r\n'
|
||||||
write_utf8_bom(content_path, content_xml)
|
write_xml_file_keep_eol(content_path, content_xml)
|
||||||
modules_created.append(content_path)
|
modules_created.append(content_path)
|
||||||
|
|
||||||
if obj_type == 'BusinessProcess':
|
if obj_type == 'BusinessProcess':
|
||||||
@@ -4782,7 +5166,7 @@ if obj_type == 'BusinessProcess':
|
|||||||
if not os.path.isfile(flowchart_path):
|
if not os.path.isfile(flowchart_path):
|
||||||
ensure_ext_dir()
|
ensure_ext_dir()
|
||||||
flowchart_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<Flowchart xmlns="http://v8.1c.ru/8.3/MDClasses" version="{format_version}"/>\r\n'
|
flowchart_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<Flowchart xmlns="http://v8.1c.ru/8.3/MDClasses" version="{format_version}"/>\r\n'
|
||||||
write_utf8_bom(flowchart_path, flowchart_xml)
|
write_xml_file_keep_eol(flowchart_path, flowchart_xml)
|
||||||
modules_created.append(flowchart_path)
|
modules_created.append(flowchart_path)
|
||||||
|
|
||||||
# Предопределённые элементы (Ext/Predefined.xml). Root-элемент по типу.
|
# Предопределённые элементы (Ext/Predefined.xml). Root-элемент по типу.
|
||||||
@@ -4795,20 +5179,20 @@ if obj_type == 'ChartOfAccounts' and defn.get('predefined'):
|
|||||||
edt_ref = resolve_type_prefix_syn(str(defn['extDimensionTypes'])) if defn.get('extDimensionTypes') else ''
|
edt_ref = resolve_type_prefix_syn(str(defn['extDimensionTypes'])) if defn.get('extDimensionTypes') else ''
|
||||||
predef_xml = build_predefined_account_xml(defn['predefined'], obj_name, af_names, edf_names, edt_ref)
|
predef_xml = build_predefined_account_xml(defn['predefined'], obj_name, af_names, edf_names, edt_ref)
|
||||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||||
write_utf8_bom(predef_path, predef_xml)
|
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||||
modules_created.append(predef_path)
|
modules_created.append(predef_path)
|
||||||
elif obj_type == 'ChartOfCalculationTypes' and defn.get('predefined'):
|
elif obj_type == 'ChartOfCalculationTypes' and defn.get('predefined'):
|
||||||
ensure_ext_dir()
|
ensure_ext_dir()
|
||||||
predef_xml = build_predefined_calc_type_xml(defn['predefined'])
|
predef_xml = build_predefined_calc_type_xml(defn['predefined'])
|
||||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||||
write_utf8_bom(predef_path, predef_xml)
|
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||||
modules_created.append(predef_path)
|
modules_created.append(predef_path)
|
||||||
elif obj_type in predef_root_by_type and defn.get('predefined'):
|
elif obj_type in predef_root_by_type and defn.get('predefined'):
|
||||||
ensure_ext_dir()
|
ensure_ext_dir()
|
||||||
cat_code_type = str(defn['codeType']) if defn.get('codeType') else 'String'
|
cat_code_type = str(defn['codeType']) if defn.get('codeType') else 'String'
|
||||||
predef_xml = build_predefined_xml(defn['predefined'], predef_root_by_type[obj_type], cat_code_type)
|
predef_xml = build_predefined_xml(defn['predefined'], predef_root_by_type[obj_type], cat_code_type)
|
||||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||||
write_utf8_bom(predef_path, predef_xml)
|
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||||
modules_created.append(predef_path)
|
modules_created.append(predef_path)
|
||||||
|
|
||||||
# Модули команд (Commands/<Имя>/Ext/CommandModule.bsl) — заготовка обработчика.
|
# Модули команд (Commands/<Имя>/Ext/CommandModule.bsl) — заготовка обработчика.
|
||||||
@@ -4825,59 +5209,169 @@ if commands:
|
|||||||
# 17. Register in Configuration.xml
|
# 17. Register in Configuration.xml
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
config_xml_path = os.path.join(output_dir, 'Configuration.xml')
|
def get_new_object_position(cfg_dir):
|
||||||
reg_result = None
|
"""Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
|
||||||
|
|
||||||
child_tag = obj_type
|
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"
|
||||||
|
|
||||||
if os.path.isfile(config_xml_path):
|
|
||||||
# Read raw content, preserving BOM/EOL byte-for-byte (newline='' => no translation).
|
def is_order_sensitive_type(type_name):
|
||||||
with open(config_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
|
"""Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Канонический порядок видов в <ChildObjects> — эталон в docs/1c-configuration-spec.md,
|
||||||
|
# таблица «Порядок типов в ChildObjects». Нужен, чтобы новая группа вида вставала на своё
|
||||||
|
# место: иначе платформа переставит её при первой же выгрузке и даст диф на ровном месте.
|
||||||
|
# Реестр карт: tests/skills/check-type-maps.mjs.
|
||||||
|
CHILD_OBJECT_TYPES = [
|
||||||
|
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||||
|
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||||
|
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||||
|
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||||
|
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||||
|
'FunctionalOptionsParameter', 'DefinedType', 'Bot', 'PaletteColor', 'CommonCommand', 'CommandGroup',
|
||||||
|
'Constant', 'CommonForm', 'Catalog', 'Document',
|
||||||
|
'DocumentNumerator', 'Sequence', 'DocumentJournal', 'Enum',
|
||||||
|
'Report', 'DataProcessor', 'InformationRegister', 'AccumulationRegister',
|
||||||
|
'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'AccountingRegister',
|
||||||
|
'ChartOfCalculationTypes', 'CalculationRegister',
|
||||||
|
'BusinessProcess', 'Task', 'IntegrationService',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register_in_childobjects(parent_xml_path, parent_tag, child_tag, child_name):
|
||||||
|
"""Регистрация объекта в <ChildObjects> родительского XML.
|
||||||
|
|
||||||
|
Общая реализация: эталон — meta-compile, копии — role-compile, xdto-compile.
|
||||||
|
Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||||
|
Возвращает исход: added | already | no-childobj | no-config.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(parent_xml_path):
|
||||||
|
return 'no-config'
|
||||||
|
|
||||||
|
# Read raw content, preserving BOM/EOL byte-for-byte (newline='' => no translation)
|
||||||
|
with open(parent_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
|
||||||
config_content = f.read()
|
config_content = f.read()
|
||||||
|
|
||||||
ns = 'http://v8.1c.ru/8.3/MDClasses'
|
ns = 'http://v8.1c.ru/8.3/MDClasses'
|
||||||
# ET is used ONLY read-only here: to locate ChildObjects and detect a duplicate.
|
# ET is used ONLY read-only here: to locate ChildObjects and detect a duplicate.
|
||||||
# We deliberately do NOT re-serialize Configuration.xml with ElementTree.write():
|
# We deliberately do NOT re-serialize Configuration.xml with ElementTree.write():
|
||||||
# it drops every xmlns declaration used only inside attribute VALUES (e.g.
|
# it drops every xmlns declaration used only inside attribute VALUES (e.g.
|
||||||
# xsi:type="app:ApplicationUsePurpose" in UsePurposes) because ET never sees those
|
# xsi:type="app:ApplicationUsePurpose" in UsePurposes) because ET never sees such
|
||||||
# prefixes in element/attribute names. The dropped declaration makes XDTO read the
|
# prefixes in element/attribute names. The dropped declaration makes XDTO read the
|
||||||
# value as anyType and Designer refuses to load the file (issue #38). Registration is
|
# value as anyType and Designer refuses to load the file (issue #38). Registration is
|
||||||
# therefore done by raw-text insertion, preserving BOM, EOL and all namespaces
|
# therefore done by raw-text insertion, preserving BOM, EOL and all namespaces
|
||||||
# byte-for-byte (same approach as subsystem-compile).
|
# byte-for-byte (same approach as subsystem-compile).
|
||||||
tree = ET.parse(config_xml_path)
|
tree = ET.parse(parent_xml_path)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
|
|
||||||
child_objects = root.find(f'{{{ns}}}Configuration/{{{ns}}}ChildObjects')
|
child_objects = root.find(f'{{{ns}}}{parent_tag}/{{{ns}}}ChildObjects')
|
||||||
if child_objects is None:
|
if child_objects is None:
|
||||||
# Try direct path
|
# Try direct path
|
||||||
config_elem = root.find(f'{{{ns}}}Configuration')
|
parent_elem = root.find(f'{{{ns}}}{parent_tag}')
|
||||||
if config_elem is not None:
|
if parent_elem is not None:
|
||||||
child_objects = config_elem.find(f'{{{ns}}}ChildObjects')
|
child_objects = parent_elem.find(f'{{{ns}}}ChildObjects')
|
||||||
|
|
||||||
if child_objects is None:
|
if child_objects is None:
|
||||||
reg_result = 'no-childobj'
|
return 'no-childobj'
|
||||||
else:
|
|
||||||
existing = child_objects.findall(f'{{{ns}}}{child_tag}')
|
existing = child_objects.findall(f'{{{ns}}}{child_tag}')
|
||||||
already_exists = any((e.text or '').strip() == obj_name for e in existing)
|
if any((e.text or '').strip() == child_name for e in existing):
|
||||||
|
return 'already'
|
||||||
|
|
||||||
if already_exists:
|
|
||||||
reg_result = 'already'
|
|
||||||
else:
|
|
||||||
eol = '\r\n' if '\r\n' in config_content else '\n'
|
eol = '\r\n' if '\r\n' in config_content else '\n'
|
||||||
entry = f'<{child_tag}>{esc_xml_text(obj_name)}</{child_tag}>'
|
entry = f'<{child_tag}>{esc_xml_text(child_name)}</{child_tag}>'
|
||||||
|
|
||||||
block = re.search(r'<ChildObjects\s*>.*?</ChildObjects>', config_content, re.S)
|
block = re.search(r'<ChildObjects\s*>.*?</ChildObjects>', config_content, re.S)
|
||||||
if block is None:
|
if block is None:
|
||||||
# Empty self-closing <ChildObjects/> => open it with the first entry.
|
# Empty self-closing <ChildObjects/> => open it with the first entry.
|
||||||
empty = re.search(r'<ChildObjects\s*/>', config_content)
|
empty = re.search(r'<ChildObjects\s*/>', config_content)
|
||||||
if empty is None:
|
if empty is None:
|
||||||
reg_result = 'no-childobj'
|
return 'no-childobj'
|
||||||
else:
|
|
||||||
replacement = f'<ChildObjects>{eol}\t\t\t{entry}{eol}\t\t</ChildObjects>'
|
replacement = f'<ChildObjects>{eol}\t\t\t{entry}{eol}\t\t</ChildObjects>'
|
||||||
new_content = config_content[:empty.start()] + replacement + config_content[empty.end():]
|
new_content = config_content[:empty.start()] + replacement + config_content[empty.end():]
|
||||||
write_utf8_bom(config_xml_path, new_content)
|
write_utf8_bom(parent_xml_path, new_content)
|
||||||
reg_result = 'added'
|
return 'added'
|
||||||
else:
|
|
||||||
|
# byName: перед первым объектом того же вида, чьё имя больше нового.
|
||||||
|
# Виды с осмысленным порядком в дереве пропускаем — см. is_order_sensitive_type.
|
||||||
|
if (not is_order_sensitive_type(child_tag)
|
||||||
|
and get_new_object_position(os.path.dirname(os.path.abspath(parent_xml_path))) == 'byName'):
|
||||||
|
line_rx = re.compile(rf'(?m)^([ \t]*)<{child_tag}>([^<]*)</{child_tag}>')
|
||||||
|
for m in line_rx.finditer(config_content, block.start(), block.end()):
|
||||||
|
if compare_metadata_names(m.group(2), child_name) > 0:
|
||||||
|
new_content = (config_content[:m.start()]
|
||||||
|
+ f'{m.group(1)}{entry}{eol}'
|
||||||
|
+ config_content[m.start():])
|
||||||
|
write_utf8_bom(parent_xml_path, new_content)
|
||||||
|
return 'added'
|
||||||
|
|
||||||
close_same = f'</{child_tag}>'
|
close_same = f'</{child_tag}>'
|
||||||
last_same = config_content.rfind(close_same, block.start(), block.end())
|
last_same = config_content.rfind(close_same, block.start(), block.end())
|
||||||
if last_same != -1:
|
if last_same != -1:
|
||||||
@@ -4887,16 +5381,36 @@ if os.path.isfile(config_xml_path):
|
|||||||
+ f'{eol}\t\t\t{entry}'
|
+ f'{eol}\t\t\t{entry}'
|
||||||
+ config_content[insert_at:])
|
+ config_content[insert_at:])
|
||||||
else:
|
else:
|
||||||
# No element of this type yet: new line before </ChildObjects>,
|
# Группы своего вида ещё нет: ставим её в канонический порядок видов — перед первой
|
||||||
# reusing the block's existing closing indent for </ChildObjects>.
|
# группой вида старше по CHILD_OBJECT_TYPES. Дописать в конец блока нельзя: платформа
|
||||||
|
# переставит группу при первой же выгрузке и даст диф на ровном месте.
|
||||||
|
anchor = None
|
||||||
|
if child_tag in CHILD_OBJECT_TYPES:
|
||||||
|
own_idx = CHILD_OBJECT_TYPES.index(child_tag)
|
||||||
|
type_rx = re.compile(r'(?m)^([ \t]*)<(\w+)>[^<]*</\2>')
|
||||||
|
for m in type_rx.finditer(config_content, block.start(), block.end()):
|
||||||
|
other = m.group(2)
|
||||||
|
if other in CHILD_OBJECT_TYPES and CHILD_OBJECT_TYPES.index(other) > own_idx:
|
||||||
|
anchor = m
|
||||||
|
break
|
||||||
|
if anchor is not None:
|
||||||
|
new_content = (config_content[:anchor.start()]
|
||||||
|
+ f'{anchor.group(1)}{entry}{eol}'
|
||||||
|
+ config_content[anchor.start():])
|
||||||
|
else:
|
||||||
|
# Видов старше в файле нет — новая строка перед </ChildObjects>,
|
||||||
|
# отступ закрывающего тега переиспользуется.
|
||||||
close_at = config_content.rfind('</ChildObjects>', block.start(), block.end())
|
close_at = config_content.rfind('</ChildObjects>', block.start(), block.end())
|
||||||
new_content = (config_content[:close_at]
|
new_content = (config_content[:close_at]
|
||||||
+ f'\t{entry}{eol}\t\t'
|
+ f'\t{entry}{eol}\t\t'
|
||||||
+ config_content[close_at:])
|
+ config_content[close_at:])
|
||||||
write_utf8_bom(config_xml_path, new_content)
|
write_utf8_bom(parent_xml_path, new_content)
|
||||||
reg_result = 'added'
|
return 'added'
|
||||||
else:
|
|
||||||
reg_result = 'no-config'
|
|
||||||
|
child_tag = obj_type
|
||||||
|
config_xml_path = os.path.join(output_dir, 'Configuration.xml')
|
||||||
|
reg_result = register_in_childobjects(config_xml_path, 'Configuration', child_tag, obj_name)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 18. Summary
|
# 18. Summary
|
||||||
@@ -4949,6 +5463,8 @@ elif reg_result == 'no-config':
|
|||||||
print(f' Configuration.xml: not found at {config_xml_path} (register manually)')
|
print(f' Configuration.xml: not found at {config_xml_path} (register manually)')
|
||||||
|
|
||||||
# Cross-reference hints
|
# Cross-reference hints
|
||||||
|
if std_attr_tail_hint:
|
||||||
|
print(f'[HINT] {std_attr_tail_hint}')
|
||||||
if obj_type == 'AccountingRegister' and not defn.get('chartOfAccounts'):
|
if obj_type == 'AccountingRegister' and not defn.get('chartOfAccounts'):
|
||||||
print('[HINT] AccountingRegister requires ChartOfAccounts reference:')
|
print('[HINT] AccountingRegister requires ChartOfAccounts reference:')
|
||||||
print(' /meta-edit -Operation modify-property -Value "ChartOfAccounts=ChartOfAccounts.XXX"')
|
print(' /meta-edit -Operation modify-property -Value "ChartOfAccounts=ChartOfAccounts.XXX"')
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# meta-decompile v0.63 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
# meta-decompile v0.65 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
#
|
#
|
||||||
# Поддержаны: Catalog, ExchangePlan, ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes, Document,
|
# Поддержаны: Catalog, ExchangePlan, ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes, Document,
|
||||||
# InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, BusinessProcess, Task, Enum. Инверс meta-compile (omit-on-default: ключ эмитим только
|
# InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, BusinessProcess, Task, Enum. Инверс meta-compile (omit-on-default: ключ эмитим только
|
||||||
# когда значение в XML отличается от умолчания компилятора). Неподдерживаемый тип / не-MetaDataObject
|
# когда значение в XML отличается от умолчания компилятора). Неподдерживаемый тип / не-MetaDataObject
|
||||||
# root → exit 3 (ring3, как form-decompile).
|
# root → exit 3 (ring3, как form-decompile).
|
||||||
|
[CmdletBinding(PositionalBinding=$false)]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user