mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-08 04:30:19 +03:00
Compare commits
997
Commits
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "cc-1c-skills",
|
||||
"interface": {
|
||||
"displayName": "1C Skills"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "1c-skills",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
|
||||
"ref": "port-codex"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"category": "Development"
|
||||
},
|
||||
{
|
||||
"name": "1c-skills-py",
|
||||
"source": {
|
||||
"source": "url",
|
||||
"url": "https://github.com/Nikolay-Shirokov/cc-1c-skills.git",
|
||||
"ref": "port-codex-py"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE"
|
||||
},
|
||||
"category": "Development"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
---
|
||||
name: cfe-patch-method
|
||||
description: Генерация и актуализация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после, вместо оригинала, изменить его тело (ИзменениеИКонтроль) — или актуализировать перехватчик после изменения оригинала
|
||||
argument-hint: -ExtensionPath <path> -ConfigPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cfe-patch-method — Генерация перехватчика метода
|
||||
|
||||
Читает оригинал метода из конфигурации-источника и генерирует `.bsl`-перехватчик заимствованного объекта: с правильной директивой контекста, полной сигнатурой, обрамляющими инструкциями препроцессора и областями. Для `ИзменениеИКонтроль` копирует всё тело оригинала. Создаёт файл модуля, дописывает в существующий или актуализирует уже заимствованный метод.
|
||||
|
||||
## Предусловие
|
||||
|
||||
Объект должен быть заимствован в расширение (`/cfe-borrow`). Нужен доступ к исходникам базовой конфигурации (`-ConfigPath`) — оттуда читается оригинал метода. `NamePrefix` берётся из `Configuration.xml` расширения.
|
||||
|
||||
### Авто-определение ConfigPath
|
||||
|
||||
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
|
||||
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
|
||||
4. Если `configSrc` нет — спроси у пользователя
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание | По умолчанию |
|
||||
|----------|----------|--------------|
|
||||
| `ExtensionPath` | Путь к расширению (обязат.) | — |
|
||||
| `ConfigPath` | Путь к конфигурации-источнику | обязат., кроме случая, когда `ModulePath` — путь к файлу |
|
||||
| `ModulePath` | Логическое имя (`Тип.Имя.Модуль`) **или** путь к файлу модуля `.bsl` | обязат. для генерации |
|
||||
| `MethodName` | Имя перехватываемого метода | обязат. для генерации |
|
||||
| `InterceptorType` | `Before` / `After` / `Instead` / `ModificationAndControl` | обязат. для генерации |
|
||||
| `Check` | Проверить контролируемые методы на дрейф (только отчёт) | — |
|
||||
| `Actualize` | Актуализировать дрейфнувшие контролируемые методы | — |
|
||||
|
||||
## Формат ModulePath
|
||||
|
||||
| ModulePath | Файл |
|
||||
|------------|------|
|
||||
| `Catalog.X.ObjectModule` | `Catalogs/X/Ext/ObjectModule.bsl` |
|
||||
| `Catalog.X.ManagerModule` | `Catalogs/X/Ext/ManagerModule.bsl` |
|
||||
| `Catalog.X.Form.Y` | `Catalogs/X/Forms/Y/Ext/Form/Module.bsl` |
|
||||
| `CommonModule.X` | `CommonModules/X/Ext/Module.bsl` |
|
||||
| `Document.X.ObjectModule` | `Documents/X/Ext/ObjectModule.bsl` |
|
||||
| `Document.X.Form.Y` | `Documents/X/Forms/Y/Ext/Form/Module.bsl` |
|
||||
|
||||
Аналогично для Report, DataProcessor, InformationRegister и других типов.
|
||||
|
||||
Вместо логического имени в `ModulePath` можно передать **путь к файлу** модуля-источника `.bsl` — тогда `-ConfigPath` указывать не нужно (оригинал читается прямо из этого файла). Путь модуля расширения определяется от типовой папки в пути автоматически.
|
||||
|
||||
## Типы перехвата
|
||||
|
||||
| InterceptorType | Декоратор | Назначение | Применим к |
|
||||
|-----------------|-----------|------------|------------|
|
||||
| `Before` | `&Перед` | Код до вызова оригинального метода | процедуры |
|
||||
| `After` | `&После` | Код после вызова оригинального метода | процедуры |
|
||||
| `Instead` | `&Вместо` | Замена метода; в теле — скаффолд `ПродолжитьВызов(...)` | процедуры и функции |
|
||||
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела оригинала для правки маркерами `#Вставка`/`#Удаление` | процедуры и функции |
|
||||
|
||||
## Маркеры `#Вставка` / `#Удаление` (для `ModificationAndControl`)
|
||||
|
||||
`&ИзменениеИКонтроль` вставляет в перехватчик **копию тела оригинала**. Дальше отредактируй тело под свою доработку, **помечая каждое изменение** — платформа так отличает твою правку от неизменного оригинала:
|
||||
|
||||
- **Добавляешь код** → оберни его `#Вставка` … `#КонецВставки`.
|
||||
- **Удаляешь код оригинала** → оберни удаляемые строки `#Удаление` … `#КонецУдаления`, но сами строки **оставь между маркерами** (платформа сверяет их с оригиналом).
|
||||
- **Заменяешь** → `#Удаление` старое `#КонецУдаления` сразу за ним `#Вставка` новое `#КонецВставки`.
|
||||
|
||||
Пример:
|
||||
```bsl
|
||||
&ИзменениеИКонтроль("ПриЗаписи")
|
||||
Процедура Расш_ПриЗаписи(Отказ)
|
||||
СуммаДокумента = РассчитатьСумму();
|
||||
#Вставка
|
||||
// доработка: округляем
|
||||
СуммаДокумента = Окр(СуммаДокумента, 2);
|
||||
#КонецВставки
|
||||
#Удаление
|
||||
Записать();
|
||||
#КонецУдаления
|
||||
#Вставка
|
||||
ЗаписатьСПроверкой(Отказ);
|
||||
#КонецВставки
|
||||
КонецПроцедуры
|
||||
```
|
||||
|
||||
Правила:
|
||||
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
|
||||
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
|
||||
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
|
||||
|
||||
## Актуализация
|
||||
|
||||
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
|
||||
|
||||
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
|
||||
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
|
||||
|
||||
Статусы в выводе:
|
||||
|
||||
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
|
||||
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
|
||||
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
|
||||
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
|
||||
|
||||
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/cfe-patch-method/scripts/cfe-patch-method.py" -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Создание информационной базы 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Создаёт новую информационную базу 1С (файловую или серверную).
|
||||
Поддерживает создание из шаблона и добавление в список баз.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UseTemplate
|
||||
Путь к файлу шаблона (.cf или .dt)
|
||||
|
||||
.PARAMETER AddToList
|
||||
Добавить в список баз 1С
|
||||
|
||||
.PARAMETER ListName
|
||||
Имя базы в списке
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UseTemplate,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AddToList,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
|
||||
|
||||
# --- 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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-FileIbCreated {
|
||||
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$IbPath)
|
||||
$f = Join-Path $IbPath "1Cv8.1CD"
|
||||
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/UseTemplate' = '-UseTemplate'; '/AddToList' = '-AddToList'; '--db-path' = '-InfoBasePath'; '--load' = '-UseTemplate'; '--restore' = '-UseTemplate' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate template ---
|
||||
if ($UseTemplate -and -not (Test-Path $UseTemplate)) {
|
||||
Write-Host "Error: template file not found: $UseTemplate" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_create_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "create", "--db-path=$InfoBasePath", "--create-database")
|
||||
if ($UseTemplate) {
|
||||
if ([System.IO.Path]::GetExtension($UseTemplate) -ieq ".dt") {
|
||||
$arguments += "--restore=$UseTemplate"
|
||||
} else {
|
||||
$arguments += "--load=$UseTemplate", "--apply"
|
||||
}
|
||||
}
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("CREATEINFOBASE")
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting
|
||||
# the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch.
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "File=`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
# --- Template ---
|
||||
if ($UseTemplate) {
|
||||
$arguments += "/UseTemplate", "`"$UseTemplate`""
|
||||
}
|
||||
|
||||
# --- Add to list ---
|
||||
if ($AddToList) {
|
||||
if ($ListName) {
|
||||
$arguments += "/AddToList", "`"$ListName`""
|
||||
} else {
|
||||
$arguments += "/AddToList"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "create_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
}
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,502 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def file_ib_created(ib_path):
|
||||
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
f = os.path.join(ib_path, "1Cv8.1CD")
|
||||
return os.path.isfile(f) and os.path.getsize(f) > 0
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create 1C information base",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UseTemplate", default="")
|
||||
parser.add_argument("-AddToList", action="store_true")
|
||||
parser.add_argument("-ListName", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/UseTemplate": "-UseTemplate",
|
||||
"/AddToList": "-AddToList",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--load": "-UseTemplate",
|
||||
"--restore": "-UseTemplate",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate template ---
|
||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
|
||||
if args.UseTemplate:
|
||||
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
|
||||
arguments.append(f"--restore={args.UseTemplate}")
|
||||
else:
|
||||
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["CREATEINFOBASE"]
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them.
|
||||
# Quoting the whole token instead breaks a path with spaces — on both OSes.
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||
else:
|
||||
arguments.append(f'File="{args.InfoBasePath}"')
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", f'"{args.ListName}"'])
|
||||
else:
|
||||
arguments.append("/AddToList")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
is_server = bool(args.InfoBaseServer and args.InfoBaseRef)
|
||||
ib_missing = exit_code == 0 and not is_server and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
|
||||
if exit_code == 0:
|
||||
if is_server:
|
||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
||||
else:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,496 +0,0 @@
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в CF-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает конфигурацию информационной базы в бинарный CF-файл.
|
||||
Поддерживает выгрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному CF-файлу
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config save does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "save", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$OutputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/DumpCfg", "`"$OutputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: db-dump-dt
|
||||
description: Выгрузка информационной базы 1С в DT-файл (вся база — конфигурация + данные). Используй когда нужно выгрузить информационную базу, выгрузить архив базы, сделать бэкап, выгрузить dt
|
||||
argument-hint: "[database] [output.dt]"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-dump-dt — Выгрузка информационной базы в DT-файл
|
||||
|
||||
Выгружает информационную базу целиком (конфигурация **+ данные**) в DT-файл — полный снимок ИБ.
|
||||
|
||||
> В отличие от `/db-dump-cf` (только конфигурация), `.dt` содержит **всю базу**: данные,
|
||||
> настройки, пользователей. Это бэкап/точка отката, а не выгрузка метаданных.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-dump-dt [database] [output.dt]
|
||||
/db-dump-dt dev backup.dt
|
||||
/db-dump-dt — база по умолчанию, имя файла по базе и дате
|
||||
```
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Выгрузка ИБ (файловая база)
|
||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база
|
||||
python ".augment/skills/db-dump-dt/scripts/db-dump-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-load-dt` — загрузка ИБ из DT (обратная операция)
|
||||
- `/db-dump-cf` — выгрузка только конфигурации (без данных)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -1,470 +0,0 @@
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка информационной базы 1С в DT-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному DT-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$OutputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/DumpIB", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C information base to DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
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)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
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)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,566 +0,0 @@
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в XML-файлы
|
||||
|
||||
.DESCRIPTION
|
||||
Выполняет выгрузку конфигурации 1С в файлы в четырёх режимах:
|
||||
- Full: полная выгрузка всей конфигурации
|
||||
- Changes: инкрементальная выгрузка изменённых объектов
|
||||
- Partial: выгрузка конкретных объектов из списка
|
||||
- UpdateInfo: обновление только ConfigDumpInfo.xml
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог для выгрузки конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим выгрузки: Full, Changes, Partial, UpdateInfo (по умолчанию Changes)
|
||||
|
||||
.PARAMETER Objects
|
||||
Имена объектов метаданных через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")]
|
||||
[string]$Mode = "Changes",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Objects,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if ($Mode -eq "Partial" -and -not $Objects) {
|
||||
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
|
||||
Write-Host "Created output directory: $ConfigDir"
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "export", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "UpdateInfo") {
|
||||
Write-Host "Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8" -ForegroundColor Red
|
||||
exit 1
|
||||
} elseif ($Mode -eq "Partial") {
|
||||
$objList = @($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$arguments = @("infobase", "config", "export", "objects") + $objList
|
||||
$arguments += "--out=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "export", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/DumpConfigToFiles", "`"$ConfigDir`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
switch ($Mode) {
|
||||
"Full" {
|
||||
Write-Host "Executing full configuration dump..."
|
||||
}
|
||||
"Changes" {
|
||||
Write-Host "Executing incremental configuration dump..."
|
||||
$arguments += "-update"
|
||||
$arguments += "-force"
|
||||
}
|
||||
"Partial" {
|
||||
Write-Host "Executing partial configuration dump..."
|
||||
$objectList = $Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
|
||||
$listFile = Join-Path $tempDir "dump_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $objectList, $utf8Bom)
|
||||
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
Write-Host "Objects to dump: $($objectList.Count)"
|
||||
foreach ($obj in $objectList) { Write-Host " $obj" }
|
||||
}
|
||||
"UpdateInfo" {
|
||||
Write-Host "Updating ConfigDumpInfo.xml..."
|
||||
$arguments += "-configDumpInfoOnly"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully" -ForegroundColor Green
|
||||
Write-Host "Configuration dumped to: $ConfigDir"
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Changes",
|
||||
choices=["Full", "Changes", "Partial", "UpdateInfo"],
|
||||
help="Dump mode (default: Changes)",
|
||||
)
|
||||
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if args.Mode == "Partial" and not args.Objects:
|
||||
print("Error: -Objects required for Partial mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
os.makedirs(args.ConfigDir, exist_ok=True)
|
||||
print(f"Created output directory: {args.ConfigDir}")
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.Mode == "Partial":
|
||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||
arguments = ["infobase", "config", "export", "objects"] + obj_list
|
||||
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration dump...")
|
||||
elif args.Mode == "Changes":
|
||||
print("Executing incremental configuration dump...")
|
||||
arguments.append("-update")
|
||||
arguments.append("-force")
|
||||
elif args.Mode == "Partial":
|
||||
print("Executing partial configuration dump...")
|
||||
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
|
||||
|
||||
list_file = os.path.join(temp_dir, "dump_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(object_list))
|
||||
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
print(f"Objects to dump: {len(object_list)}")
|
||||
for obj in object_list:
|
||||
print(f" {obj}")
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Updating ConfigDumpInfo.xml...")
|
||||
arguments.append("-configDumpInfoOnly")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print("Dump completed successfully")
|
||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,497 +0,0 @@
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из CF-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию из бинарного CF-файла в информационную базу.
|
||||
Поддерживает загрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к CF-файлу для загрузки
|
||||
|
||||
.PARAMETER Extension
|
||||
Загрузить как расширение
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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')
|
||||
$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'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config load does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "load", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$InputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/LoadCfg", "`"$InputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: db-load-dt
|
||||
description: Загрузка информационной базы 1С из DT-файла — полная перезапись базы (конфигурация + данные). Используй когда нужно загрузить архив информационной базы, восстановить базу, загрузить dt
|
||||
disable-model-invocation: true
|
||||
argument-hint: <input.dt> [database]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-load-dt — Загрузка информационной базы из DT-файла
|
||||
|
||||
Восстанавливает информационную базу целиком (конфигурация **+ данные**) из DT-файла.
|
||||
|
||||
> ⚠️ **Необратимая операция.** Загрузка `.dt` **полностью перезаписывает базу** — и
|
||||
> конфигурацию, и все данные. Текущее содержимое базы будет потеряно. После загрузки
|
||||
> `/db-update` **не нужен** — конфигурация БД уже синхронна внутри снимка.
|
||||
|
||||
## Когда НЕ использовать
|
||||
|
||||
- Нужно создать **новую** базу из `.dt` → используй `/db-create` (из DT-шаблона), а не загрузку
|
||||
в существующую.
|
||||
- Нужно обновить только конфигурацию (без данных) → `/db-load-cf` или `/db-load-xml`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-load-dt <input.dt> [database]
|
||||
/db-load-dt backup.dt dev
|
||||
```
|
||||
|
||||
## Порядок действий перед загрузкой
|
||||
|
||||
1. Предложи пользователю сначала сделать `/db-dump-dt` текущего состояния базы — это точка
|
||||
отката (восстановиться будет нечем, если не сохранить).
|
||||
2. Запроси **явное подтверждение**: вся база (данные + конфигурация) будет перезаписана.
|
||||
3. Только после подтверждения выполняй загрузку.
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## После выполнения
|
||||
|
||||
Если база занята (активные сеансы), загрузка не выполнится — для серверной базы можно
|
||||
передать `-UnlockCode`; иначе освободи базу и повтори.
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Файловая база
|
||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база с ускорением загрузки
|
||||
python ".augment/skills/db-load-dt/scripts/db-load-dt.py" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-dump-dt` — выгрузка ИБ в DT (обратная операция, точка отката перед загрузкой)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -1,487 +0,0 @@
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка информационной базы 1С из DT-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
|
||||
ВНИМАНИЕ: операция полностью перезаписывает базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к DT-файлу для загрузки
|
||||
|
||||
.PARAMETER JobsCount
|
||||
Количество фоновых заданий для загрузки (0 = по числу процессоров)
|
||||
|
||||
.PARAMETER UnlockCode
|
||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$JobsCount = 0,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UnlockCode,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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')
|
||||
$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'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
|
||||
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$InputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
|
||||
|
||||
$arguments += "/RestoreIB", "`"$InputFile`""
|
||||
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C information base from DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-JobsCount", type=int, default=0)
|
||||
parser.add_argument("-UnlockCode", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
||||
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
||||
arguments.append("--create-database")
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
if args.UnlockCode:
|
||||
arguments.append(f'/UC"{args.UnlockCode}"')
|
||||
|
||||
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
|
||||
if args.JobsCount > 0:
|
||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,686 +0,0 @@
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка изменений из Git в базу 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Определяет изменённые файлы конфигурации по данным Git и выполняет
|
||||
частичную загрузку в информационную базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-выгрузки конфигурации (git-репозиторий)
|
||||
|
||||
.PARAMETER Source
|
||||
Источник изменений: All, Staged, Unstaged, Commit (по умолчанию All)
|
||||
|
||||
.PARAMETER CommitRange
|
||||
Диапазон коммитов (для Source=Commit), напр. HEAD~3..HEAD
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER DryRun
|
||||
Только показать что будет загружено (без загрузки)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source Commit -CommitRange "HEAD~3..HEAD"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("All", "Staged", "Unstaged", "Commit")]
|
||||
[string]$Source = "All",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CommitRange,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$DryRun,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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 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 ""
|
||||
}
|
||||
|
||||
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
||||
function Get-ObjectXmlFromSubFile {
|
||||
param([string]$RelativePath)
|
||||
|
||||
$parts = $RelativePath -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
return "$($parts[0])/$($parts[1]).xml"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
if (-not $DryRun) {
|
||||
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 + validate connection (skip if DryRun) ---
|
||||
$engine = "1cv8"
|
||||
if (-not $DryRun) {
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if ($Source -eq "Commit" -and -not $CommitRange) {
|
||||
Write-Host "Error: -CommitRange required for Source=Commit" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Check git ---
|
||||
try {
|
||||
$null = git --version 2>&1
|
||||
} catch {
|
||||
Write-Host "Error: git not found in PATH" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
# Все git-вызовы для сбора путей идут через один хелпер с -c core.quotePath=false,
|
||||
# иначе кириллические пути возвращаются в octal-виде и не распознаются (зеркало run_git в .py).
|
||||
function Invoke-GitLines {
|
||||
param([string[]]$GitArgs)
|
||||
$out = git -c core.quotePath=false @GitArgs 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { return $out }
|
||||
return @()
|
||||
}
|
||||
|
||||
$changedFiles = @()
|
||||
$ConfigDir = (Resolve-Path $ConfigDir).Path.TrimEnd('\')
|
||||
$configDirNormalized = $ConfigDir.Replace('\', '/')
|
||||
|
||||
Push-Location $ConfigDir
|
||||
try {
|
||||
switch ($Source) {
|
||||
"Staged" {
|
||||
Write-Host "Getting staged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
}
|
||||
"Unstaged" {
|
||||
Write-Host "Getting unstaged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
"Commit" {
|
||||
Write-Host "Getting changes from $CommitRange..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative', $CommitRange)
|
||||
}
|
||||
"All" {
|
||||
Write-Host "Getting all uncommitted changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$changedFiles = $changedFiles | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
|
||||
if ($changedFiles.Count -eq 0) {
|
||||
Write-Host "No changes found"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Git changes detected: $($changedFiles.Count) files"
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
$configFiles = @()
|
||||
$supportSkipped = @()
|
||||
|
||||
foreach ($file in $changedFiles) {
|
||||
$file = $file.Trim().Replace('\', '/')
|
||||
if ([string]::IsNullOrWhiteSpace($file)) { continue }
|
||||
|
||||
# Skip service files (not partially loadable). Support-state files are tracked
|
||||
# to warn the user: support changes apply only via a full load.
|
||||
if ($file -match 'ParentConfigurations\.bin$') { $supportSkipped += $file; continue }
|
||||
if ($file -eq "ConfigDumpInfo.xml" -or $file -match '(^|/)ConfigDumpInfo\.xml$') { continue }
|
||||
|
||||
$fullPath = Join-Path $ConfigDir $file
|
||||
|
||||
if ($file -match '\.xml$') {
|
||||
# XML file — add directly if exists
|
||||
if (Test-Path $fullPath) {
|
||||
if ($configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
|
||||
$objectXml = Get-ObjectXmlFromSubFile -RelativePath $file
|
||||
if ($objectXml) {
|
||||
$fullXmlPath = Join-Path $ConfigDir $objectXml
|
||||
if (Test-Path $fullXmlPath) {
|
||||
if ($configFiles -notcontains $objectXml) {
|
||||
$configFiles += $objectXml
|
||||
}
|
||||
if ((Test-Path $fullPath) -and $configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
|
||||
# Add all files from Ext/ directory of the object
|
||||
$parts = $file -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
$extDir = Join-Path (Join-Path $ConfigDir $parts[0]) "$($parts[1])\Ext"
|
||||
if (Test-Path $extDir) {
|
||||
Get-ChildItem -Path $extDir -Recurse -File | ForEach-Object {
|
||||
$extRelPath = $_.FullName.Replace("$ConfigDir\", '').Replace('\', '/')
|
||||
if ($configFiles -notcontains $extRelPath) {
|
||||
$configFiles += $extRelPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportSkipped.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportSkipped) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($configFiles.Count -eq 0) {
|
||||
Write-Host "No configuration files found in changes"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Files for loading: $($configFiles.Count)"
|
||||
foreach ($f in $configFiles) { Write-Host " $f" }
|
||||
|
||||
# --- DryRun: stop here ---
|
||||
if ($DryRun) {
|
||||
Write-Host ""
|
||||
Write-Host "DryRun mode - no changes applied"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_git_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $configFiles
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
$listFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $configFiles, $utf8Bom)
|
||||
|
||||
# --- Build arguments ---
|
||||
$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 += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
$arguments += "-Format", $Format
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host ""
|
||||
Write-Host "Executing partial configuration load..."
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
Write-Host ""
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def get_object_xml_from_subfile(relative_path):
|
||||
"""Map sub-file path (BSL, HTML, etc.) to object XML path."""
|
||||
parts = re.split(r"[\\/]", relative_path)
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}/{parts[1]}.xml"
|
||||
return None
|
||||
|
||||
|
||||
def run_git(config_dir, git_args):
|
||||
"""Run a git command in config_dir and return output lines on success."""
|
||||
result = subprocess.run(
|
||||
["git", "-c", "core.quotePath=false"] + git_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=config_dir,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
return []
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load Git changes into 1C database",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
|
||||
parser.add_argument(
|
||||
"-Source",
|
||||
default="All",
|
||||
choices=["All", "Staged", "Unstaged", "Commit"],
|
||||
help="Change source (default: All)",
|
||||
)
|
||||
parser.add_argument("-CommitRange", default="", help="Commit range (for Source=Commit), e.g. HEAD~3..HEAD")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="File format (default: Hierarchical)",
|
||||
)
|
||||
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("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
v8path = None
|
||||
if not args.DryRun:
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
engine = "1cv8"
|
||||
if not args.DryRun:
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if args.Source == "Commit" and not args.CommitRange:
|
||||
print("Error: -CommitRange required for Source=Commit", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check git ---
|
||||
try:
|
||||
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: git not found in PATH", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
changed_files = []
|
||||
|
||||
if args.Source == "Staged":
|
||||
print("Getting staged changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
|
||||
elif args.Source == "Unstaged":
|
||||
print("Getting unstaged changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
|
||||
elif args.Source == "Commit":
|
||||
print(f"Getting changes from {args.CommitRange}...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative", args.CommitRange])
|
||||
elif args.Source == "All":
|
||||
print("Getting all uncommitted changes...")
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--cached", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["diff", "--name-only", "--relative"])
|
||||
changed_files += run_git(args.ConfigDir, ["ls-files", "--others", "--exclude-standard"])
|
||||
|
||||
# Deduplicate and filter blanks
|
||||
changed_files = list(dict.fromkeys(f for f in changed_files if f.strip()))
|
||||
|
||||
if len(changed_files) == 0:
|
||||
print("No changes found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Git changes detected: {len(changed_files)} files")
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
config_files = []
|
||||
support_skipped = []
|
||||
|
||||
for file in changed_files:
|
||||
file = file.strip().replace("\\", "/")
|
||||
if not file:
|
||||
continue
|
||||
|
||||
# Skip service files (not partially loadable). Support-state files are
|
||||
# tracked to warn: support changes apply only via a full load.
|
||||
if file.endswith("ParentConfigurations.bin"):
|
||||
support_skipped.append(file)
|
||||
continue
|
||||
if file == "ConfigDumpInfo.xml" or file.endswith("/ConfigDumpInfo.xml"):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(args.ConfigDir, file)
|
||||
|
||||
if file.endswith(".xml"):
|
||||
# XML file — add directly if exists
|
||||
if os.path.exists(full_path):
|
||||
if file not in config_files:
|
||||
config_files.append(file)
|
||||
else:
|
||||
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
|
||||
object_xml = get_object_xml_from_subfile(file)
|
||||
if object_xml:
|
||||
full_xml_path = os.path.join(args.ConfigDir, object_xml)
|
||||
if os.path.exists(full_xml_path):
|
||||
if object_xml not in config_files:
|
||||
config_files.append(object_xml)
|
||||
if os.path.exists(full_path) and file not in config_files:
|
||||
config_files.append(file)
|
||||
|
||||
# Add all files from Ext/ directory of the object
|
||||
parts = re.split(r"[\\/]", file)
|
||||
if len(parts) >= 2:
|
||||
ext_dir = os.path.join(args.ConfigDir, parts[0], parts[1], "Ext")
|
||||
if os.path.isdir(ext_dir):
|
||||
for root, dirs, files in os.walk(ext_dir):
|
||||
for fname in files:
|
||||
abs_path = os.path.join(root, fname)
|
||||
rel_path = os.path.relpath(abs_path, args.ConfigDir).replace("\\", "/")
|
||||
if rel_path not in config_files:
|
||||
config_files.append(rel_path)
|
||||
|
||||
if support_skipped:
|
||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr)
|
||||
for sf in support_skipped:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr)
|
||||
|
||||
if len(config_files) == 0:
|
||||
print("No configuration files found in changes")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Files for loading: {len(config_files)}")
|
||||
for f in config_files:
|
||||
print(f" {f}")
|
||||
|
||||
# --- DryRun: stop here ---
|
||||
if args.DryRun:
|
||||
print("")
|
||||
print("DryRun mode - no changes applied")
|
||||
sys.exit(0)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_git_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + config_files
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(config_files))
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- UpdateDB ---
|
||||
if args.UpdateDB:
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print("")
|
||||
print("Executing partial configuration load...")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
print("")
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,664 +0,0 @@
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из XML-файлов
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию в информационную базу из XML-файлов.
|
||||
Поддерживает полную и частичную загрузку.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-исходников конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим загрузки: Full или Partial (по умолчанию Full)
|
||||
|
||||
.PARAMETER Files
|
||||
Относительные пути файлов через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER ListFile
|
||||
Путь к файлу со списком файлов (альтернатива -Files, для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Partial")]
|
||||
[string]$Mode = "Full",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Files,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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')
|
||||
$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'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
||||
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
$fileList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} elseif ($Files) {
|
||||
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $fileList
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
|
||||
if ($Mode -eq "Full") {
|
||||
Write-Host "Executing full configuration load..."
|
||||
} else {
|
||||
Write-Host "Executing partial configuration load..."
|
||||
|
||||
# Build list file
|
||||
$rawList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} else {
|
||||
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
|
||||
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
|
||||
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
|
||||
if ($supportFiles.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$generatedListFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
|
||||
Write-Host "Files to load: $($fileList.Count)"
|
||||
foreach ($f in $fileList) { Write-Host " $f" }
|
||||
|
||||
$arguments += "-listFile", "`"$generatedListFile`""
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
}
|
||||
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Read log ---
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# 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 ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
||||
Write-Host $msg -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Full",
|
||||
choices=["Full", "Partial"],
|
||||
help="Load mode (default: Full)",
|
||||
)
|
||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
||||
parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="File format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
parser.add_argument(
|
||||
"-StrictLog",
|
||||
action="store_true",
|
||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
args.ListFile = clean_path(args.ListFile, "-ListFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
||||
elif args.Files:
|
||||
file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
|
||||
else:
|
||||
file_list = []
|
||||
if not file_list:
|
||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration load...")
|
||||
else:
|
||||
print("Executing partial configuration load...")
|
||||
|
||||
# Build list file
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
||||
else:
|
||||
raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
|
||||
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)]
|
||||
if support_files:
|
||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr)
|
||||
for sf in support_files:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
||||
if not file_list:
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(file_list))
|
||||
print(f"Files to load: {len(file_list)}")
|
||||
for fl in file_list:
|
||||
print(f" {fl}")
|
||||
|
||||
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- UpdateDB ---
|
||||
if args.UpdateDB:
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Read log ---
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
except Exception:
|
||||
log_content = ""
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
fatal_log_patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
]
|
||||
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 ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
if silent_failures:
|
||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
||||
print(
|
||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
||||
f"platform loaded config but dropped properties/refs{suffix}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for f in silent_failures:
|
||||
print(f" {f}", file=sys.stderr)
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,369 +0,0 @@
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Запуск 1С:Предприятие
|
||||
|
||||
.DESCRIPTION
|
||||
Запускает информационную базу в режиме 1С:Предприятие (пользовательский режим).
|
||||
Запуск в фоне — не ждёт завершения процесса.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER Execute
|
||||
Путь к внешней обработке для запуска
|
||||
|
||||
.PARAMETER CParam
|
||||
Параметр запуска (/C)
|
||||
|
||||
.PARAMETER URL
|
||||
Навигационная ссылка (e1cib/...)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -Execute "C:\epf\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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]$Execute,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CParam,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$URL,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||
|
||||
# --- 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
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
$engine = "1cv8"
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '/Execute' = '-Execute'; '/C' = '-CParam'; '/URL' = '-URL' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# ShellExecute re-joins the argument string, so quote each extra token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Build arguments as single string ---
|
||||
# Note: Start-Process without -NoNewWindow uses ShellExecute.
|
||||
# Passing ArgumentList as array can corrupt Cyrillic when ShellExecute
|
||||
# re-joins elements. Single string avoids this.
|
||||
$argString = "ENTERPRISE"
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$argString += " /S `"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$argString += " /F `"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $argString += " /N`"$UserName`"" }
|
||||
if ($Password) { $argString += " /P`"$Password`"" }
|
||||
|
||||
# --- Optional params ---
|
||||
if ($Execute) {
|
||||
$ext = [System.IO.Path]::GetExtension($Execute).ToLower()
|
||||
if ($ext -eq ".erf") {
|
||||
Write-Host "[WARN] /Execute не поддерживает ERF-файлы (внешние отчёты)." -ForegroundColor Yellow
|
||||
Write-Host " Откройте отчёт через «Файл -> Открыть»: $Execute" -ForegroundColor Yellow
|
||||
Write-Host " Запускаю базу без /Execute." -ForegroundColor Yellow
|
||||
$Execute = ""
|
||||
}
|
||||
}
|
||||
if ($Execute) {
|
||||
$argString += " /Execute `"$Execute`""
|
||||
}
|
||||
if ($CParam) {
|
||||
$argString += " /C `"$CParam`""
|
||||
}
|
||||
if ($URL) {
|
||||
$argString += " /URL `"$URL`""
|
||||
}
|
||||
|
||||
$argString += " /DisableStartupDialogs"
|
||||
|
||||
# The display string is built from the same tokens with secret-prone values redacted.
|
||||
$displayString = $argString
|
||||
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
|
||||
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||
Write-Host "Running: 1cv8.exe $displayArg"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ($proc.HasExited) {
|
||||
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
|
||||
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
|
||||
}
|
||||
Write-Host "PID: $($proc.Id)"
|
||||
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
||||
@@ -1,365 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Launch 1C:Enterprise",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-Execute", default="")
|
||||
parser.add_argument("-CParam", default="")
|
||||
parser.add_argument("-URL", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.Execute = clean_path(args.Execute, "-Execute")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
engine = "1cv8"
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"/Execute": "-Execute",
|
||||
"/C": "-CParam",
|
||||
"/URL": "-URL",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["ENTERPRISE"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
# --- Optional params ---
|
||||
execute = args.Execute
|
||||
if execute:
|
||||
ext = os.path.splitext(execute)[1].lower()
|
||||
if ext == ".erf":
|
||||
print("[WARN] /Execute does not support ERF files (external reports).")
|
||||
print(f" Open the report via File -> Open: {execute}")
|
||||
print(" Launching database without /Execute.")
|
||||
execute = ""
|
||||
|
||||
if execute:
|
||||
arguments.extend(["/Execute", execute])
|
||||
if args.CParam:
|
||||
arguments.extend(["/C", args.CParam])
|
||||
if args.URL:
|
||||
arguments.extend(["/URL", args.URL])
|
||||
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(extra_args)
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
proc = subprocess.Popen([v8path] + arguments)
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline and proc.poll() is None:
|
||||
time.sleep(0.2)
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr)
|
||||
sys.exit(rc if rc and rc > 0 else 1)
|
||||
print(f"PID: {proc.pid}")
|
||||
print("1C:Enterprise launched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,515 +0,0 @@
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Обновление конфигурации базы данных 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Применяет изменения основной конфигурации к конфигурации базы данных.
|
||||
Поддерживает динамическое обновление, обновление расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для обновления
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Обновить все расширения
|
||||
|
||||
.PARAMETER Dynamic
|
||||
Динамическое обновление: "+" включить, "-" отключить
|
||||
|
||||
.PARAMETER Server
|
||||
Обновление на стороне сервера
|
||||
|
||||
.PARAMETER WarningsAsErrors
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("+", "-")]
|
||||
[string]$Dynamic,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$Server,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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')
|
||||
$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 ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_update_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config apply does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($Dynamic -eq "+") { $arguments += "--dynamic=auto" }
|
||||
elseif ($Dynamic -eq "-") { $arguments += "--dynamic=disable" }
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/UpdateDBCfg"
|
||||
|
||||
# --- Options ---
|
||||
if ($Dynamic) {
|
||||
$arguments += "-Dynamic$Dynamic"
|
||||
}
|
||||
if ($Server) {
|
||||
$arguments += "-Server"
|
||||
}
|
||||
if ($WarningsAsErrors) {
|
||||
$arguments += "-WarningsAsErrors"
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "update_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Update 1C database configuration",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.Dynamic == "+":
|
||||
arguments.append("--dynamic=auto")
|
||||
elif args.Dynamic == "-":
|
||||
arguments.append("--dynamic=disable")
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_update_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Options ---
|
||||
if args.Dynamic:
|
||||
arguments.append(f"-Dynamic{args.Dynamic}")
|
||||
if args.Server:
|
||||
arguments.append("-Server")
|
||||
if args.WarningsAsErrors:
|
||||
arguments.append("-WarningsAsErrors")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,510 +0,0 @@
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Сборка внешней обработки/отчёта 1С из XML-исходников
|
||||
|
||||
.DESCRIPTION
|
||||
Собирает EPF/ERF-файл из XML-исходников с помощью платформы 1С.
|
||||
Общий скрипт для epf-build и erf-build.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER SourceFile
|
||||
Путь к корневому XML-файлу исходников
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному EPF/ERF-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$SourceFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
$autoCreatedBase = $null
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
$sourceDir = Split-Path $SourceFile -Parent
|
||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||
Write-Host "No database specified. Creating temporary stub database..."
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
# Invoked via -Command, not -File: -File takes the tail literally, so an array
|
||||
# parameter would arrive as a single comma-glued token.
|
||||
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
|
||||
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $autoBasePath)"
|
||||
if ($AdditionalV8Arguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
if ($AdditionalIbcmdArguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
|
||||
if ($stubProc.ExitCode -ne 0) {
|
||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$InfoBasePath = $autoBasePath
|
||||
$autoCreatedBase = $autoBasePath
|
||||
}
|
||||
|
||||
# --- Validate source file ---
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
Write-Host "Error: source file not found: $SourceFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_build_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
$srcDir = Split-Path $SourceFile -Parent
|
||||
$arguments = @("infobase", "config", "import", "$srcDir", "--out=$OutputFile", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/LoadExternalDataProcessorOrReportFromFiles", "`"$SourceFile`"", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "build_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($autoCreatedBase -and (Test-Path $autoCreatedBase)) {
|
||||
Remove-Item -Path $autoCreatedBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build external data processor or report (EPF/ERF) from XML sources",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-SourceFile", required=True, help="Path to root XML source file")
|
||||
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.SourceFile = clean_path(args.SourceFile, "-SourceFile")
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
auto_created_base = None
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
source_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
||||
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||
print("No database specified. Creating temporary stub database...")
|
||||
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||
"-TempBasePath", auto_base_path]
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
if v8_extra:
|
||||
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
|
||||
if ibcmd_extra:
|
||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||
result = subprocess.run(stub_cmd, capture_output=False)
|
||||
if result.returncode != 0:
|
||||
print("Error: failed to create stub database", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
args.InfoBasePath = auto_base_path
|
||||
auto_created_base = auto_base_path
|
||||
|
||||
# --- Validate source file ---
|
||||
if not os.path.isfile(args.SourceFile):
|
||||
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_build_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
src_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
arguments = ["infobase", "config", "import", src_dir, f"--out={args.OutputFile}", f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Build completed successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if auto_created_base and os.path.exists(auto_created_base):
|
||||
shutil.rmtree(auto_created_base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,497 +0,0 @@
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Разборка внешней обработки/отчёта 1С в XML-исходники
|
||||
|
||||
.DESCRIPTION
|
||||
Разбирает EPF/ERF-файл во XML-исходники с помощью платформы 1С.
|
||||
Общий скрипт для epf-dump и erf-dump.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к EPF/ERF-файлу
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Каталог для выгрузки исходников
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- 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')
|
||||
$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'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- Validate database connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef" -ForegroundColor Red
|
||||
Write-Host "Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly." -ForegroundColor Yellow
|
||||
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 ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_dump_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
$arguments = @("infobase", "config", "export", "--file=$InputFile", "$OutputDir", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$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 += "/DumpExternalDataProcessorOrReportToFiles", "`"$OutputDir`"", "`"$InputFile`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"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",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""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
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""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 = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""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."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""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.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""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 — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""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 "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
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
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""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."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump external data processor or report (EPF/ERF) to XML sources",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-InputFile", required=True, help="Path to EPF/ERF file")
|
||||
parser.add_argument("-OutputDir", required=True, help="Directory for dumped XML sources")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
args.OutputDir = clean_path(args.OutputDir, "-OutputDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate database connection ---
|
||||
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("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if not os.path.exists(args.OutputDir):
|
||||
os.makedirs(args.OutputDir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_dump_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,123 +0,0 @@
|
||||
# Оформление
|
||||
|
||||
Два независимых механизма: **оформление элемента** (постоянные цвета/шрифт/граница на конкретном элементе) и **условное оформление формы** (`conditionalAppearance` — правила, применяемые при выполнении условия).
|
||||
|
||||
## Оформление элемента (цвета / шрифты / граница)
|
||||
|
||||
Свойства задаются прямо на элементе. Применимо к полям (`input`/`check`/`radio`/`labelField`/`picField`/`calendar`), декорациям (`label`/`picture`), кнопкам (`button`), группам (`group`/`columnGroup`), страницам (`page`/`pages`), попапам (`popup`) и таблицам (`table`). Каждое свойство необязательно.
|
||||
|
||||
| Ключ | Что задаёт |
|
||||
|------|-----------|
|
||||
| `textColor` | Цвет текста |
|
||||
| `backColor` | Цвет фона |
|
||||
| `borderColor` | Цвет рамки |
|
||||
| `font` | Шрифт |
|
||||
| `border` | Граница |
|
||||
| `titleTextColor` / `titleBackColor` / `titleFont` | Цвет текста / цвет фона / шрифт заголовка колонки (`labelField`, колонки таблицы); у `page`/`pages`/`popup` — `titleTextColor`/`titleFont` заголовка страницы/попапа |
|
||||
| `footerTextColor` / `footerBackColor` / `footerFont` | Цвет текста / цвет фона / шрифт подвала колонки |
|
||||
|
||||
Те же свойства доступны и через словарь `appearance` элемента — под русскими именами параметров платформы: `ЦветТекста`, `ЦветФона`, `ЦветРамки`, `Шрифт`, `Граница`, `ЦветТекстаЗаголовка`, `ЦветФонаЗаголовка`, `ШрифтЗаголовка`, `ЦветТекстаПодвала`, `ЦветФонаПодвала`, `ШрифтПодвала`. Это та же запись, что и в правилах условного оформления (ниже) и в `appearance` поля дин-списка.
|
||||
|
||||
### Цвет
|
||||
|
||||
Строка в одной из форм:
|
||||
|
||||
| Форма | Значение |
|
||||
|-------|----------|
|
||||
| `web:Имя` | Цвет из web-палитры, напр. `web:Red`, `web:FireBrick`, `web:HoneyDew` |
|
||||
| `win:Имя` | Системный цвет Windows, напр. `win:MenuBar`, `win:ButtonText`, `win:DisabledText` |
|
||||
| `style:ИмяСтиля` | Ссылка на элемент стиля конфигурации/платформы, напр. `style:FormBackColor`, `style:BorderColor` |
|
||||
| `#RRGGBB` | RGB-hex, напр. `#FF0000` |
|
||||
|
||||
Имя должно существовать в своей палитре (несуществующий web-/win-цвет или ссылка на отсутствующий `style:`-элемент — ошибка загрузки формы).
|
||||
|
||||
### Шрифт (`font` / `titleFont` / `footerFont`)
|
||||
|
||||
- Строка `"style:ИмяСтиля"` — шрифт из элемента стиля. Минимальная форма.
|
||||
- Объект — задаются только нужные атрибуты:
|
||||
|
||||
| Ключ | Назначение |
|
||||
|------|-----------|
|
||||
| `ref` | Ссылка на стиль (`"style:X"`) или системный шрифт (`"sys:…"`) |
|
||||
| `faceName` | Имя гарнитуры (для собственного шрифта) |
|
||||
| `height` | Размер |
|
||||
| `bold` / `italic` / `underline` / `strikeout` | `true`/`false` — начертание |
|
||||
| `scale` | Масштаб, % |
|
||||
| `kind` | `Absolute` (собственный шрифт — с `faceName`+`height`) / `WindowsFont` (системный — с `ref:"sys:…"`) |
|
||||
|
||||
```json
|
||||
{ "label": "Внимание!", "textColor": "web:FireBrick",
|
||||
"font": { "faceName": "Arial", "height": 12, "bold": true, "kind": "Absolute", "scale": 100 } }
|
||||
```
|
||||
|
||||
### Граница (`border`)
|
||||
|
||||
- Строка `"style:ИмяСтиля"` (или объект `{ "ref": "style:X" }`) — граница из стиля.
|
||||
- Объект `{ "width": N, "style": "..." }` — собственная граница. `style` — один из: `Single`, `Double`, `Underline`, `DoubleUnderline`, `Overline`, `Embossed`, `Indented`, `WithoutBorder`.
|
||||
|
||||
```json
|
||||
{ "input": "Цена", "path": "Объект.Цена", "textColor": "#FF0000",
|
||||
"borderColor": "style:BorderColor", "border": { "width": 1, "style": "Single" } }
|
||||
{ "labelField": "Код", "titleTextColor": "web:HoneyDew", "border": "style:ControlBorder" }
|
||||
```
|
||||
|
||||
## Условное оформление формы (`conditionalAppearance`)
|
||||
|
||||
Форменный ключ верхнего уровня — массив правил. Каждое правило применяет оформление к перечисленным полям, когда выполняется его условие.
|
||||
|
||||
```json
|
||||
"conditionalAppearance": [
|
||||
{ "selection": ["ОбычноеПоле"], "filter": ["ЧисловоеПоле > 100"],
|
||||
"appearance": { "ЦветФона": "style:FormBackColor" },
|
||||
"presentation": { "ru": "Подсветка", "en": "Highlight" } }
|
||||
]
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `selection` | array | Имена форматируемых полей формы |
|
||||
| `filter` | array | Условие применения (грамматика — ниже) |
|
||||
| `appearance` | object | Словарь «параметр платформы: значение» |
|
||||
| `presentation` | string / object | Подпись правила в списке настроек |
|
||||
| `use` | bool | `false` — правило отключено |
|
||||
| `viewMode` | string | Режим отображения настройки |
|
||||
| `userSettingID` | string | Идентификатор пользовательской настройки; `"auto"` — сгенерировать |
|
||||
|
||||
### filter
|
||||
|
||||
Та же грамматика, что в отборе списка — shorthand `"Поле оператор значение @флаги"` или объект:
|
||||
|
||||
```json
|
||||
"filter": [
|
||||
"Статус = 3",
|
||||
{ "field": "Сумма", "op": ">=", "value": 1000 },
|
||||
{ "group": "Or", "items": [ "Просрочено = true", "Заблокирован = true" ] }
|
||||
]
|
||||
```
|
||||
|
||||
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
|
||||
- **Флаги:** `@off` (отключён), `@user`, `@quickAccess`; `_` = пустое значение.
|
||||
- **Группа:** `{ "group": "And"|"Or"|"Not", "items": [...], "use"? }`.
|
||||
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата; именованный относительный период — строкой `"BeginningOfThisWeek"` с `"valueType": "v8:StandardBeginningDate"` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
|
||||
|
||||
### appearance
|
||||
|
||||
Словарь «параметр платформы: значение». Имена параметров — русские: `ЦветТекста`, `ЦветФона`, `Шрифт`, `Граница`, `Текст`, `Заголовок`, `Формат`, `ВидимостьЭлемента`, `Доступность` и другие параметры оформления компоновки.
|
||||
|
||||
Значения:
|
||||
- **Цвет** (`ЦветТекста`/`ЦветФона`/…) и **шрифт** (`Шрифт`) — те же формы, что в оформлении элемента выше (`web:`/`win:`/`style:`/`#RRGGBB`; шрифт — строка `"style:X"` или объект).
|
||||
- **Текстовые параметры** (`Текст`/`Заголовок`/`Формат`) — по форме значения:
|
||||
- голая строка → нелокализованный литерал (`""` → пустое значение);
|
||||
- объект `{ "ru": "...", "en": "..." }` → локализуемая строка;
|
||||
- объект `{ "field": "путь" }` → ссылка на поле компоновки.
|
||||
|
||||
```json
|
||||
"conditionalAppearance": [
|
||||
{ "selection": ["Остаток"], "filter": ["Остаток < 0"],
|
||||
"appearance": { "ЦветТекста": "web:Red", "Шрифт": { "bold": true } } },
|
||||
{ "selection": ["Комментарий"], "filter": ["Комментарий notFilled"],
|
||||
"appearance": { "Текст": { "ru": "— нет данных —" }, "ЦветТекста": "win:DisabledText" } }
|
||||
]
|
||||
```
|
||||
|
||||
> Условное оформление **самого дин-списка** задаётся не здесь, а в `settings.conditionalAppearance` реквизита-списка — см. `references/dynamic-list.md`.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Диаграммы, диаграмма Ганта, планировщик
|
||||
|
||||
Поле-диаграмма (`chart` / `ganttChart`), поле-планировщик (`planner`) и дендрограмма выводят значение из реквизита соответствующего типа. Конструкция всегда двойная:
|
||||
|
||||
1. **Реквизит** chart/planner-типа (несёт данные и, при необходимости, design-time конфиг).
|
||||
2. **Элемент** формы, привязанный к реквизиту через `path`.
|
||||
|
||||
Минимум — реквизит нужного типа плюс элемент с тем же `path`:
|
||||
|
||||
```json
|
||||
"attributes": [ { "name": "Диаграмма", "type": "d5p1:Chart" } ],
|
||||
"items": [ { "chart": "ПолеДиаграммы", "path": "Диаграмма" } ]
|
||||
```
|
||||
|
||||
Реквизит, заполняемый в коде (без встроенной настройки), достаточно объявить типом — элемент привязывается и работает.
|
||||
|
||||
## Типы реквизита и элемента
|
||||
|
||||
| Элемент | Ключ типа | Тип реквизита | Что несёт элемент дополнительно |
|
||||
|---------|-----------|---------------|---------------------------------|
|
||||
| Диаграмма | `chart` | `d5p1:Chart` | — |
|
||||
| Диаграмма Ганта | `ganttChart` | `d5p1:GanttChart` | `ganttTable` — вложенная таблица (см. ниже) |
|
||||
| Планировщик | `planner` | `pl:Planner` | — |
|
||||
| График. схема | `graphicalSchema` | `d5p1:FlowchartContextType` | `edit`, `warningOnEditRepresentation` |
|
||||
| Период | `periodField` | `v8:StandardPeriod` | — |
|
||||
| Дендрограмма | `dendrogram` | — | — |
|
||||
|
||||
Имя элемента — значение ключа (`"chart": "ПолеДиаграммы"`); `path` — короткое имя реквизита.
|
||||
|
||||
### Элемент диаграммы Ганта (`ganttTable`)
|
||||
|
||||
У поля Ганта внутри лежит полноценная таблица — задаётся ключом `ganttTable` (та же грамматика, что у обычной `table`):
|
||||
|
||||
```json
|
||||
{ "ganttChart": "Ганта", "path": "Ганта",
|
||||
"ganttTable": { "table": "ТаблицаГанта", "path": "Ганта", "height": 3 } }
|
||||
```
|
||||
|
||||
## Design-time конфиг диаграммы (`chart`)
|
||||
|
||||
Реквизит типа `d5p1:Chart` / `d5p1:GanttChart` может нести встроенную настройку диаграммы — объект `chart` на реквизите. Платформа всегда пишет полный набор свойств (~127: тип, серии, легенда, заголовок, шкалы, цвета, шрифты, оси), поэтому **авторинг с нуля непрактичен** — возьмите рабочую диаграмму за основу и правьте смысловое ядро.
|
||||
|
||||
Ключи `chart` = канонические имена свойств диаграммы; задавайте только те, что меняете:
|
||||
|
||||
```json
|
||||
{ "name": "Диаграмма", "type": "d5p1:Chart", "chart": {
|
||||
"chartType": "Line",
|
||||
"isSeriesDesign": true, "realSeriesCount": "2",
|
||||
"realSeriesData": [
|
||||
{ "id": "1", "color": "auto", "line": {"width":2,"gap":false,"style":"Solid"},
|
||||
"marker": "Auto", "text": "Серия 1", "strIsChanged": false, "isExpand": false,
|
||||
"isIndicator": false, "colorPriority": false }
|
||||
],
|
||||
"isShowTitle": true, "title": "Продажи",
|
||||
"isShowLegend": true, "legendPlacement": "Bottom",
|
||||
"paletteKind": "Auto"
|
||||
} }
|
||||
```
|
||||
|
||||
Смысловое ядро для правки:
|
||||
|
||||
| Ключ | Назначение |
|
||||
|------|------------|
|
||||
| `chartType` | Тип: `Line` / `Pie` / `Bar` / `Histogram` / `Column` / `Area` / … |
|
||||
| `realSeriesData` | Массив серий — объекты `{ id, text, color, line, marker, … }` |
|
||||
| `isShowTitle` + `title` | Показ и текст заголовка |
|
||||
| `isShowLegend` + `legendPlacement` | Показ и расположение легенды (`Bottom` / `Right` / …) |
|
||||
| `paletteKind` | Палитра (`Auto` / …) |
|
||||
| `bkgColor` / `labelsColor` / … | Базовые цвета |
|
||||
|
||||
Формы значений внутри `chart`:
|
||||
|
||||
- **Цвета** — verbatim: `auto`, `style:ИмяСтиля`, `web:Red`, `#hex`.
|
||||
- **`line`** — `{ width, gap, style }` (стиль линии: `Solid` / …).
|
||||
- **`border`** — `{ width, style }`.
|
||||
- **`font`** — `{ kind: "AutoFont" }` либо атрибуты шрифта.
|
||||
- **Локализуемые строки** (`title`, `vsFormat`, `lbFormat`, `labelFormat`, серия `text`, …) — голая строка либо `{ "ru": "…", "en": "…" }`.
|
||||
- **Области** (`elementsChart` / `elementsLegend` / `elementsTitle`) — `{ left, right, top, bottom }`.
|
||||
- **Серии** (`realSeriesData` / `realExSeriesData`) — массивы объектов.
|
||||
|
||||
Любое из ~127 свойств переопределяется по каноническому имени; остальное оставляйте дефолтным (не указывайте — берётся из основы).
|
||||
|
||||
### Диаграмма Ганта (`d5p1:GanttChart`)
|
||||
|
||||
Реквизит типа `d5p1:GanttChart` использует **тот же** ключ `chart`. Внутри — вложенный полный `chart`-блок плюс гант-специфика (`points` / `series` / `timeScale` / `drawEmpty` / …). Так же берите рабочую диаграмму Ганта за основу.
|
||||
|
||||
> **Ограничение.** Диаграммы (Chart/Gantt) с заполненными **точками/осями** (`realPointData` / `realDataItems`, заполненные `valuesAxis` / `pointsAxis`) генерик-движком не поддержаны — это редкий вариант. Частые дашборд-диаграммы и диаграммы Ганта (серии / легенда / оформление / шкалы) поддержаны полностью.
|
||||
|
||||
## Design-time конфиг планировщика (`planner`)
|
||||
|
||||
Реквизит типа `pl:Planner` несёт встроенную настройку планировщика — объект `planner`. Компилятор подставляет умолчания для пропущенных ключей, поэтому авторинг может быть кратким:
|
||||
|
||||
```json
|
||||
{ "name": "Планировщик", "type": "pl:Planner", "planner": {
|
||||
"items": [
|
||||
{ "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00",
|
||||
"borderColor": "auto", "backColor": "auto", "deleted": false, "editMode": "EnableEdit" }
|
||||
],
|
||||
"period": { "begin": "2026-06-09T00:00:00", "end": "2026-06-09T23:59:59" },
|
||||
"displayCurrentDate": true, "itemsTimeRepresentation": "BeginTime",
|
||||
"timeScale": { "placement": "Left", "levels": [ { "measure": "Hour", "interval": 1 } ] }
|
||||
} }
|
||||
```
|
||||
|
||||
Минимум — один `item`:
|
||||
|
||||
```json
|
||||
"planner": { "items": [ { "text": "Встреча", "begin": "2026-06-09T01:00:00", "end": "2026-06-09T04:00:00" } ] }
|
||||
```
|
||||
|
||||
| Ключ `planner` | Тип | Назначение |
|
||||
|----------------|-----|------------|
|
||||
| `items` | array | Элементы расписания. Поля элемента: `text`, `tooltip`, `begin`, `end`, `value`, `borderColor`, `backColor`, `textColor`, `font`, `border`, `replacementDate`, `deleted` (bool), `editMode` (`EnableEdit` / …), `id` (необязательно — авто-GUID), `textFormatted` |
|
||||
| `dimensions` | array | Измерения (разрезы) планировщика. Поля: `value` (объект разреза — ссылка `Enum.X.EnumValue.Y` / `Справочник.X`; опустить → пусто), `text` (заголовок), `borderColor`, `backColor`, `textColor`, `font`, `textFormatted`, `elements`. `elements` — элементы измерения, рекурсивны (могут нести вложенные `elements`): `value`, `text`, цвета, `font`, `showOnlySubordinatesAreas` (bool), `textFormatted` |
|
||||
| `period` | object | Отображаемый период `{ begin, end }` (необязательно) |
|
||||
| `timeScale` | object | Шкала времени (см. ниже) |
|
||||
| `borderColor` / `backColor` / `textColor` / `lineColor` | color | Цвета (умолч. `auto`) |
|
||||
| `font` | font | Шрифт (умолч. `{ kind: "AutoFont" }`) |
|
||||
| `border` | border | Рамка `{ width, style }` |
|
||||
| `beginOfRepresentationPeriod` / `endOfRepresentationPeriod` | dateTime | Период представления |
|
||||
| `displayCurrentDate` / `displayWrapHeaders` / `displayTimeScaleWrapHeaders` / `alignElementsOfTimeScale` | bool | Флаги отображения |
|
||||
| `timeScaleWrapHeadersFormat` | ML | Формат перенесённых заголовков шкалы |
|
||||
| `timeScaleWrapBeginIndent` / `timeScaleWrapEndIndent` | int | Отступы переноса шкалы |
|
||||
| `periodicVariantUnit` / `periodicVariantRepetition` | value / int | Единица и кратность периодического варианта |
|
||||
| `itemsTimeRepresentation` | value | Представление времени элементов (`BeginTime` / …) |
|
||||
| `itemsBehaviorWhenSpaceInsufficient` / `newItemsTextType` / `fixDimensionsHeader` / `fixTimeScaleHeader` | value | Поведение элементов и заголовков |
|
||||
| `autoMinColumnWidth` / `autoMinRowHeight` | bool | Авто-минимум размеров |
|
||||
| `minColumnWidth` / `minRowHeight` | int | Минимальные размеры |
|
||||
|
||||
Шкала времени (`timeScale`):
|
||||
|
||||
```json
|
||||
"timeScale": {
|
||||
"placement": "Left",
|
||||
"levels": [ { "measure": "Hour", "interval": 1 } ]
|
||||
}
|
||||
```
|
||||
|
||||
Ключи: `placement`, `levels` (массив уровней), `transparent`, `backColor`, `textColor`, `currentLevel`. Уровень: `measure` (`Hour` / `Day` / …), `interval`, `show`, `line` (`{ width, gap, style }`), `scaleColor`, `dayFormatRule`, `format` (ML), `labels` (`{ ticks }`), `backColor`, `textColor`, `showPereodicalLabels`.
|
||||
|
||||
Формы значений в `planner` те же, что у диаграммы: цвета verbatim (`auto` / `style:X` / `web:Red` / `#hex`); шрифт `{ kind: "AutoFont" }` либо ref-строка; граница `{ width, style }`; ML-форматы — строка или `{ "ru": …, "en": … }`.
|
||||
|
||||
> **Ограничение.** Привязка элемента расписания к элементам измерений (`item.dimensionValues`) пока всегда пустая. Сами измерения (`dimensions`) задавать можно.
|
||||
@@ -1,73 +0,0 @@
|
||||
# Параметры выбора и связь по типу
|
||||
|
||||
Свойства поля ввода (`input`), управляющие выбором значения: чем ограничен список выбора и каким будет тип значения. Имена параметров — строки 1С как есть (`"Отбор.Х"`).
|
||||
|
||||
```json
|
||||
{ "input": "Контрагент", "path": "Объект.Контрагент",
|
||||
"choiceParameters": [
|
||||
{ "name": "Отбор.Активный", "value": true },
|
||||
{ "name": "Отбор.ВидПродукции", "value": ["Enum.Виды.Агрохимикат", "Enum.Виды.Пестицид"] }
|
||||
],
|
||||
"choiceParameterLinks": [
|
||||
{ "name": "Отбор.Организация", "dataPath": "Объект.Организация" },
|
||||
{ "name": "Отбор.Тип", "dataPath": "Объект.Тип", "valueChange": "DontChange" }
|
||||
],
|
||||
"typeLink": { "dataPath": "Объект.ЗначениеДата", "linkItem": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
## Параметры выбора (`choiceParameters`)
|
||||
|
||||
Фиксированные значения параметров выбора, отбирающие список значений независимо от данных формы. Массив объектов `{ name, value }`:
|
||||
|
||||
- `name` — имя параметра (`"Отбор.Активный"`).
|
||||
- `value` — значение. Допустимы: bool, число, строка, ISO-дата (`"2020-01-01T00:00:00"`), ссылка-путь (`Enum.X.Y`, `Catalog.X`). **Массив** значений задаёт фиксированный массив.
|
||||
|
||||
Короткая форма — строки `"name=value"`; значение с запятыми становится массивом, `true`/`false` → bool, число → число, остальное → строка/ссылка:
|
||||
|
||||
```json
|
||||
"choiceParameters": [
|
||||
"Отбор.Активный=true",
|
||||
"Отбор.ВидПродукции=Enum.Виды.Агрохимикат, Enum.Виды.Пестицид"
|
||||
]
|
||||
```
|
||||
|
||||
## Связи параметров выбора (`choiceParameterLinks`)
|
||||
|
||||
Параметры выбора, значение которых берётся из **другого поля формы** (а не задано фиксированно). Типовой случай — отбор списка договоров по выбранному контрагенту. Массив объектов `{ name, dataPath, valueChange? }`:
|
||||
|
||||
- `name` — имя параметра выбора.
|
||||
- `dataPath` — путь к полю формы, чьё значение подставляется в параметр.
|
||||
- `valueChange` — что делать с уже выбранным значением при смене источника: `Clear` (очистить, необязательно — поведение по умолчанию) / `DontChange` (не менять).
|
||||
|
||||
```json
|
||||
{ "input": "Договор", "path": "Объект.Договор",
|
||||
"choiceParameterLinks": [
|
||||
{ "name": "Отбор.Владелец", "dataPath": "Объект.Контрагент" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Короткая форма — строки `"name=dataPath"`, опциональный хвост `:Clear` / `:DontChange`:
|
||||
|
||||
```json
|
||||
"choiceParameterLinks": [ "Отбор.Организация=Объект.Организация", "Отбор.Тип=Объект.Тип:DontChange" ]
|
||||
```
|
||||
|
||||
## Связь по типу (`typeLink`)
|
||||
|
||||
Тип значения поля определяется другим полем формы (напр. поле «Значение» субконто, тип которого задаётся выбранным видом субконто). Объект `{ dataPath, linkItem }`:
|
||||
|
||||
- `dataPath` — путь к полю, задающему тип.
|
||||
- `linkItem` — индекс элемента связи (необязательно, по умолчанию `0`).
|
||||
|
||||
```json
|
||||
"typeLink": { "dataPath": "Объект.ВидСубконто", "linkItem": 0 }
|
||||
```
|
||||
|
||||
Короткая форма — строка `"dataPath"` либо `"dataPath#linkItem"`:
|
||||
|
||||
```json
|
||||
"typeLink": "Объект.ВидСубконто"
|
||||
"typeLink": "Объект.ВидСубконто#1"
|
||||
```
|
||||
@@ -1,86 +0,0 @@
|
||||
# Командный интерфейс формы
|
||||
|
||||
Форменный ключ `commandInterface` управляет расстановкой команд по двум панелям формы:
|
||||
|
||||
- `commandBar` — командная панель формы;
|
||||
- `navigationPanel` — панель навигации.
|
||||
|
||||
Указывать нужно **только команды, у которых меняется расстановка по умолчанию** (видимость, группа, порядок). Команды, которые платформа размещает автоматически и без изменений, в блок не включают.
|
||||
|
||||
```json
|
||||
"commandInterface": {
|
||||
"commandBar": [
|
||||
{ "command": "Form.Command.Печать", "defaultVisible": false, "group": "FormCommandBarImportant",
|
||||
"visible": { "common": false, "roles": { "Бухгалтер": true } } },
|
||||
"CommonCommand.История"
|
||||
],
|
||||
"navigationPanel": {
|
||||
"important": [ { "command": "CommonCommand.СвязанныеДокументы", "defaultVisible": false, "visible": false } ],
|
||||
"seeAlso": [ { "command": "CommonCommand.Заметки", "defaultVisible": false, "visible": false } ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Элемент-команда
|
||||
|
||||
Каждый элемент панели — объект, либо строка-shorthand (= голый `command` со всеми остальными свойствами по умолчанию):
|
||||
|
||||
```json
|
||||
"CommonCommand.История"
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `command` | string | Ссылка на команду дословно: `CommonCommand.X`, `Document.X.StandardCommand.Y`, `Form.Command.X`, `Form.StandardCommand.OK`, `"0"` (пустой / разделитель) |
|
||||
| `type` | string | `Auto` (по умолчанию, необязательно) или `Added` |
|
||||
| `defaultVisible` | bool | Видимость по умолчанию. На практике задаётся только `false` — чтобы скрыть команду, которая иначе видна |
|
||||
| `visible` | bool / object | Видимость с исключениями по ролям: `bool` либо `{ "common": bool, "roles": { "Имя": bool } }` |
|
||||
| `group` | string | Группа размещения дословно: предопределённая (`FormCommandBarImportant`, `FormNavigationPanelGoTo`, …), именованная (`CommandGroup.X`) или GUID-группа расширения |
|
||||
| `index` | int | Порядок команды внутри группы |
|
||||
| `attribute` | string | Путь реквизита для элемента панели навигации |
|
||||
|
||||
## Две формы записи панели
|
||||
|
||||
Панель можно описать **плоским массивом** или **деревом по группам** — выбирайте любую.
|
||||
|
||||
**Плоский массив** — каждый элемент при необходимости несёт собственный `group`:
|
||||
|
||||
```json
|
||||
"commandBar": [
|
||||
{ "command": "Form.Command.Печать", "group": "FormCommandBarImportant", "defaultVisible": false },
|
||||
{ "command": "CommonCommand.История", "group": "FormCommandBarImportant", "index": 1 }
|
||||
]
|
||||
```
|
||||
|
||||
**Дерево** — объект `{ группа: [команды] }`; группа берётся из ключа, элементы её не повторяют:
|
||||
|
||||
```json
|
||||
"navigationPanel": {
|
||||
"important": [ "CommonCommand.СвязанныеДокументы" ],
|
||||
"goTo": [ { "command": "Document.Заказ.StandardCommand.Movements", "defaultVisible": false, "visible": false } ],
|
||||
"seeAlso": [ "CommonCommand.Заметки" ]
|
||||
}
|
||||
```
|
||||
|
||||
Ключи-группы дерева зависят от панели:
|
||||
|
||||
- `navigationPanel`: `important`, `goTo`, `seeAlso` (можно по-русски — `важное`, `перейти`, `смТакже`);
|
||||
- `commandBar`: `important`, `createBasedOn`;
|
||||
- любой другой ключ (`CommandGroup.X` или GUID) подставляется в группу дословно.
|
||||
|
||||
## Скрыть видимую команду
|
||||
|
||||
Самый частый случай — убрать команду, которую платформа показывает по умолчанию:
|
||||
|
||||
```json
|
||||
"commandBar": [
|
||||
{ "command": "Form.Command.Печать", "defaultVisible": false, "visible": false }
|
||||
]
|
||||
```
|
||||
|
||||
Показать команду только некоторым ролям:
|
||||
|
||||
```json
|
||||
{ "command": "Form.Command.Печать", "defaultVisible": false,
|
||||
"visible": { "common": false, "roles": { "Бухгалтер": true } } }
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
# Companion-панели и расширенная подсказка элемента
|
||||
|
||||
Любой элемент формы может нести свой собственный контент в трёх companion-свойствах: расширенную подсказку (`extendedTooltip`), командную панель (`commandBar`) и контекстное меню (`contextMenu`). Все три задаются ключами прямо на объекте элемента.
|
||||
|
||||
```jsonc
|
||||
{ "table": "Список", "path": "Список",
|
||||
"commandBar": { "children": [ … ] },
|
||||
"contextMenu": { "children": [ … ] },
|
||||
"extendedTooltip": "Двойной клик открывает карточку" }
|
||||
```
|
||||
|
||||
## Расширенная подсказка (`extendedTooltip`)
|
||||
|
||||
Подсказка-надпись рядом с элементом. Две формы записи.
|
||||
|
||||
**Текст-форма** — просто текст подсказки:
|
||||
|
||||
```jsonc
|
||||
"extendedTooltip": "Укажите ИНН контрагента"
|
||||
"extendedTooltip": { "ru": "Сумма с НДС", "en": "Amount incl. VAT" }
|
||||
"extendedTooltip": { "text": "Всего <b>с НДС</b>", "formatted": true }
|
||||
```
|
||||
|
||||
- строка — ru-текст;
|
||||
- `{ "ru": …, "en": … }` — многоязычный (как `title`);
|
||||
- `{ "text": …, "formatted": true }` — форматированный текст (inline-разметка 1С: `<b>…</>`, `<i>`, `<u>`, `<color web:Red>…</>`, `<bgColor …>`, `<font …>`, `<fontSize …>`, `<link URL>…</>`, `<img …>`; закрывающий тег — `</>`). `formatted` нужен только когда текст содержит такую разметку.
|
||||
|
||||
**Own-content форма** — объект с раскладкой/оформлением/флагами, когда подсказке нужны размеры, цвет, гиперссылка и т.п.:
|
||||
|
||||
```jsonc
|
||||
"extendedTooltip": {
|
||||
"text": "Перейти к инструкции",
|
||||
"hyperlink": true,
|
||||
"textColor": "web:Blue",
|
||||
"events": { "URLProcessing": "ПодсказкаОбработкаНавигационнойСсылки" }
|
||||
}
|
||||
```
|
||||
|
||||
Ключи own-content объекта (все необязательны):
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `text` | string/ML | Текст подсказки (с `formatted` — форматированный) |
|
||||
| `formatted` | bool | Интерпретировать inline-разметку в `text` |
|
||||
| `tooltip` | string/ML | Всплывающая подсказка самой расширенной подсказки (редко; ≠ обычному `tooltip` элемента) |
|
||||
| `hyperlink` | bool | Сделать подсказку гиперссылкой |
|
||||
| `visible` / `enabled` | bool | Видимость / доступность подсказки |
|
||||
| `width` / `height` | number | Размеры |
|
||||
| `maxWidth` / `autoMaxWidth` | number / bool | Максимальная ширина / авто-максимум |
|
||||
| `titleHeight` | number | Высота заголовка |
|
||||
| `horizontalStretch` | bool | Горизонтальное растяжение |
|
||||
| `verticalAlign` | string | Вертикальное выравнивание |
|
||||
| `textColor` / `font` | string/object | Цвет текста / шрифт (см. `references/appearance.md`) |
|
||||
| `events` | object | Обработчики событий подсказки, напр. `{ "URLProcessing": "Имя" }` у гиперссылочной подсказки |
|
||||
|
||||
## Командная панель (`commandBar`)
|
||||
|
||||
Собственная командная панель элемента (обычно таблицы или группы).
|
||||
|
||||
**Значение** — массив или объект:
|
||||
|
||||
```jsonc
|
||||
"commandBar": [ { "button": "Создать", "command": "СоздатьЭлемент" } ]
|
||||
|
||||
"commandBar": {
|
||||
"autofill": false,
|
||||
"horizontalAlign": "Right",
|
||||
"children": [
|
||||
{ "button": "Создать", "command": "СоздатьЭлемент" },
|
||||
{ "buttonGroup": "Печать", "children": [ … ] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- массив `[ … ]` — краткая запись для `{ "children": [ … ] }`;
|
||||
- объект — `children` плюс необязательные `autofill` и `horizontalAlign`.
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `children` | array | Содержимое панели — обычная грамматика кнопок (см. основную инструкцию) |
|
||||
| `autofill` | bool | `false` — подавить автозаполнение панели стандартными командами. Необязательно (по умолчанию панель автозаполняется) |
|
||||
| `horizontalAlign` | string | Горизонтальное выравнивание содержимого: `Left` / `Center` / `Right`. Необязательно |
|
||||
|
||||
`children` — кнопки: `button` (с `command` / `commandName` / `stdCommand`), `buttonGroup`, `popup` — как в основной инструкции по кнопкам.
|
||||
|
||||
> Для таблицы динамического списка панель по умолчанию подавлена (чтобы не дублировать командную панель формы). Чтобы оставить автозаполняемую панель у самой таблицы — задайте `commandBar: { "autofill": true }`.
|
||||
|
||||
## Контекстное меню (`contextMenu`)
|
||||
|
||||
Собственное контекстное меню элемента. Грамматика та же, что у `commandBar`, но без `horizontalAlign`.
|
||||
|
||||
```jsonc
|
||||
"contextMenu": [ { "button": "Карта маршрута", "commandName": "CommonCommand.КартаМаршрута" } ]
|
||||
|
||||
"contextMenu": {
|
||||
"autofill": false,
|
||||
"children": [
|
||||
{ "button": "Скопировать ссылку", "command": "СкопироватьСсылку" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `children` | array | Пункты меню — обычная грамматика кнопок |
|
||||
| `autofill` | bool | `false` — подавить автозаполнение меню. Необязательно |
|
||||
|
||||
## Пример: таблица со своим меню и инфо-баннером
|
||||
|
||||
```jsonc
|
||||
{ "table": "Заказы", "path": "Объект.Заказы",
|
||||
"extendedTooltip": {
|
||||
"text": "Строки с просрочкой выделены <color web:FireBrick>красным</>",
|
||||
"formatted": true
|
||||
},
|
||||
"commandBar": {
|
||||
"autofill": false,
|
||||
"horizontalAlign": "Right",
|
||||
"children": [
|
||||
{ "button": "Добавить", "command": "ДобавитьЗаказ" },
|
||||
{ "button": "Удалить", "command": "УдалитьЗаказ" }
|
||||
]
|
||||
},
|
||||
"contextMenu": {
|
||||
"children": [
|
||||
{ "button": "Открыть документ", "command": "ОткрытьЗаказ" },
|
||||
{ "buttonGroup": "Экспорт", "children": [
|
||||
{ "button": "В Excel", "command": "ВыгрузитьВExcel" } ] }
|
||||
]
|
||||
} }
|
||||
```
|
||||
@@ -1,144 +0,0 @@
|
||||
# Динамический список
|
||||
|
||||
Реквизит с `type: "DynamicList"` (обычно `main: true`) — основа формы списка. Объект `settings` описывает источник данных и настройки списка. Минимум — указать источник:
|
||||
|
||||
```json
|
||||
{ "name": "Список", "type": "DynamicList", "main": true,
|
||||
"settings": { "mainTable": "Catalog.Контрагенты" } }
|
||||
```
|
||||
|
||||
К списку привязывается таблица-элемент (`table`), ссылающаяся на реквизит через `path` — см. основную инструкцию.
|
||||
|
||||
## Источник данных
|
||||
|
||||
Два взаимоисключающих режима:
|
||||
|
||||
**Таблично-ориентированный** — основная таблица метаданных:
|
||||
|
||||
```json
|
||||
"settings": { "mainTable": "Catalog.Контрагенты" }
|
||||
```
|
||||
|
||||
**Запросный** — произвольный запрос:
|
||||
|
||||
```json
|
||||
"settings": {
|
||||
"query": "ВЫБРАТЬ Т.Ссылка, Т.Наименование, Т.Сумма ИЗ Документ.Заказ КАК Т ГДЕ Т.Сумма > &Порог",
|
||||
"mainTable": "Document.Заказ"
|
||||
}
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `mainTable` | string | Основная таблица (`Catalog.X` / `Document.X` / …). Можно вместе с `query` |
|
||||
| `query` | string | Текст запроса. Поддерживает `@file.sql` (путь к файлу запроса рядом с JSON) |
|
||||
| `keyType` | string | Запросный список без `mainTable`: тип ключа набора — `FieldValue` / `RowKey` / `RowNumber` |
|
||||
| `keyFields` | array | Поля ключа набора (для `keyType` без `mainTable`) |
|
||||
|
||||
Параметры запроса (`&Имя`) задаются в `parameters` (ниже).
|
||||
|
||||
`"dynamicDataRead": false` отключает динамическое считывание (список читается обычным запросом, без фонового обновления) — нужно для тяжёлых/агрегатных запросов.
|
||||
|
||||
## Параметры запроса (`parameters`)
|
||||
|
||||
Значения для `&параметров` текста запроса. Shorthand `"Имя [Заголовок]: тип = Значение"` (всё кроме имени необязательно) либо объект:
|
||||
|
||||
```json
|
||||
"settings": {
|
||||
"query": "… ГДЕ Т.Артикул = &Артикул И Т.Цена ПОДОБНО &Маска",
|
||||
"parameters": [
|
||||
"Артикул",
|
||||
"Маска: string = %",
|
||||
{ "name": "ВидЦен", "valueListAllowed": true },
|
||||
{ "name": "Период", "type": "dateTime" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ключи объекта: `name`, `title`, `type` (грамматика типов — см. основную инструкцию), `value`, `valueListAllowed` (разрешить список значений), `availableValues` (`[{ value, presentation }]`), `expression`, `use`.
|
||||
|
||||
## Значения параметров в настройках (`dataParameters`)
|
||||
|
||||
Предустановленные значения параметров на уровне настроек списка. Shorthand `"Имя = Значение"` или объект `{ parameter, value?, use?, viewMode? }`:
|
||||
|
||||
```json
|
||||
"dataParameters": [ "Организация = _", "ВидЦен" ]
|
||||
```
|
||||
|
||||
## Поля набора (`fields`)
|
||||
|
||||
Обычно поля выводятся из источника сами — `fields` нужен **только чтобы переопределить** свойства отдельного поля:
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
{ "field": "Сумма", "title": "Сумма, руб", "appearance": { "Формат": "ЧДЦ=2" } },
|
||||
{ "field": "Остаток", "valueType": "number(15,2)" }
|
||||
]
|
||||
```
|
||||
|
||||
Ключи поля: `field`, `dataPath`, `title`, `valueType`, `appearance` (как в условном оформлении), `presentationExpression`, `inputParameters` (связь по параметрам выбора), `typeLink` (`{ field, linkItem }` — связь по типу, напр. субконто).
|
||||
|
||||
## Вычисляемые поля (`calculatedFields`)
|
||||
|
||||
Поля, считаемые выражением. Shorthand `"Имя [Заголовок]: тип = Выражение"`:
|
||||
|
||||
```json
|
||||
"calculatedFields": [
|
||||
"Метка = Code + \" \" + Description",
|
||||
"Маржа [Маржа, руб]: number(15,2) = Цена - Закупка"
|
||||
]
|
||||
```
|
||||
|
||||
Объектная форма — для `presentationExpression` / `orderExpression`:
|
||||
|
||||
```json
|
||||
{ "dataPath": "Сорт", "expression": "Code", "title": "Сорт",
|
||||
"valueType": "string(10)", "presentationExpression": "Code" }
|
||||
```
|
||||
|
||||
## Отбор (`filter`)
|
||||
|
||||
Shorthand `"Поле оператор значение @флаги"` или объект:
|
||||
|
||||
```json
|
||||
"filter": [
|
||||
"Организация = _ @off @user",
|
||||
"Сумма > 1000",
|
||||
{ "field": "Дата", "op": ">=", "value": "2024-01-01T00:00:00" },
|
||||
{ "group": "Or", "items": [ "Статус = 1", "Статус = 2" ] }
|
||||
]
|
||||
```
|
||||
|
||||
- **Операторы:** `=` `<>` `>` `>=` `<` `<=`, `in` / `notIn`, `inHierarchy`, `contains` / `notContains`, `beginsWith` / `notBeginsWith`, `like` / `notLike` (`%`-шаблон), `filled` / `notFilled`.
|
||||
- **Флаги:** `@off` (отключён), `@user` (в пользовательских настройках), `@quickAccess`; `_` = пустое значение.
|
||||
- **Группа:** `{ group: "And"|"Or"|"Not", items: [...] }`.
|
||||
- **Дата-значение:** ISO-дата `"2024-01-01T00:00:00"` — фиксированная дата. Именованный относительный период — строкой с типом: `{ "value": "BeginningOfThisWeek", "valueType": "v8:StandardBeginningDate" }` (варианты `BeginningOfThisDay`/`BeginningOfThisWeek`/`BeginningOfThisMonth`/`BeginningOfThisYear`/…).
|
||||
|
||||
## Сортировка (`order`)
|
||||
|
||||
Строка `"Поле"` (по возр.) / `"Поле desc"`, либо объект `{ field, direction? }`. `"Auto"` — автосортировка:
|
||||
|
||||
```json
|
||||
"order": [ "Дата desc", "Наименование", "Auto" ]
|
||||
```
|
||||
|
||||
## Группировка строк (`grouping`)
|
||||
|
||||
Линейная цепочка уровней (внешний → внутренний). Шорткат `>` или массив:
|
||||
|
||||
```json
|
||||
"grouping": "Контрагент > Договор"
|
||||
"grouping": [ "Контрагент", { "field": "Дата", "groupType": "Hierarchy" } ]
|
||||
```
|
||||
|
||||
Ключи уровня-объекта: `field`, `groupType` (`Items` / `Hierarchy`).
|
||||
|
||||
## Условное оформление (`conditionalAppearance`)
|
||||
|
||||
```json
|
||||
"conditionalAppearance": [
|
||||
{ "filter": [ "Просрочено = true" ], "appearance": { "ЦветТекста": "web:Red" } }
|
||||
]
|
||||
```
|
||||
|
||||
`filter` — та же грамматика, что выше. `appearance` — словарь «параметр платформы: значение» (`ЦветТекста`, `ЦветФона`, `Шрифт`, `Текст`, `Формат`, …). Значение `Текст`/`Заголовок`/`Формат`: голая строка — нелокализованный литерал; `{ru,en}` — локализуемая строка; `{ field: "путь" }` — ссылка на поле. Подробнее об оформлении — `references/appearance.md`.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Продвинутая раскладка
|
||||
|
||||
Тонкая настройка размещения элемента внутри родителя сверх базовой геометрии (`width`/`height`/`horizontalStretch`/`verticalStretch`/`visible`/`enabled` и ориентации групп/страниц — они в основной инструкции). Все ключи ниже задаются прямо на элементе и **необязательны** — без них действует поведение платформы по умолчанию.
|
||||
|
||||
## Выравнивание внутри родителя
|
||||
|
||||
Различают **выравнивание самого элемента** в отведённой ему ячейке и **выравнивание содержимого** элемента.
|
||||
|
||||
| Ключ | Значения | Что выравнивает |
|
||||
|------|----------|-----------------|
|
||||
| `groupHorizontalAlign` | `Left` / `Center` / `Right` | Положение **элемента** по горизонтали в родительской группе (когда элемент у́же доступного места) |
|
||||
| `groupVerticalAlign` | `Top` / `Center` / `Bottom` | Положение **элемента** по вертикали в родительской группе |
|
||||
| `horizontalAlign` | `Left` / `Center` / `Right` | Выравнивание **содержимого** (текста/значения) внутри самого элемента |
|
||||
| `verticalAlign` | `Top` / `Center` / `Bottom` | Выравнивание содержимого по вертикали внутри элемента |
|
||||
|
||||
`group*Align` отвечает на вопрос «куда сдвинуть нерастянутый элемент в его ячейке», `horizontalAlign`/`verticalAlign` — «как разместить текст внутри элемента». Это разные оси настройки, их часто комбинируют.
|
||||
|
||||
```json
|
||||
{ "button": "ОК", "groupHorizontalAlign": "Right" }
|
||||
{ "input": "Сумма", "path": "Объект.Сумма", "horizontalAlign": "Right" }
|
||||
{ "label": "Итого", "groupHorizontalAlign": "Center", "horizontalAlign": "Center" }
|
||||
```
|
||||
|
||||
## Ограничение максимального размера
|
||||
|
||||
По умолчанию растягивающийся элемент имеет авто-вычисляемый предел ширины/высоты. Чтобы задать жёсткий предел или вовсе снять авто-предел:
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `maxWidth` | число | Жёсткий максимум ширины элемента |
|
||||
| `maxHeight` | число | Жёсткий максимум высоты элемента |
|
||||
| `autoMaxWidth` | `false` | Отключить авто-предел ширины (элемент тянется без ограничения сверху) |
|
||||
| `autoMaxHeight` | `false` | Отключить авто-предел высоты |
|
||||
|
||||
`autoMaxWidth: false` нужен, например, для широкого многострочного поля или растянутого по всей форме поля ввода, чтобы платформа не «прижимала» его к авто-пределу. Указывают именно отклонение от дефолта; обычное значение `true` писать не нужно.
|
||||
|
||||
```json
|
||||
{ "input": "Комментарий", "path": "Объект.Комментарий", "multiLine": true,
|
||||
"horizontalStretch": true, "autoMaxWidth": false }
|
||||
{ "input": "Поиск", "path": "СтрокаПоиска", "horizontalStretch": true, "maxWidth": 600 }
|
||||
```
|
||||
|
||||
## Поведение при вводе и активации
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `skipOnInput` | `true` / `false` | Пропускать элемент при обходе по Enter/Tab (фокус через него не проходит). Указывают явно, в т.ч. `false` чтобы вернуть в обход поле, которое платформа пропустила бы |
|
||||
| `defaultItem` | `true` | Элемент получает фокус по умолчанию при открытии формы (поле/таблица для немедленного ввода) |
|
||||
|
||||
```json
|
||||
{ "input": "Идентификатор", "path": "Объект.Идентификатор", "skipOnInput": true }
|
||||
{ "input": "Штрихкод", "path": "Штрихкод", "defaultItem": true }
|
||||
```
|
||||
|
||||
`skipOnInput: true` — для служебных/расчётных полей, которые видны, но не редактируются вводом с клавиатуры в общем потоке. `defaultItem: true` ставят на одном элементе формы — точке, с которой пользователь начнёт работу.
|
||||
|
||||
## Перетаскивание
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `enableStartDrag` | `true` | Разрешить начинать перетаскивание из элемента (источник drag-n-drop) |
|
||||
|
||||
Для таблиц приём/перемещение строк управляется ключами таблицы (`enableDrag`, `changeRowOrder`) — см. основную инструкцию; `enableStartDrag` — общий низкоуровневый флаг «этот элемент может быть источником перетаскивания».
|
||||
|
||||
## Закрепление колонки в таблице (`fixingInTable`)
|
||||
|
||||
Свойство поля-колонки внутри таблицы: закрепить колонку у края, чтобы она не уходила при горизонтальной прокрутке.
|
||||
|
||||
| Значения |
|
||||
|----------|
|
||||
| `None` (по умолчанию — не закреплена) / `Left` / `Right` |
|
||||
|
||||
```json
|
||||
{ "table": "Товары", "path": "Объект.Товары", "columns": [
|
||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
|
||||
{ "input": "Количество", "path": "Объект.Товары.Количество" },
|
||||
{ "input": "Сумма", "path": "Объект.Товары.Сумма", "fixingInTable": "Right" } ] }
|
||||
```
|
||||
|
||||
Закрепляют ключевые колонки (идентифицирующую слева, итоговую справа), чтобы они оставались видны при прокрутке широкой таблицы.
|
||||
|
||||
## Ячейки колонок: шапка и подвал
|
||||
|
||||
Для поля-колонки внутри таблицы (и `columnGroup`) — размещение в шапке/подвале и выравнивание текста ячеек. Применять только к элементам внутри `columns` таблицы.
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `showInHeader` | `true` / `false` | Показывать колонку в шапке таблицы |
|
||||
| `showInFooter` | `true` / `false` | Показывать колонку в подвале (нужно для итогов; подвал самой таблицы включается `footer: true`) |
|
||||
| `headerHorizontalAlign` | `Left` / `Right` / `Center` / `Auto` | Выравнивание текста в шапке колонки |
|
||||
| `footerHorizontalAlign` | `Left` / `Right` / `Center` | Выравнивание текста в подвале колонки |
|
||||
| `autoCellHeight` | `true` / `false` | Авто-высота ячейки (перенос содержимого на несколько строк) |
|
||||
|
||||
```json
|
||||
{ "table": "Товары", "path": "Объект.Товары", "footer": true, "columns": [
|
||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "autoCellHeight": true },
|
||||
{ "input": "Сумма", "path": "Объект.Товары.Сумма",
|
||||
"headerHorizontalAlign": "Right", "showInFooter": true, "footerHorizontalAlign": "Right" } ] }
|
||||
```
|
||||
|
||||
## Адаптивная важность (`displayImportance`)
|
||||
|
||||
| Значения |
|
||||
|----------|
|
||||
| `VeryHigh` / `High` / `Usual` / `VeryLow` / `Low` |
|
||||
|
||||
Приоритет элемента при адаптивной перекомпоновке формы на узких/мобильных экранах: элементы с меньшей важностью сворачиваются/прячутся первыми. Применимо к любому элементу.
|
||||
|
||||
```json
|
||||
{ "input": "Комментарий", "path": "Объект.Комментарий", "displayImportance": "Low" }
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
# Форма отчёта
|
||||
|
||||
Форма, подключённая к объекту-отчёту (`Report`). Кроме обычных свойств формы у неё есть несколько свойств в `properties`, связывающих форму с механизмом компоновки (СКД): куда выводится результат, где данные расшифровки, какого она типа. Все они задаются в блоке `properties` верхнего уровня.
|
||||
|
||||
```json
|
||||
"properties": {
|
||||
"reportFormType": "Main",
|
||||
"reportResult": "РезультатОтчета",
|
||||
"detailsData": "ДанныеРасшифровки"
|
||||
}
|
||||
```
|
||||
|
||||
Ни одно из этих свойств не обязательно — указывайте только те, что нужны конкретной форме.
|
||||
|
||||
## Тип формы отчёта (`reportFormType`)
|
||||
|
||||
Роль формы в составе отчёта:
|
||||
|
||||
| Значение | Назначение |
|
||||
|----------|-----------|
|
||||
| `Main` | Основная форма отчёта (результат + настройки) |
|
||||
| `Settings` | Форма настроек |
|
||||
| `Variant` | Форма варианта |
|
||||
|
||||
```json
|
||||
"reportFormType": "Main"
|
||||
```
|
||||
|
||||
## Привязка к компоновке
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `reportResult` | string | Имя реквизита-результата формы (табличный документ, куда выводится отчёт) |
|
||||
| `detailsData` | string | Имя реквизита данных расшифровки |
|
||||
| `variantAppearance` | string | Имя реквизита оформления варианта |
|
||||
|
||||
Значение каждого ключа — имя реквизита формы (а не путь к данным). Реквизит с таким именем должен присутствовать в `attributes` формы.
|
||||
|
||||
## Группа пользовательских настроек (`customSettingsFolder`)
|
||||
|
||||
Группа-элемент формы, в которую генерируются пользовательские настройки компоновщика. Задаётся **по имени** элемента-группы:
|
||||
|
||||
```json
|
||||
"customSettingsFolder": "ГруппаПользовательскихНастроек"
|
||||
```
|
||||
|
||||
## Прочие свойства компоновки
|
||||
|
||||
Редкие, задавайте только при явной необходимости:
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `autoShowState` | `Auto`, `DontShow`, `ShowOnComposition` | Автопоказ состояния формирования |
|
||||
| `reportResultViewMode` | `Auto` | Режим просмотра результата |
|
||||
| `viewModeApplicationOnSetReportResult` | `Auto` | Применение режима просмотра при установке результата |
|
||||
|
||||
## Реалистичный пример
|
||||
|
||||
Основная форма отчёта со СКД: реквизит-результат, данные расшифровки и группа пользовательских настроек.
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"reportFormType": "Main",
|
||||
"reportResult": "РезультатОтчета",
|
||||
"detailsData": "ДанныеРасшифровки",
|
||||
"customSettingsFolder": "ГруппаПользовательскихНастроек"
|
||||
},
|
||||
"attributes": [
|
||||
{ "name": "РезультатОтчета", "type": "SpreadsheetDocument" },
|
||||
{ "name": "ДанныеРасшифровки", "type": "DataCompositionDetailsData" }
|
||||
],
|
||||
"elements": [
|
||||
{ "group": "vertical", "name": "ГруппаПользовательскихНастроек" },
|
||||
{ "spreadsheet": "РезультатОтчета", "path": "РезультатОтчета",
|
||||
"titleLocation": "none" }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1,73 +0,0 @@
|
||||
# Доступ по ролям
|
||||
|
||||
Единый механизм платформы для разграничения по ролям: задаётся общее значение для всех ролей плюс исключения для конкретных ролей. Один и тот же формат значения у четырёх ключей — каждый на своём владельце:
|
||||
|
||||
| Ключ | Владелец | Смысл |
|
||||
|------|----------|-------|
|
||||
| `userVisible` | элемент формы | пользовательская видимость элемента |
|
||||
| `view` | реквизит формы | право просмотра |
|
||||
| `edit` | реквизит формы | право редактирования |
|
||||
| `use` | команда формы | доступность команды |
|
||||
|
||||
Ключ необязателен: его отсутствие = полный доступ для всех ролей.
|
||||
|
||||
## Значение
|
||||
|
||||
Две формы (одинаковы для всех четырёх ключей):
|
||||
|
||||
**Скаляр** `true` / `false` — общее значение для всех ролей, без исключений:
|
||||
|
||||
```json
|
||||
{ "input": "Поле", "userVisible": false }
|
||||
```
|
||||
|
||||
**Объект** `{ "common": <bool>, "roles": { "ИмяРоли": <bool>, … } }` — общее значение `common` плюс явные исключения по ролям:
|
||||
|
||||
```json
|
||||
{ "name": "Реквизит",
|
||||
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
|
||||
```
|
||||
|
||||
Роль, **не указанная** в `roles`, наследует `common`. Указанная — задаёт явный `true`/`false` (может и совпадать с `common`).
|
||||
|
||||
## Имя роли
|
||||
|
||||
Ключи в `roles` — имена ролей конфигурации (`ПолныеПрава`, `Бухгалтер`, …).
|
||||
|
||||
## Примеры
|
||||
|
||||
Элемент скрыт у всех пользователей:
|
||||
|
||||
```json
|
||||
{ "input": "Комментарий", "userVisible": false }
|
||||
```
|
||||
|
||||
Реквизит не виден никому и редактируется только одной ролью:
|
||||
|
||||
```json
|
||||
{ "name": "СуммаБонуса",
|
||||
"view": false,
|
||||
"edit": { "common": false, "roles": { "ПолныеПрава": true } } }
|
||||
```
|
||||
|
||||
Поле доступно для просмотра всем, но редактируемо только администратору:
|
||||
|
||||
```json
|
||||
{ "name": "Статус",
|
||||
"view": true,
|
||||
"edit": { "common": false, "roles": { "Администратор": true } } }
|
||||
```
|
||||
|
||||
Команда недоступна по умолчанию, разрешена только бухгалтеру:
|
||||
|
||||
```json
|
||||
{ "name": "ПровестиЗакрытие",
|
||||
"use": { "common": false, "roles": { "Бухгалтер": true } } }
|
||||
```
|
||||
|
||||
Обратный случай — доступно всем, кроме одной роли:
|
||||
|
||||
```json
|
||||
{ "name": "РедактироватьЦену",
|
||||
"edit": { "common": true, "roles": { "Кладовщик": false } } }
|
||||
```
|
||||
@@ -1,109 +0,0 @@
|
||||
# Спец-поля «документ/датчик»
|
||||
|
||||
Поля для отображения специальных данных: табличный документ, HTML, текст, форматированный документ, индикатор, ползунок. Каждое привязывается к реквизиту своего платформенного типа.
|
||||
|
||||
Структурно это обычные поля — поддерживают общий скелет поля (`path`, `title`, `titleLocation`, флаги `readOnly`/`enabled`/`visible`, `layout`, оформление, события). Ниже — только ключ `type` (имя элемента задаётся значением ключа) и собственные скаляры каждого семейства. Все скаляры необязательны.
|
||||
|
||||
| Ключ типа | Тип реквизита |
|
||||
|-----------|---------------|
|
||||
| `spreadsheet` | `mxl:SpreadsheetDocument` (ТабличныйДокумент) |
|
||||
| `html` | `string` |
|
||||
| `textDoc` | `d5p1:TextDocument` (ТекстовыйДокумент) |
|
||||
| `formattedDoc` | `fd:FormattedDocument` (ФорматированныйДокумент) |
|
||||
| `progressBar` | число |
|
||||
| `trackBar` | число |
|
||||
|
||||
## spreadsheet — поле табличного документа
|
||||
|
||||
Просмотр/редактирование табличного документа (отчёт, печатная форма).
|
||||
|
||||
```json
|
||||
{ "spreadsheet": "ТаблицаОтчета", "path": "ТаблицаОтчета",
|
||||
"titleLocation": "none", "readOnly": true,
|
||||
"output": "Disable", "protection": true }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `output` | string | Использование вывода: `Enable` / `Disable` |
|
||||
| `protection` | bool | Защита от изменений |
|
||||
| `edit` | bool | Разрешить редактирование |
|
||||
| `showGrid` | bool | Показывать сетку |
|
||||
| `showHeaders` | bool | Показывать заголовки строк/колонок |
|
||||
| `showGroups` | bool | Показывать группировки |
|
||||
| `showRowAndColumnNames` | bool | Показывать имена строк и колонок |
|
||||
| `showCellNames` | bool | Показывать имена ячеек |
|
||||
| `verticalScrollBar` / `horizontalScrollBar` | string | Режим полос прокрутки |
|
||||
| `viewScalingMode` | string | Режим масштабирования просмотра |
|
||||
| `selectionShowMode` | string | Режим отображения выделения |
|
||||
| `pointerType` | string | Тип указателя |
|
||||
| `enableDrag` / `enableStartDrag` | bool | Разрешить перетаскивание / начало перетаскивания |
|
||||
|
||||
## html — поле HTML-документа
|
||||
|
||||
Просмотр HTML. Реквизит — строка (содержит HTML-текст или адрес).
|
||||
|
||||
```json
|
||||
{ "html": "Просмотр", "path": "СодержимоеHTML", "titleLocation": "none",
|
||||
"output": "Enable", "warningOnEditRepresentation": false }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `output` | string | Использование вывода: `Enable` / `Disable` |
|
||||
| `warningOnEditRepresentation` | bool | Предупреждать при изменении представления |
|
||||
|
||||
## textDoc — поле текстового документа
|
||||
|
||||
Просмотр/редактирование текстового документа.
|
||||
|
||||
```json
|
||||
{ "textDoc": "Текст", "path": "ТекстДокумента", "editMode": "Edit" }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
|
||||
|
||||
## formattedDoc — поле форматированного документа
|
||||
|
||||
Просмотр/редактирование форматированного документа.
|
||||
|
||||
```json
|
||||
{ "formattedDoc": "Описание", "path": "ФорматированноеОписание", "editMode": "Edit" }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `editMode` | string | Режим редактирования (напр. `Edit` / `View`) |
|
||||
|
||||
## progressBar — поле индикатора
|
||||
|
||||
Индикатор прогресса. Реквизит — числовой.
|
||||
|
||||
```json
|
||||
{ "progressBar": "Прогресс", "path": "Прогресс",
|
||||
"minValue": 0, "maxValue": 100, "showPercent": true }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
|
||||
| `showPercent` | bool | Показывать проценты |
|
||||
|
||||
## trackBar — поле ползунка
|
||||
|
||||
Регулятор-ползунок. Реквизит — числовой.
|
||||
|
||||
```json
|
||||
{ "trackBar": "Масштаб", "path": "Масштаб",
|
||||
"minValue": 20, "maxValue": 400, "markingStep": 20 }
|
||||
```
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `minValue` / `maxValue` | число | Минимальное / максимальное значение |
|
||||
| `step` | число | Шаг изменения |
|
||||
| `largeStep` | число | Крупный шаг |
|
||||
| `markingStep` | число | Шаг разметки |
|
||||
| `markingAppearance` | string | Оформление разметки |
|
||||
@@ -1,132 +0,0 @@
|
||||
# Таблица — продвинутые возможности
|
||||
|
||||
Базовый элемент таблицы (`type: "table"`, колонки, основные свойства) описан в основной инструкции, раздел «Таблица (table)». Здесь — продвинутые возможности: дополнения командной панели, специфика таблицы динамического списка и неочевидные свойства/режимы.
|
||||
|
||||
## Представление (`representation`)
|
||||
|
||||
Как таблица рисует строки:
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список", "representation": "Tree" }
|
||||
```
|
||||
|
||||
`List` — плоский список (по умолчанию), `Tree` — дерево, `HierarchicalList` — иерархический список (группы + элементы на одном уровне).
|
||||
|
||||
Для дерева/иерархии управляйте раскрытием уровней через `initialTreeView` (`ExpandTopLevel` / `ExpandAllLevels` / `NoExpand`).
|
||||
|
||||
## Выделение и текущая строка
|
||||
|
||||
| Ключ | Значения | Назначение |
|
||||
|------|----------|-----------|
|
||||
| `selectionMode` | `SingleRow` / `MultiRow` | Режим выделения строк |
|
||||
| `multipleChoice` | bool | Разрешить множественный выбор (для форм выбора) |
|
||||
| `currentRowUse` | `DontUse` / `Use` / `SelectionPresentation` / `SelectionPresentationAndChoice` / `Choice` | Использование текущей строки таблицы |
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список", "selectionMode": "MultiRow", "multipleChoice": true }
|
||||
```
|
||||
|
||||
## Поиск при вводе (`searchOnInput`)
|
||||
|
||||
Поведение встроенного поиска при наборе текста в таблице:
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список", "searchOnInput": "Use" }
|
||||
```
|
||||
|
||||
`Auto` (по умолчанию) / `Use` (искать) / `DontUse` (не искать).
|
||||
|
||||
Где располагать сами элементы поиска — управляется `searchStringLocation` / `viewStatusLocation` / `searchControlLocation` (`None` / `Top` / `Bottom` / `CommandBar` / `Auto`).
|
||||
|
||||
## Прочие свойства таблицы
|
||||
|
||||
| Ключ | Тип | Назначение |
|
||||
|------|-----|-----------|
|
||||
| `useAlternationRowColor` | bool | Чередование цвета строк |
|
||||
| `verticalLines` / `horizontalLines` | bool | Линии сетки (укажите `false`, чтобы скрыть) |
|
||||
| `markIncomplete` | bool | Автоотметка незаполненных ячеек |
|
||||
| `heightInTableRows` | int | Высота элемента в строках (отдельно от `height`) |
|
||||
| `autoInsertNewRow` | bool | Автодобавление новой строки при вводе в последнюю |
|
||||
| `rowsPicture` | string \| object | Картинка строк. Ссылка (`"CommonPicture.X"`, `"abs:..."`) либо объект `{ src, loadTransparent?, transparentPixel? }` |
|
||||
| `tooltipRepresentation` | string | Режим показа подсказки таблицы: `None`, `Button`, `ShowBottom`, `ShowTop`, `ShowLeft`, `ShowRight`, `ShowAuto`, `Balloon` |
|
||||
|
||||
## Фиксация колонки (`fixingInTable`)
|
||||
|
||||
Свойство **колонки** (на `input` / `labelField` / `check` / `picField` внутри `columns`), а не самой таблицы. Закрепляет колонку у края при горизонтальной прокрутке:
|
||||
|
||||
```json
|
||||
{ "table": "Товары", "path": "Объект.Товары", "columns": [
|
||||
{ "input": "Номенклатура", "path": "Объект.Товары.Номенклатура", "fixingInTable": "Left" },
|
||||
{ "input": "Количество", "path": "Объект.Товары.Количество" }
|
||||
]}
|
||||
```
|
||||
|
||||
`Left` / `Right` / `None`.
|
||||
|
||||
## Исключённые команды (`excludedCommands`)
|
||||
|
||||
Убрать стандартные команды редактора таблицы (кнопки добавления/перемещения/сортировки):
|
||||
|
||||
```json
|
||||
{ "table": "Товары", "path": "Объект.Товары",
|
||||
"excludedCommands": [ "Add", "Delete", "MoveUp", "SortListAsc" ] }
|
||||
```
|
||||
|
||||
Свойство работает на любом поле и на уровне формы; для таблицы значимы команды вида `Add` / `Delete` / `MoveUp` / `MoveDown` / `SortListAsc` / `SortListDesc`.
|
||||
|
||||
## Дополнения командной панели (`additions`)
|
||||
|
||||
Дополнения — это «представления» встроенного поиска таблицы:
|
||||
|
||||
- `searchString` — отображение строки поиска,
|
||||
- `viewStatus` — состояние просмотра,
|
||||
- `searchControl` — управление поиском.
|
||||
|
||||
Каждое дополнение — полноценный элемент (полный набор свойств поля). Размещать их можно двумя способами.
|
||||
|
||||
**(1) Стандартные дополнения** генерирует платформа на уровне таблицы. В DSL указывайте **только отклонения** от стандартного вида — через карту `additions` (ключ = тип дополнения):
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список",
|
||||
"additions": { "viewStatus": { "horizontalLocation": "left" } } }
|
||||
```
|
||||
|
||||
**(2) Кастомное дополнение**, размещённое прямо в командной панели — обычный элемент в `commandBar` с ключом-типом:
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список", "commandBar": [
|
||||
{ "searchString": "ПоискСписка", "source": "Список", "width": 15, "horizontalStretch": true }
|
||||
]}
|
||||
```
|
||||
|
||||
- Тип-ключ: `searchString` / `viewStatus` / `searchControl`.
|
||||
- `source` — имя таблицы-источника; необязательно, по умолчанию = имя родительской таблицы.
|
||||
- `horizontalLocation`: `auto` (по умолчанию) / `left` / `right`. Применимо и к обычным элементам командных панелей.
|
||||
- Прочие свойства как у поля: `title`, `visible`, `userVisible`, `enabled`, `tooltip`, оформление, `width` / `maxWidth` / `autoMaxWidth` / `horizontalStretch` / `groupHorizontalAlign` и др.
|
||||
|
||||
## Таблица динамического списка
|
||||
|
||||
Когда `path` таблицы указывает на реквизит `type: "DynamicList"` (см. `references/dynamic-list.md`), доступен блок специфичных свойств. Указывайте **только отличия** от умолчания.
|
||||
|
||||
| Ключ | Тип | Умолчание | Назначение |
|
||||
|------|-----|-----------|-----------|
|
||||
| `rowPictureDataPath` | string | картинка осн. таблицы | Путь к картинке строки. `""` — подавить картинку |
|
||||
| `rowsPicture` | string | — | Картинка строк (`"CommonPicture.X"`) |
|
||||
| `autoRefresh` | bool | `false` | Автообновление списка |
|
||||
| `autoRefreshPeriod` | int | `60` | Период автообновления, сек |
|
||||
| `updateOnDataChange` | string | `Auto` | Обновлять при изменении данных: `Auto` / `DontUpdate` |
|
||||
| `choiceFoldersAndItems` | string | `Items` | Что выбирать: `Items` / `Folders` / `FoldersAndItems` |
|
||||
| `restoreCurrentRow` | bool | `false` | Восстанавливать текущую строку при обновлении |
|
||||
| `showRoot` | bool | `true` | Показывать корень |
|
||||
| `allowRootChoice` | bool | `false` | Разрешить выбор корня |
|
||||
| `allowGettingCurrentRowURL` | bool | `true` | Разрешить получение URL текущей строки |
|
||||
| `userSettingsGroup` | string | — | Группа пользовательских настроек (привязка к одноимённой группе настроек) |
|
||||
|
||||
```json
|
||||
{ "table": "Список", "path": "Список",
|
||||
"representation": "Tree",
|
||||
"rowPictureDataPath": "Список.DefaultPicture",
|
||||
"choiceFoldersAndItems": "FoldersAndItems",
|
||||
"allowRootChoice": true,
|
||||
"updateOnDataChange": "DontUpdate" }
|
||||
```
|
||||
@@ -1,77 +0,0 @@
|
||||
# Продвинутые конструкции типов
|
||||
|
||||
Примитивы (`string(n)`, `number(p,s)`, `boolean`, `date`/`dateTime`, …) и одиночные ссылки (`CatalogRef.Контрагенты`, `DocumentRef.Заказ`, `EnumRef.X`, …) описаны в основной инструкции. Здесь — типы, которые нельзя выразить одним именем: составные типы, наборы типов и платформенные наборы ссылок.
|
||||
|
||||
Любая из этих конструкций пишется в поле `type` реквизита, реквизита-параметра или поля.
|
||||
|
||||
## Составные типы
|
||||
|
||||
Несколько типов на одном реквизите — части перечисляются через разделитель `" | "` (можно `+`). Реквизит сможет принимать значение любого из перечисленных типов:
|
||||
|
||||
```json
|
||||
{ "name": "Плательщик",
|
||||
"type": "CatalogRef.Организации | CatalogRef.ИндивидуальныеПредприниматели" }
|
||||
```
|
||||
|
||||
Смешивать можно типы из разных категорий — ссылки, примитивы, наборы типов:
|
||||
|
||||
```json
|
||||
{ "name": "Источник",
|
||||
"type": "CatalogRef.Контрагенты | DocumentRef.Заказ | string(150)" }
|
||||
```
|
||||
|
||||
Каждая часть — самостоятельный токен из этого файла или из основной инструкции. Порядок частей произвольный.
|
||||
|
||||
## Наборы типов (TypeSet)
|
||||
|
||||
«Набор типов» подставляется вместо конкретного типа — это один токен, а не перечисление. Применимо и в составном типе как одна из частей.
|
||||
|
||||
| Токен `type` | Смысл |
|
||||
|------|-------|
|
||||
| `"DefinedType.ИмяТипа"` | определяемый тип конфигурации |
|
||||
| `"Characteristic.ИмяПлана"` | тип значения характеристики (по плану видов характеристик) |
|
||||
| `"AnyRef"` | любая ссылка |
|
||||
| `"AnyIBRef"` | любая ссылка информационной базы |
|
||||
|
||||
Определяемый тип — реквизит принимает то, что задано в определяемом типе конфигурации (например `DefinedType.ДенежнаяСумма`):
|
||||
|
||||
```json
|
||||
{ "name": "Сумма", "type": "DefinedType.ДенежнаяСумма" }
|
||||
```
|
||||
|
||||
Характеристика — тип значения берётся из плана видов характеристик:
|
||||
|
||||
```json
|
||||
{ "name": "Значение", "type": "Characteristic.ДополнительныеРеквизиты" }
|
||||
```
|
||||
|
||||
## Платформенные наборы ссылок
|
||||
|
||||
«Голый» ссылочный токен **без `.Имя`** означает «любая ссылка этой категории объектов»:
|
||||
|
||||
| Токен `type` | Смысл |
|
||||
|------|-------|
|
||||
| `"CatalogRef"` | любая ссылка справочника |
|
||||
| `"DocumentRef"` | любая ссылка документа |
|
||||
| `"EnumRef"` | любая ссылка перечисления |
|
||||
| `"ExchangePlanRef"` | любая ссылка плана обмена |
|
||||
| `"TaskRef"` | любая ссылка задачи |
|
||||
| `"BusinessProcessRef"` | любая ссылка бизнес-процесса |
|
||||
| `"ChartOfCharacteristicTypesRef"` | любая ссылка плана видов характеристик |
|
||||
| `"ChartOfAccountsRef"` | любая ссылка плана счетов |
|
||||
| `"ChartOfCalculationTypesRef"` | любая ссылка плана видов расчёта |
|
||||
|
||||
Различие с одиночной ссылкой — только в наличии `.Имя`:
|
||||
|
||||
- `"CatalogRef.Валюты"` — конкретный справочник «Валюты»;
|
||||
- `"CatalogRef"` — любой справочник.
|
||||
|
||||
```json
|
||||
{ "name": "ЛюбойСправочник", "type": "CatalogRef" }
|
||||
```
|
||||
|
||||
Эти наборы тоже комбинируются в составном типе:
|
||||
|
||||
```json
|
||||
{ "name": "Объект", "type": "CatalogRef | DocumentRef" }
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: form-decompile
|
||||
description: Декомпиляция управляемой формы 1С (Form.xml) в JSON-черновик в формате form-compile. Используй для scaffold новой формы по образцу или структурного рефакторинга. Не для точечных правок
|
||||
argument-hint: <FormPath> [-OutputPath <out.json>]
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /form-decompile — JSON-черновик из Form.xml управляемой формы
|
||||
|
||||
Читает Form.xml и эмитит компактный JSON в формате `form-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- **Scaffold новой формы по образцу** — взять существующую форму, получить JSON, поправить и скомпилировать в новую.
|
||||
- **Структурный рефакторинг** — перебрать дерево элементов, реквизиты, команды.
|
||||
|
||||
## Когда **не** использовать
|
||||
|
||||
- **Точечные правки готовой формы** (добавить элемент, реквизит, команду) → `/form-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает форму целиком, может терять непокрытые конструкции и даёт большой diff. `/form-edit` правит адресно.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `FormPath` | Путь к Form.xml (обязательный) |
|
||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/form-decompile/scripts/form-decompile.py" -FormPath "<Form.xml>" -OutputPath "<out.json>"
|
||||
```
|
||||
|
||||
## Что получаешь
|
||||
|
||||
JSON-черновик в формате `/form-compile` — **не полное обратимое представление**: раундтрип `xml → json → xml` не гарантируется, часть конструкций DSL не покрывает и **теряет молча**.
|
||||
|
||||
Критичные конструкции (`ConditionalAppearance` со scope, design-time диаграммы/планировщики на реквизите, неизвестный тип элемента, не-Form root) → скрипт падает с ненулевым кодом и сообщением в stderr; для правок такой формы — `/form-edit`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `/form-decompile <Form.xml> -OutputPath draft.json` — получить черновик.
|
||||
2. Поправить JSON под задачу.
|
||||
3. `/form-compile -JsonPath draft.json -OutputPath new/Form.xml` — собрать обратно.
|
||||
4. `/form-validate` + `/form-info` — проверить результат.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,272 +0,0 @@
|
||||
# help-add v1.9 — Add built-in help to 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ObjectName,
|
||||
|
||||
[string]$Lang = "ru",
|
||||
|
||||
[string]$SrcDir = "src"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
# --- Detect format version ---
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
$head = $content.Substring(0, [Math]::Min(2000, $content.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"
|
||||
}
|
||||
|
||||
$formatVersion = Detect-FormatVersion (Resolve-Path $SrcDir).Path
|
||||
|
||||
# --- Проверки ---
|
||||
|
||||
$objectDir = Join-Path $SrcDir $ObjectName
|
||||
$extDir = Join-Path $objectDir "Ext"
|
||||
|
||||
if (-not (Test-Path $extDir)) {
|
||||
Write-Error "Каталог объекта не найден: $extDir. Проверьте путь ObjectName (например Catalogs/МойСправочник)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$helpXmlPath = Join-Path $extDir "Help.xml"
|
||||
if (Test-Path $helpXmlPath) {
|
||||
Write-Error "Справка уже существует: $helpXmlPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Assert-EditAllowed $objectDir 'editable'
|
||||
|
||||
# --- Кодировка ---
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# --- 1. Help.xml ---
|
||||
|
||||
$helpXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Help xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<Page>$Lang</Page>
|
||||
</Help>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
$helpDir = Join-Path $extDir "Help"
|
||||
New-Item -ItemType Directory -Path $helpDir -Force | Out-Null
|
||||
|
||||
$helpHtmlPath = Join-Path $helpDir "$Lang.html"
|
||||
|
||||
$helpHtml = @"
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<link rel="stylesheet" type="text/css" href="v8help://service_book/service_style"/>
|
||||
</head>
|
||||
<body>
|
||||
<h1>$ObjectName</h1>
|
||||
<p>Описание.</p>
|
||||
</body>
|
||||
</html>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($helpHtmlPath, $helpHtml, $encBom)
|
||||
|
||||
# --- 3. Проверка IncludeHelpInContents в метаданных форм ---
|
||||
|
||||
$formsDir = Join-Path $objectDir "Forms"
|
||||
if (Test-Path $formsDir) {
|
||||
$formMetaFiles = Get-ChildItem -Path $formsDir -Filter "*.xml" -File
|
||||
foreach ($formMeta in $formMetaFiles) {
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.Load($formMeta.FullName)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
$includeHelp = $xmlDoc.SelectSingleNode("//md:IncludeHelpInContents", $nsMgr)
|
||||
if (-not $includeHelp) {
|
||||
# Добавить после <FormType>
|
||||
$formType = $xmlDoc.SelectSingleNode("//md:FormType", $nsMgr)
|
||||
if ($formType) {
|
||||
$newElem = $xmlDoc.CreateElement("IncludeHelpInContents", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$newElem.InnerText = "false"
|
||||
$parent = $formType.ParentNode
|
||||
$nextSibling = $formType.NextSibling
|
||||
# Вставить перенос + табуляцию + элемент
|
||||
$ws = $xmlDoc.CreateWhitespace("`n`t`t`t")
|
||||
if ($nextSibling) {
|
||||
$parent.InsertBefore($ws, $nextSibling) | Out-Null
|
||||
$parent.InsertBefore($newElem, $ws) | Out-Null
|
||||
} else {
|
||||
$parent.AppendChild($ws) | Out-Null
|
||||
$parent.AppendChild($newElem) | Out-Null
|
||||
}
|
||||
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
|
||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[OK] Создана справка: $ObjectName"
|
||||
Write-Host " Метаданные: $helpXmlPath"
|
||||
Write-Host " Страница: $helpHtmlPath"
|
||||
@@ -1,381 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-help v1.9 — Add built-in help to 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
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 _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Add built-in help to 1C object", allow_abbrev=False)
|
||||
parser.add_argument("-ObjectName", required=True)
|
||||
parser.add_argument("-Lang", default="ru")
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
args = parser.parse_args()
|
||||
|
||||
object_name = args.ObjectName
|
||||
lang = args.Lang
|
||||
src_dir = args.SrcDir
|
||||
|
||||
format_version = detect_format_version(os.path.abspath(src_dir))
|
||||
|
||||
# --- Checks ---
|
||||
|
||||
object_dir = os.path.join(src_dir, object_name)
|
||||
ext_dir = os.path.join(object_dir, "Ext")
|
||||
|
||||
if not os.path.isdir(ext_dir):
|
||||
print(f"Каталог объекта не найден: {ext_dir}. Проверьте путь ObjectName (например Catalogs/МойСправочник).", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
help_xml_path = os.path.join(ext_dir, "Help.xml")
|
||||
if os.path.exists(help_xml_path):
|
||||
print(f"Справка уже существует: {help_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
assert_edit_allowed(object_dir, "editable")
|
||||
|
||||
# --- 1. Help.xml ---
|
||||
|
||||
help_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<Help xmlns="http://v8.1c.ru/8.3/xcf/extrnprops"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
f' version="{format_version}">\n'
|
||||
f'\t<Page>{lang}</Page>\n'
|
||||
'</Help>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_xml_path, help_xml)
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
help_dir = os.path.join(ext_dir, "Help")
|
||||
os.makedirs(help_dir, exist_ok=True)
|
||||
|
||||
help_html_path = os.path.join(help_dir, f"{lang}.html")
|
||||
|
||||
help_html = (
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">\n'
|
||||
'<html>\n'
|
||||
'<head>\n'
|
||||
' <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>\n'
|
||||
' <link rel="stylesheet" type="text/css" href="v8help://service_book/service_style"/>\n'
|
||||
'</head>\n'
|
||||
'<body>\n'
|
||||
f' <h1>{object_name}</h1>\n'
|
||||
' <p>Описание.</p>\n'
|
||||
'</body>\n'
|
||||
'</html>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_html_path, help_html)
|
||||
|
||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
||||
|
||||
forms_dir = os.path.join(object_dir, "Forms")
|
||||
if os.path.isdir(forms_dir):
|
||||
for entry in os.listdir(forms_dir):
|
||||
if not entry.endswith(".xml"):
|
||||
continue
|
||||
form_meta_full = os.path.join(forms_dir, entry)
|
||||
if not os.path.isfile(form_meta_full):
|
||||
continue
|
||||
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
form_tree = etree.parse(form_meta_full, parser_xml)
|
||||
form_root = form_tree.getroot()
|
||||
|
||||
include_help = form_root.find(".//md:IncludeHelpInContents", NSMAP)
|
||||
if include_help is not None:
|
||||
continue
|
||||
|
||||
# Add after <FormType>
|
||||
form_type = form_root.find(".//md:FormType", NSMAP)
|
||||
if form_type is None:
|
||||
continue
|
||||
|
||||
parent = form_type.getparent()
|
||||
ns = "http://v8.1c.ru/8.3/MDClasses"
|
||||
new_elem = etree.SubElement(parent, f"{{{ns}}}IncludeHelpInContents")
|
||||
new_elem.text = "false"
|
||||
# Remove SubElement's auto-placement (it appends to end) and insert after FormType
|
||||
parent.remove(new_elem)
|
||||
|
||||
# Find index of FormType in parent
|
||||
form_type_idx = list(parent).index(form_type)
|
||||
|
||||
# Insert after FormType
|
||||
parent.insert(form_type_idx + 1, new_elem)
|
||||
|
||||
# Whitespace handling: copy FormType's tail as new_elem's tail,
|
||||
# and set FormType's tail to include newline + indent
|
||||
new_elem.tail = form_type.tail
|
||||
form_type.tail = "\n\t\t\t"
|
||||
|
||||
save_xml_with_bom(form_tree, form_meta_full)
|
||||
|
||||
print(f" IncludeHelpInContents добавлен: {entry}")
|
||||
|
||||
print(f"[OK] Создана справка: {object_name}")
|
||||
print(f" Метаданные: {help_xml_path}")
|
||||
print(f" Страница: {help_html_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,138 +0,0 @@
|
||||
---
|
||||
name: meta-compile
|
||||
description: Создать объект метаданных 1С. Используй когда нужно создать или добавить справочник, документ, регистр, перечисление, константу, общий модуль, обработку, отчёт и др.
|
||||
argument-hint: <JsonPath> <OutputDir>
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /meta-compile — генерация объектов метаданных из JSON
|
||||
|
||||
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
||||
регистрирует объект в `Configuration.xml`.
|
||||
|
||||
`ConfigDumpInfo.xml` намеренно не трогается: это служебный файл версий объектов, которым управляет
|
||||
платформа (для инкрементальной выгрузки).
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
||||
2. Запусти скрипт.
|
||||
3. Изменить созданный объект — `/meta-edit`; проверить — `/meta-validate`.
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/meta-compile/scripts/meta-compile.py" -JsonPath "<json>" -OutputDir "<ConfigDir>"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `JsonPath` | Путь к JSON-файлу |
|
||||
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/`, …) |
|
||||
|
||||
## Формат JSON
|
||||
|
||||
**Один объект** `{ ... }` или **массив** объектов `[{ ... }, { ... }]` (batch — несколько объектов за прогон).
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Номенклатура", "...свойства типа...": "..." }
|
||||
```
|
||||
|
||||
`type` и `name` — обязательные. Остальное — по типу (см. индекс ниже). `synonym` по умолчанию выводится из
|
||||
`name` (CamelCase → слова через пробел); можно задать явно строкой или мультиязычно: `"synonym": { "ru": "…", "en": "…" }`.
|
||||
|
||||
## Реквизиты (shorthand)
|
||||
|
||||
Массивы `attributes`, `dimensions`, `resources` и колонки в `tabularSections` задаются строками:
|
||||
|
||||
```
|
||||
"Имя" → String(10)
|
||||
"Имя: Тип" → с типом
|
||||
"Имя: Тип | req, index" → с флагами
|
||||
```
|
||||
|
||||
**Типы:** `String(100)`, `String(10, fixed)` (фикс. длина), `Number(15,2)`, `Boolean`, `Date`, `DateTime`,
|
||||
`Time`, ссылочные `CatalogRef.Xxx` / `DocumentRef.Xxx` / `EnumRef.Xxx` / `DefinedType.Xxx` и т.п.
|
||||
Составной тип — через `+`: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
||||
|
||||
**Флаги** (после `|`, через запятую):
|
||||
|
||||
| Флаг | Значение | Где |
|
||||
|------|----------|-----|
|
||||
| `req` | обязательное заполнение | attributes, dimensions, resources |
|
||||
| `index` | индексировать | attributes, dimensions |
|
||||
| `indexAdditional` | индекс с доп. упорядочиванием | attributes |
|
||||
| `multiline` | многострочное поле | attributes |
|
||||
| `nonneg` | неотрицательное (Number) | attributes, resources |
|
||||
| `master` | ведущее измерение | dimensions (регистры) |
|
||||
| `mainFilter` | основной отбор | dimensions (регистры) |
|
||||
| `denyIncomplete` | запрет незаполненных | dimensions |
|
||||
| `useInTotals` | использовать в итогах | dimensions (регистр накопления) |
|
||||
|
||||
Реквизиту нужны свойства сверх shorthand (значение заполнения, параметры выбора, формат, подсказка, …) —
|
||||
задаётся **объектной формой**, см. `reference/attributes.md`.
|
||||
|
||||
## Табличные части
|
||||
|
||||
```json
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
||||
```
|
||||
|
||||
Ключ — имя ТЧ, значение — массив колонок (shorthand) ЛИБО объект со свойствами ТЧ (см. `reference/attributes.md`).
|
||||
|
||||
## Индекс: свойства по типам
|
||||
|
||||
Для каждого типа — свой reference-файл со свойствами, дефолтами и допустимыми значениями:
|
||||
|
||||
| Тип(ы) | Файл |
|
||||
|--------|------|
|
||||
| Catalog (справочник) | `reference/catalog.md` |
|
||||
| Document, DocumentJournal, Sequence, DocumentNumerator | `reference/document.md` |
|
||||
| InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister | `reference/registers.md` |
|
||||
| ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes | `reference/charts.md` |
|
||||
| ExchangePlan | `reference/exchangeplan.md` |
|
||||
| BusinessProcess, Task | `reference/process.md` |
|
||||
| Report, DataProcessor | `reference/report-dataprocessor.md` |
|
||||
| CommonModule, ScheduledJob, EventSubscription | `reference/code.md` |
|
||||
| HTTPService, WebService | `reference/web.md` |
|
||||
| Enum, Constant, DefinedType | `reference/simple.md` |
|
||||
| FunctionalOption, FilterCriterion, SettingsStorage, CommonForm, CommonPicture, CommonTemplate, служебные | `reference/other-types.md` |
|
||||
|
||||
Кросс-типовые детали:
|
||||
- **`reference/attributes.md`** — объектная форма реквизита и колонки ТЧ (значение заполнения, параметры
|
||||
выбора, формат, подсказка, границы, …) + свойства самой ТЧ.
|
||||
- **`reference/blocks.md`** — блоки объекта: представления, команды (+ характеристики/стандартные реквизиты).
|
||||
|
||||
Эта инструкция и reference-файлы — полная документация. Не ищи примеры XML в выгрузках конфигураций.
|
||||
|
||||
## Примеры
|
||||
|
||||
Справочник с реквизитами:
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"] }
|
||||
```
|
||||
|
||||
Документ с движениями и ТЧ:
|
||||
```json
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] } }
|
||||
```
|
||||
|
||||
Регистр сведений:
|
||||
```json
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
Batch:
|
||||
```json
|
||||
[ { "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
||||
{ "type": "Catalog", "name": "Валюты" },
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" } ]
|
||||
```
|
||||
@@ -1,140 +0,0 @@
|
||||
# Объектная форма реквизита и табличной части
|
||||
|
||||
Когда реквизиту (в `attributes` / `dimensions` / `resources` / колонках ТЧ) нужны свойства сверх
|
||||
shorthand — вместо строки задаётся объект:
|
||||
|
||||
```json
|
||||
{ "name": "Цена", "type": "Number(15,2)", "tooltip": "Цена за единицу", "fillValue": 0 }
|
||||
```
|
||||
|
||||
`name` и `type` обязательны (тип можно задать и раздельно: `"type": "Number", "length": 15, "precision": 2`).
|
||||
Остальные ключи — ниже, все со значением по умолчанию (не задавать, если устраивает дефолт).
|
||||
|
||||
## Свойства реквизита
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML (строка или `{ru,en}`) |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (то же, что флаг `req`) |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fillFromFillingValue` | `false` | bool |
|
||||
| `fillValue` | по типу (см. ниже) | значение заполнения |
|
||||
| `createOnInput` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `quickChoice` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `dataHistory` | `Use` | `Use` / `DontUse` |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (реквизит иерархического справочника) |
|
||||
| `passwordMode` | `false` | bool |
|
||||
| `multiLine` | `false` | bool (то же, что флаг `multiline`) |
|
||||
| `extendedEdit` | `false` | bool (расширенное редактирование — многострочный ввод) |
|
||||
| `mask` | пусто | строка маски ввода |
|
||||
| `format` / `editFormat` | пусто | форматная строка 1С (ML) |
|
||||
| `markNegatives` | `false` | bool (выделять отрицательные, для Number) |
|
||||
| `minValue` / `maxValue` | не задано | граница диапазона (см. ниже) |
|
||||
| `choiceParameterLinks` | пусто | связи параметров выбора (см. ниже) |
|
||||
| `choiceParameters` | пусто | параметры выбора (см. ниже) |
|
||||
| `choiceForm` | пусто | ссылка на форму выбора `Тип.Объект.Form.ИмяФормы` |
|
||||
| `choiceFoldersAndItems` | `Items` | `Items` / `Folders` / `FoldersAndItems` (что выбирать в иерарх. справочнике) |
|
||||
|
||||
Индексирование задаётся флагом `index` / `indexAdditional` в shorthand, либо в объекте — как и в строковой форме,
|
||||
через `"type": "… | index"`.
|
||||
|
||||
### `fillValue` — значение заполнения
|
||||
|
||||
Пустое значение по типу компилятор подставляет сам — ключ **не задают**:
|
||||
|
||||
| Тип реквизита | Пустое значение |
|
||||
|---------------|-----------------|
|
||||
| String | пустая строка |
|
||||
| Number | `0` |
|
||||
| Boolean, Date, ссылочный, составной | не задано (nil) |
|
||||
|
||||
Ключ `fillValue` задают для **конкретного** значения — интерпретируется по типу реквизита:
|
||||
|
||||
- **Boolean** — `true` / `false`.
|
||||
- **Number** — число (`21`, `1.5`).
|
||||
- **String** — строка.
|
||||
- **Date** — ISO-строка `"2020-01-01T00:00:00"`.
|
||||
- **Ссылочный** — путь: `"Catalog.Валюты.EmptyRef"` (пустая ссылка), `"Enum.Периодичность.EnumValue.Месяц"`
|
||||
(значение перечисления), `"Catalog.СтраныМира.Россия"` (предопределённый элемент).
|
||||
- **`null`** — явно «значение не задано» (nil), когда нужно перекрыть непустой дефолт типа.
|
||||
- **`{ "emptyRef": true }`** — пустая ссылка для реквизита типа `DefinedType.X` (когда тип из пути не выводится).
|
||||
|
||||
> Пустая ссылка (`EmptyRef`) и `null` — разное: платформа хранит их отдельно.
|
||||
|
||||
### `minValue` / `maxValue` — границы диапазона
|
||||
|
||||
Число → числовая граница; строка → строковая (напр. год `"2000"`). Без ключа — граница не задана.
|
||||
|
||||
### `choiceParameterLinks` — связи параметров выбора
|
||||
|
||||
Связывают параметр выбора этого реквизита с другим реквизитом объекта. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameterLinks": ["Отбор.Организация=Организация", "Отбор.Договор=Договор:DontChange"]
|
||||
"choiceParameterLinks": [{ "name": "Отбор.Организация", "dataPath": "Организация", "valueChange": "Clear" }]
|
||||
```
|
||||
|
||||
- `dataPath` — реквизит **того же объекта**: имя обычного реквизита (`"Организация"`) или стандартного
|
||||
(`"Владелец"`, `"Ссылка"`).
|
||||
- `valueChange` — `Clear` (по умолчанию) / `DontChange`.
|
||||
|
||||
### `choiceParameters` — параметры выбора
|
||||
|
||||
Фиксируют параметр выбора значением. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameters": ["Отбор.ЭтоГруппа=false"]
|
||||
"choiceParameters": [{ "name": "Отбор.Владелец", "value": "Catalog.Организации.EmptyRef" }]
|
||||
```
|
||||
|
||||
- `value` — bool / число / строка / ссылочный путь (несёт тип) ИЛИ массив (список фиксированных значений).
|
||||
- Для набора голых имён-значений добавьте `type` (тип поля-фильтра), чтобы они стали ссылками:
|
||||
`{ "name": "Отбор.Тип", "type": "EnumRef.ТипыВЕТИС", "value": ["EmptyRef", "ТТН"] }`.
|
||||
|
||||
### Редкие ключи
|
||||
|
||||
`linkByType` — связь по типу (тип реквизита-Характеристики берётся из другого реквизита):
|
||||
`{ "dataPath": "Свойство", "linkItem": 0 }` или строка-путь. Применяется для реквизитов-характеристик.
|
||||
|
||||
---
|
||||
|
||||
## Табличная часть — объектная форма
|
||||
|
||||
Значение в `tabularSections` — массив колонок ЛИБО объект со свойствами самой ТЧ:
|
||||
|
||||
```json
|
||||
"tabularSections": {
|
||||
"Товары": {
|
||||
"synonym": { "ru": "Товары", "en": "Goods" },
|
||||
"tooltip": "Строки заказа",
|
||||
"fillChecking": "ShowError",
|
||||
"attributes": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (обязательность заполнения ТЧ) |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
||||
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
||||
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||
| `lineNumberLength` | по режиму совместимости | `5`…`9` — разрядность номера строки: `5` → до 99 999 строк, `9` → до 999 999 999. Требует формата 2.20 (платформа 8.3.27) |
|
||||
|
||||
### `lineNumber` — стандартный реквизит НомерСтроки
|
||||
|
||||
У каждой ТЧ есть стандартный реквизит НомерСтроки. По умолчанию все его свойства типовые. Ключ `lineNumber`
|
||||
на объектной форме ТЧ их переопределяет:
|
||||
|
||||
```json
|
||||
"Строки": { "lineNumber": { "synonym": "Номер п/п", "fullTextSearch": "DontUse" }, "attributes": [...] }
|
||||
```
|
||||
|
||||
Переопределяемые: `synonym`, `comment`, `fullTextSearch` (`Use`/`DontUse`), `tooltip`, `format`, `editFormat`,
|
||||
`choiceHistoryOnInput` (`Auto`/`DontUse`).
|
||||
@@ -1,109 +0,0 @@
|
||||
# Блоки объекта
|
||||
|
||||
Кросс-типовые блоки уровня объекта (применимы к ссылочным типам — Catalog, Document, ChartOf*, ExchangePlan,
|
||||
BusinessProcess, Task и др.).
|
||||
|
||||
## Представления
|
||||
|
||||
Тексты представления объекта в интерфейсе (ML — строка или `{ru,en}`, по умолчанию пусто):
|
||||
|
||||
| Ключ | Смысл |
|
||||
|------|-------|
|
||||
| `objectPresentation` | представление объекта |
|
||||
| `extendedObjectPresentation` | расширенное представление объекта |
|
||||
| `listPresentation` | представление списка |
|
||||
| `extendedListPresentation` | расширенное представление списка |
|
||||
| `explanation` | пояснение |
|
||||
|
||||
Набор доступных ключей зависит от типа (у списочных без формы объекта нет `objectPresentation` и т.п.).
|
||||
|
||||
```json
|
||||
"listPresentation": "Организации", "objectPresentation": { "ru": "Организация", "en": "Company" }
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
Команды объекта. Ключ — имя команды, значение — объект свойств (map `имя → объект` или массив `[{name, …}]`).
|
||||
Для каждой команды создаётся заготовка модуля с обработчиком `ОбработкаКоманды`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `group` | **обязательно** | группа размещения (см. ниже) |
|
||||
| `commandParameterType` | пусто | тип параметра (напр. `CatalogRef.Номенклатура`) — **только для групп формы** |
|
||||
| `parameterUseMode` | `Single` | `Single` / `Multiple` |
|
||||
| `modifiesData` | `false` | bool |
|
||||
| `representation` | `Auto` | вид отображения |
|
||||
| `picture` | пусто | ссылка на картинку (`StdPicture.Print`, `CommonPicture.Загрузка`) |
|
||||
| `shortcut` | пусто | сочетание клавиш |
|
||||
|
||||
```json
|
||||
"commands": {
|
||||
"ПечатьЭтикеток": { "synonym": "Печать этикеток", "group": "FormCommandBarImportant",
|
||||
"commandParameterType": "CatalogRef.Номенклатура", "picture": "StdPicture.Print" }
|
||||
}
|
||||
```
|
||||
|
||||
**Группа (`group`) обязательна** — каждая команда размещается в группе командного интерфейса:
|
||||
|
||||
- **Командный интерфейс раздела** (панель навигации / панель действий; `commandParameterType` **недоступен**):
|
||||
`NavigationPanelImportant` / `NavigationPanelOrdinary` / `NavigationPanelSeeAlso`,
|
||||
`ActionsPanelCreate` / `ActionsPanelReports` / `ActionsPanelTools`.
|
||||
- **Командный интерфейс формы** (`commandParameterType` допустим): `FormCommandBarImportant` /
|
||||
`FormCommandBarCreateBasedOn`, `FormNavigationPanelImportant` / `FormNavigationPanelGoTo` / `FormNavigationPanelSeeAlso`.
|
||||
- **Кастомная группа:** `CommandGroup.<Имя>` (параметр допустим).
|
||||
|
||||
Группа раздела вместе с `commandParameterType` → ошибка.
|
||||
|
||||
## `inputByString` / `dataLockFields` / `basedOn`
|
||||
|
||||
Списки полей/объектов уровня объекта. Поля — по имени реквизита объекта (обычного или стандартного).
|
||||
|
||||
- **`inputByString`** — поля быстрого ввода по строке. По умолчанию выводятся из Кода/Наименования — ключ не нужен;
|
||||
задать при другом наборе/порядке, либо `[]` для отключения.
|
||||
```json
|
||||
"inputByString": ["Код", "Наименование", "Контрагент"]
|
||||
```
|
||||
- **`dataLockFields`** — поля управляемой блокировки данных (по умолчанию пусто).
|
||||
```json
|
||||
"dataLockFields": ["Организация", "Контрагент"]
|
||||
```
|
||||
- **`basedOn`** — «ввод на основании»: список ссылок на объекты метаданных (по умолчанию пусто).
|
||||
```json
|
||||
"basedOn": ["Catalog.Контрагенты", "Document.ЗаказПоставщику"]
|
||||
```
|
||||
|
||||
## `standardAttributes` — кастомизация стандартных реквизитов
|
||||
|
||||
Стандартные реквизиты объекта (Наименование, Код, Владелец, …) переопределяются блоком
|
||||
`standardAttributes` — объект `{ ИмяРеквизита: { переопределения } }`. Имена — как в 1С: `Description`, `Code`,
|
||||
`Owner`, `Parent`, `DeletionMark`, `Ref` и т.д. (для Document — `Date`, `Number`, `Posted`).
|
||||
|
||||
Переопределяемые поля — как у обычного реквизита (`synonym`, `tooltip`, `fillChecking`, `fillValue`,
|
||||
`choiceParameters`, `comment`, `mask`, `choiceForm`; полный набор — `attributes.md`).
|
||||
|
||||
```json
|
||||
"standardAttributes": {
|
||||
"Description": { "synonym": "Наименование контрагента" },
|
||||
"Code": { "fillChecking": "ShowError" }
|
||||
}
|
||||
```
|
||||
|
||||
## `characteristics` — «Дополнительные реквизиты и сведения»
|
||||
|
||||
Привязка плана видов характеристик. Массив; каждый элемент связывает **источник типов** (где определены
|
||||
характеристики) и **источник значений** (где хранятся значения).
|
||||
|
||||
```json
|
||||
"characteristics": [{
|
||||
"types": { "from": "Catalog.НаборыДопРеквизитов.ДополнительныеРеквизиты",
|
||||
"key": "Свойство", "filterField": "Ссылка", "filterValue": "Справочник_Организации" },
|
||||
"values": { "from": "Catalog.Организации.TabularSection.ДополнительныеРеквизиты",
|
||||
"object": "Ссылка", "type": "Свойство", "value": "Значение" }
|
||||
}]
|
||||
```
|
||||
|
||||
- `from` — таблица-источник; `key`/`filterField`/`object`/`type`/`value` — поля источника (по имени реквизита).
|
||||
- `filterValue` — значение фильтра типов: имя предопределённого набора (строка) или путь к элементу.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Catalog (Справочник)
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
||||
```
|
||||
|
||||
## Свойства
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `hierarchyType` | `HierarchyFoldersAndItems` | `HierarchyFoldersAndItems` / `HierarchyOfItems` |
|
||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||
| `owners` | `[]` | массив владельцев: `["Catalog.Контрагенты"]` |
|
||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||
| `codeType` | `String` | `String` / `Number` |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `codeSeries` | `WholeCatalog` | `WholeCatalog` / `WithinSubordination` / `WithinOwnerSubordination` |
|
||||
| `autonumbering` | `true` | bool (автонумерация) |
|
||||
| `checkUnique` | `false` | bool (контроль уникальности кода) |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `defaultPresentation` | `AsDescription` | `AsDescription` / `AsCode` |
|
||||
| `quickChoice` | `true` | bool (быстрый выбор) |
|
||||
| `choiceMode` | `BothWays` | `BothWays` / `QuickChoice` / `FromForm` |
|
||||
| `editType` | `InDialog` | `InDialog` / `InList` / `BothWays` |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fullTextSearchOnInputByString` | `DontUse` | `Use` / `DontUse` |
|
||||
| `searchStringModeOnInputByString` | `Begin` | `Begin` / `AnyPart` |
|
||||
| `predefinedDataUpdate` | `Auto` | `Auto` / `DontAutoUpdate` / `AutoUpdate` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` | `[]` | реквизиты (shorthand / объектная форма) |
|
||||
| `tabularSections` | `{}` | табличные части |
|
||||
|
||||
**Формы.** Ссылка на форму — `Тип.Объект.Form.ИмяФормы` (напр. `Catalog.Организации.Form.ФормаЭлемента`).
|
||||
Слоты основных форм: `defaultObjectForm`, `defaultFolderForm`, `defaultListForm`, `defaultChoiceForm`,
|
||||
`defaultFolderChoiceForm`; вспомогательных — те же имена с префиксом `auxiliary` (`auxiliaryObjectForm`, …).
|
||||
|
||||
## `predefined` — предопределённые элементы
|
||||
|
||||
Массив предопределённых элементов → `Ext/Predefined.xml`. Элемент — строка (плоский случай) или объект (иерархия).
|
||||
|
||||
**Строка:** `"(Код) Имя [Наименование]"` — `Имя` обязательно; `(Код)` и `[Наименование]` опциональны.
|
||||
Без `[...]` наименование выводится из имени; `[]` — пустое; `[текст]` — заданное.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"Основной",
|
||||
"(1) ДокументОПриемке [Документ о приемке]",
|
||||
{ "name": "Группа1", "isFolder": true, "description": "Прочие",
|
||||
"childItems": ["Факс", "(7) Скайп"] }
|
||||
]
|
||||
```
|
||||
|
||||
**Объект:** `name` (обязательно), `code`, `description` (наименование), `isFolder` (признак группы),
|
||||
`childItems` (вложенные, рекурсивно). Тип кода — по свойству `codeType`.
|
||||
|
||||
## Дополнительно
|
||||
|
||||
- Свойства реквизитов и табличных частей — `attributes.md`.
|
||||
- Представления (`objectPresentation`, `listPresentation`, …), команды объекта, характеристики
|
||||
(«ДопРеквизиты и сведения»), кастомизация стандартных реквизитов, `inputByString` / `dataLockFields` /
|
||||
`basedOn` — `blocks.md`.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Планы: ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes
|
||||
|
||||
Все три — ссылочные типы (наследуют слой Catalog: коды, `standardAttributes`, `characteristics`, `inputByString`,
|
||||
формы, представления — см. `catalog.md` / `attributes.md` / `blocks.md`) с предопределёнными элементами и своими
|
||||
специальными свойствами.
|
||||
|
||||
## ChartOfCharacteristicTypes (План видов характеристик)
|
||||
|
||||
Хранит определения характеристик (видов). Иерархический (папки+элементы).
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | любой примитив | тип значения характеристики (составной — строка `"A + B"` или массив `valueTypes`) |
|
||||
| `characteristicExtValues` | пусто | ссылка на справочник доп. значений |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `foldersOnTop` | `true` | bool |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `codeSeries` | `WholeCharacteristicKind` | серия кодов |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды (несут тип значения — см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Предопределённые виды** несут **тип значения на элемент** — короткой строкой после `:`
|
||||
(`"(Код) Имя [Наименование]: Тип"`, составной через `+`) или объектной формой с ключом `type`:
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"(000001) Цвет: CatalogRef.Цвета",
|
||||
"(000002) Размер [Размер одежды]: String(50) + Number(3,0)",
|
||||
{ "name": "Группа", "isFolder": true, "type": "" }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfAccounts (План счетов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `extDimensionTypes` | пусто | ссылка на ПВХ видов субконто `ChartOfCharacteristicTypes.X` |
|
||||
| `maxExtDimensionCount` | `0` (без ПВХ) / `3` (с ПВХ) | макс. число субконто |
|
||||
| `codeMask` | пусто | маска кода счёта (напр. `"@@@.@@"`) |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `codeSeries` | `WholeChartOfAccounts` | серия кодов |
|
||||
| `defaultPresentation` | `AsCode` | `AsCode` / `AsDescription` |
|
||||
| `autoOrderByCode` | `true` | bool |
|
||||
| `orderLength` | `9` | длина строки упорядочивания |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `accountingFlags` | `[]` | признаки учёта (как реквизиты, тип по умолчанию Boolean; массив имён/реквизитов) |
|
||||
| `extDimensionAccountingFlags` | `[]` | признаки учёта субконто (как реквизиты) |
|
||||
| `predefined` | `[]` | предопределённые счета (см. ниже) |
|
||||
|
||||
**Предопределённый счёт** (объектная форма):
|
||||
|
||||
| Поле | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `name` | — | имя (обязательно) |
|
||||
| `code` | пусто | код счёта |
|
||||
| `description` | из имени | наименование |
|
||||
| `accountType` | `ActivePassive` | `Active` / `Passive` / `ActivePassive` |
|
||||
| `offBalance` | `false` | bool (забалансовый) |
|
||||
| `order` | — | строка сортировки |
|
||||
| `flags` | `[]` | включённые признаки учёта (только TRUE) |
|
||||
| `subconto` | `[]` | виды субконто (см. ниже) |
|
||||
| `childItems` | `[]` | подчинённые счета |
|
||||
|
||||
`subconto` — строка `"Вид | Признак1, Признак2"` (после `|` — включённые признаки учёта субконто; токен `Turnover` —
|
||||
«только обороты») или объект `{ type, turnover, flags }`. `Вид` — имя предопределённого вида из ПВХ `extDimensionTypes`.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
{ "name": "ОсновныеСредства", "code": "01", "accountType": "Active", "order": " 01",
|
||||
"flags": ["Количественный"], "subconto": ["Номенклатура | Суммовой, Валютный"],
|
||||
"childItems": [ { "name": "ОСВОрганизации", "code": "01.01", "accountType": "Active", "order": " 01.01" } ] }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfCalculationTypes (План видов расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `codeLength` | `5` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `dependenceOnCalculationTypes` | `DontUse` | `DontUse` / `OnPeriod` / `OnActionPeriod` |
|
||||
| `baseCalculationTypes` | `[]` | базовые виды расчёта (список ссылок `ChartOfCalculationTypes.X`) |
|
||||
| `actionPeriodUse` | `false` | bool (использовать период действия) |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды расчёта (см. ниже) |
|
||||
|
||||
**Предопределённый вид расчёта** — плоский: строка `"(Код) Имя [Наименование]"` или объект
|
||||
`{ name, code, description, actionPeriodIsBase }` (`actionPeriodIsBase` — bool, по умолчанию `false`).
|
||||
|
||||
```json
|
||||
"predefined": [ "(00001) Оклад [Оклад по дням]", { "name": "Премия", "code": "00002", "actionPeriodIsBase": true } ]
|
||||
```
|
||||
|
||||
> **ChartOfAccounts** ссылается на ПВХ через `extDimensionTypes`. Регистр бухгалтерии/расчёта требует
|
||||
> соответствующий план (см. `registers.md`).
|
||||
@@ -1,56 +0,0 @@
|
||||
# CommonModule, ScheduledJob, EventSubscription (объекты, привязанные к коду)
|
||||
|
||||
## CommonModule (Общий модуль)
|
||||
|
||||
Флаги контекста выполнения (все bool, по умолчанию `false`). Создаёт пустой `Ext/Module.bsl`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `context` | — | шорткат флагов (см. ниже) |
|
||||
| `global` | `false` | bool |
|
||||
| `server` | `false` | bool |
|
||||
| `serverCall` | `false` | bool (вызов сервера) |
|
||||
| `clientManagedApplication` | `false` | bool (клиент управляемого приложения) |
|
||||
| `clientOrdinaryApplication` | `false` | bool (клиент обычного приложения) |
|
||||
| `externalConnection` | `false` | bool |
|
||||
| `privileged` | `false` | bool |
|
||||
| `returnValuesReuse` | `DontUse` | `DontUse` / `DuringRequest` / `DuringSession` |
|
||||
|
||||
Шорткат `context`: `"server"` → Server+ServerCall; `"client"` → ClientManagedApplication;
|
||||
`"serverClient"` → Server+ClientManagedApplication.
|
||||
|
||||
```json
|
||||
{ "type": "CommonModule", "name": "ОбменДаннымиСервер", "context": "server", "returnValuesReuse": "DuringRequest" }
|
||||
```
|
||||
|
||||
## ScheduledJob (Регламентное задание)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `methodName` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
| `description` | пусто | наименование задания |
|
||||
| `key` | пусто | ключ |
|
||||
| `use` | `false` | bool (использование) |
|
||||
| `predefined` | `false` | bool (предопределённое) |
|
||||
| `restartCountOnFailure` | `3` | число повторов при сбое |
|
||||
| `restartIntervalOnFailure` | `10` | интервал повтора, сек |
|
||||
|
||||
```json
|
||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить", "use": true }
|
||||
```
|
||||
|
||||
## EventSubscription (Подписка на событие)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `source` | `[]` | объекты-источники: `["CatalogObject.Контрагенты", "DocumentObject.Реализация"]` |
|
||||
| `event` | `BeforeWrite` | `BeforeWrite` / `OnWrite` / `BeforeDelete` / `OnReadAtServer` / `FillCheckProcessing` … |
|
||||
| `handler` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
|
||||
```json
|
||||
{ "type": "EventSubscription", "name": "ПередЗаписьюКонтрагента",
|
||||
"source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite",
|
||||
"handler": "ОбщегоНазначенияСервер.ПередЗаписьюКонтрагента" }
|
||||
```
|
||||
|
||||
> Процедура-обработчик (`methodName` / `handler`) должна существовать в указанном общем модуле (экспортная).
|
||||
@@ -1,79 +0,0 @@
|
||||
# Document, DocumentJournal, Sequence, DocumentNumerator
|
||||
|
||||
## Document (Документ)
|
||||
|
||||
```json
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] } }
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `numerator` | пусто | ссылка на нумератор `DocumentNumerator.X` |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / `Month` / `Quarter` / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `posting` | `Allow` | `Allow` / `Deny` (проведение) |
|
||||
| `realTimePosting` | `Deny` | `Allow` / `Deny` (оперативное проведение) |
|
||||
| `registerRecordsDeletion` | `AutoDelete` | `AutoDelete` / `AutoDeleteOnUnpost` / `AutoDeleteOff` |
|
||||
| `registerRecordsWritingOnPost` | `WriteSelected` | `WriteModified` / `WriteSelected` / `WriteAll` |
|
||||
| `sequenceFilling` | `AutoFill` | заполнение последовательностей |
|
||||
| `postInPrivilegedMode` | `true` | bool |
|
||||
| `unpostInPrivilegedMode` | `true` | bool |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `registerRecords` | `[]` | движения: список ссылок `["AccumulationRegister.ОстаткиТоваров", "InformationRegister.Цены"]` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Формы: `defaultObjectForm`, `defaultListForm`, `defaultChoiceForm`, `auxiliary*` (см. `catalog.md`).
|
||||
Реквизиты и ТЧ — `attributes.md`. Представления, команды, характеристики, `basedOn`, `standardAttributes`,
|
||||
`inputByString`, `dataLockFields` — `blocks.md`.
|
||||
|
||||
## DocumentJournal (Журнал документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registeredDocuments` | `[]` | документы журнала: `["Document.Встреча", "Document.Звонок"]` |
|
||||
| `columns` | `[]` | графы журнала (см. ниже) |
|
||||
|
||||
Графа — строка `"Имя"` или объект `{ name, synonym, indexing, references }`, где `indexing` — `Index`/`DontIndex`,
|
||||
`references` — пути к реквизитам документов, отображаемым в графе.
|
||||
|
||||
```json
|
||||
{ "type": "DocumentJournal", "name": "Взаимодействия",
|
||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
||||
"columns": [{ "name": "Организация", "indexing": "Index",
|
||||
"references": ["Document.Встреча.Attribute.Организация"] }] }
|
||||
```
|
||||
|
||||
## Sequence (Последовательность документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `moveBoundaryOnPosting` | `DontMove` | сдвиг границы при проведении |
|
||||
| `documents` | `[]` | документы последовательности (список ссылок) |
|
||||
| `registerRecords` | `[]` | движения (список ссылок) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` | `[]` | измерения `{name, type, documentMap[], registerRecordsMap[]}` |
|
||||
|
||||
`documentMap` / `registerRecordsMap` — пути к реквизитам документов / движениям, соответствующим измерению.
|
||||
|
||||
## DocumentNumerator (Нумератор документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / … / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
@@ -1,41 +0,0 @@
|
||||
# ExchangePlan (План обмена)
|
||||
|
||||
Близок к справочнику (без иерархии/владельцев), плюс состав объектов обмена. Наследует слой Catalog:
|
||||
`codeLength`, `codeAllowedLength`, `descriptionLength`, `defaultPresentation`, `editType`, `quickChoice`,
|
||||
`choiceMode`, формы, `standardAttributes`, `characteristics`, `inputByString`, `basedOn`, представления —
|
||||
см. `catalog.md` / `attributes.md` / `blocks.md`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `distributedInfoBase` | `false` | bool (распределённая ИБ — РИБ) |
|
||||
| `includeConfigurationExtensions` | `false` | bool (включать расширения конфигурации) |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `content` | `[]` | состав обмена (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
## `content` — состав плана обмена
|
||||
|
||||
Список объектов-участников обмена; у каждого — признак авторегистрации изменений (по умолчанию выключена).
|
||||
Элемент — ссылка на объект метаданных (строка) или объект с признаком:
|
||||
|
||||
```json
|
||||
"content": [
|
||||
"Catalog.Организации", // авторегистрация выключена
|
||||
"InformationRegister.Курсы: autoRecord", // авторегистрация включена (токен)
|
||||
{ "metadata": "Document.РеализацияТоваров", "autoRecord": true }
|
||||
]
|
||||
```
|
||||
|
||||
- Строка `"Тип.Имя"` — авторегистрация выключена; суффикс `: autoRecord` — включена.
|
||||
- Объект: `metadata` (ссылка), `autoRecord` (bool или `Allow`/`Deny`).
|
||||
|
||||
```json
|
||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "distributedInfoBase": false,
|
||||
"content": ["Catalog.Номенклатура: autoRecord", "Catalog.Контрагенты: autoRecord"],
|
||||
"attributes": ["АдресСервера: String(200)"] }
|
||||
```
|
||||
@@ -1,90 +0,0 @@
|
||||
# Прочие типы
|
||||
|
||||
Редкие/служебные объекты. Каждый — минимальный набор свойств.
|
||||
|
||||
## FunctionalOption (Функциональная опция)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `location` | пусто | где хранится значение: `Constant.X` / `InformationRegister.X.Resource.Y` / `<Тип>.X.Attribute.Y` |
|
||||
| `content` | `[]` | реквизиты/измерения/ресурсы, зависящие от опции (полные пути к объектам) |
|
||||
| `privilegedGetMode` | `true` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FunctionalOption", "name": "ВестиУчетПоСкладам", "location": "Constant.ВестиУчетПоСкладам",
|
||||
"content": ["Document.РеализацияТоваров.TabularSection.Товары.Attribute.Склад"] }
|
||||
```
|
||||
|
||||
## FilterCriterion (Критерий отбора)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | — | тип значения отбора (составной через `+`) |
|
||||
| `content` | `[]` | реквизиты, по которым идёт отбор (пути к объектам) |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FilterCriterion", "name": "ДокументыПоКонтрагенту", "valueType": "CatalogRef.Контрагенты",
|
||||
"content": ["Document.Реализация.Attribute.Контрагент"] }
|
||||
```
|
||||
|
||||
## SettingsStorage (Хранилище настроек)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `defaultSaveForm` / `defaultLoadForm` | пусто | формы сохранения / загрузки |
|
||||
| `auxiliarySaveForm` / `auxiliaryLoadForm` | пусто | вспомогательные формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
## CommonForm (Общая форма)
|
||||
|
||||
Создаёт метаданные + заготовку формы. Содержимое формы наполняется `/form-compile` или `/form-edit`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `formType` | `Managed` | тип формы |
|
||||
| `usePurposes` | `[PlatformApplication, MobilePlatformApplication]` | назначение (массив) |
|
||||
| `useStandardCommands` | `false` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "CommonForm", "name": "НастройкиОбмена", "usePurposes": ["PlatformApplication"] }
|
||||
```
|
||||
|
||||
## CommonPicture / CommonTemplate (Общие картинки и макеты)
|
||||
|
||||
Только метаданные + регистрация; содержимое (`Ext/Picture*`, `Ext/Template.*`) импортируется отдельно
|
||||
(для табличного макета — `/mxl-compile`).
|
||||
|
||||
- **CommonPicture** — `availabilityForChoice` / `availabilityForAppearance` (bool, по умолчанию `false`).
|
||||
- **CommonTemplate** — `templateType` (`SpreadsheetDocument` по умолчанию / `TextDocument` / `HTMLDocument` /
|
||||
`BinaryData` / `AddIn` / `DataCompositionSchema` / `DataCompositionAppearanceTemplate` / `GraphicalSchema`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonTemplate", "name": "ПечатьЗаказа", "templateType": "SpreadsheetDocument" }
|
||||
```
|
||||
|
||||
## Служебные типы
|
||||
|
||||
- **SessionParameter** (параметр сеанса) — `valueType` (тип значения, составной через `+`).
|
||||
- **FunctionalOptionsParameter** (параметр функциональной опции) — `use` (массив измерений/реквизитов).
|
||||
- **WSReference** (WS-ссылка) — `locationURL` (URL WSDL).
|
||||
- **CommandGroup** (группа команд) — `category` (по умолч. `NavigationPanel`) — где размещается группа:
|
||||
`NavigationPanel` / `ActionsPanel` (командный интерфейс раздела) или `FormCommandBar` / `FormNavigationPanel`
|
||||
(командный интерфейс формы); `representation` (`Auto`), `tooltip` (ML), `picture`. Команды объекта ссылаются на
|
||||
группу через `group: "CommandGroup.<Имя>"` (см. `blocks.md`).
|
||||
- **CommonCommand** (общая команда) — `group`, `representation`, `tooltip`, `picture`, `shortcut`,
|
||||
`commandParameterType`, `parameterUseMode` (`Single`/`Multiple`), `modifiesData`, `includeHelpInContents`.
|
||||
Создаёт `Ext/CommandModule.bsl`.
|
||||
- **CommonAttribute** (общий реквизит) — `valueType` (по умолчанию `String(0)`) + свойства реквизита
|
||||
(`attributes.md`) + `content` (объекты, куда входит реквизит) + свойства разделения данных
|
||||
(`dataSeparation`, `separatedDataUse`, `usersSeparation`, … — по умолчанию `DontUse`/`Independently`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonAttribute", "name": "Организация", "valueType": "CatalogRef.Организации",
|
||||
"autoUse": "Use", "content": ["Document.РеализацияТоваров", "Document.ПоступлениеТоваров"] }
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
# BusinessProcess, Task (Бизнес-процессы и Задачи)
|
||||
|
||||
Ссылочные типы. Наследуют слой Catalog (нумерация, формы, `standardAttributes`, `characteristics`, `basedOn`,
|
||||
представления — см. `catalog.md` / `attributes.md` / `blocks.md`). Бизнес-процесс всегда связан с задачей.
|
||||
|
||||
## BusinessProcess (Бизнес-процесс)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `task` | пусто | ссылка на задачу `Task.X` (обязательна для рабочего БП) |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Создаётся с картой маршрута (`Ext/Flowchart.xml`) и модулем объекта.
|
||||
|
||||
```json
|
||||
{ "type": "BusinessProcess", "name": "Согласование", "task": "Task.ЗадачаИсполнителя",
|
||||
"attributes": ["Документ: DocumentRef.ЗаявкаНаРасход"] }
|
||||
```
|
||||
|
||||
## Task (Задача)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `14` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `addressing` | пусто | ссылка на регистр сведений адресации `InformationRegister.X` |
|
||||
| `mainAddressingAttribute` | пусто | основной реквизит адресации (имя реквизита адресации) |
|
||||
| `currentPerformer` | пусто | реквизит текущего исполнителя |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `addressingAttributes` | `[]` | реквизиты адресации (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Реквизит адресации** — shorthand `"Имя: Тип"` или объект `{ name, type, addressingDimension }`
|
||||
(`addressingDimension` — измерение регистра адресации).
|
||||
|
||||
```json
|
||||
{ "type": "Task", "name": "ЗадачаИсполнителя",
|
||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи", "Роль: CatalogRef.Роли"] }
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
# Регистры: Information, Accumulation, Accounting, Calculation
|
||||
|
||||
**Измерения и ресурсы** задаются как реквизиты (shorthand `"Имя: Тип | флаги"` или объектная форма, см.
|
||||
`attributes.md`). Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (регистр накопления).
|
||||
|
||||
```json
|
||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
```
|
||||
|
||||
## InformationRegister (Регистр сведений)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `writeMode` | `Independent` | `Independent` / `RecorderSubordinate` |
|
||||
| `periodicity` | `Nonperiodical` | `Nonperiodical` / `Second` / `Day` / `Month` / `Quarter` / `Year` / `RecorderPosition` |
|
||||
| `mainFilterOnPeriod` | `false` | bool (основной отбор по периоду) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
## AccumulationRegister (Регистр накопления)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registerType` | `Balance` | `Balance` (остатки) / `Turnovers` (обороты) |
|
||||
| `enableTotalsSplitting` | `true` | bool (разделение итогов) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура", "Склад: CatalogRef.Склады"],
|
||||
"resources": ["Количество: Number(15,3)"] }
|
||||
```
|
||||
|
||||
## AccountingRegister (Регистр бухгалтерии)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfAccounts` | — | **обязательно**: ссылка на план счетов `ChartOfAccounts.X` |
|
||||
| `correspondence` | `false` | bool (корреспонденция) |
|
||||
| `periodAdjustmentLength` | `0` | длина периода корректировки |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccountingRegister", "name": "Хозрасчетный",
|
||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
||||
"dimensions": ["Организация: CatalogRef.Организации"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
## CalculationRegister (Регистр расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfCalculationTypes` | — | **обязательно**: ссылка на ПВР `ChartOfCalculationTypes.X` |
|
||||
| `periodicity` | `Month` | периодичность |
|
||||
| `actionPeriod` | `false` | bool (период действия) |
|
||||
| `basePeriod` | `false` | bool (базовый период) |
|
||||
| `schedule` | пусто | ссылка на регистр сведений графиков |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "CalculationRegister", "name": "Начисления",
|
||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления", "periodicity": "Month",
|
||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
> **AccountingRegister** требует план счетов, **CalculationRegister** — план видов расчёта (и оба — документ-регистратор).
|
||||
@@ -1,45 +0,0 @@
|
||||
# Report, DataProcessor (Отчёты и Обработки)
|
||||
|
||||
Почти идентичны по составу: реквизиты, табличные части, формы, макеты, команды. Модуль объекта — `Ext/ObjectModule.bsl`.
|
||||
Реквизиты и ТЧ — `attributes.md`; команды — `blocks.md`.
|
||||
|
||||
Ссылки на формы/схемы/хранилища пишутся **как есть** (имя формы может быть буквально «Форма»).
|
||||
|
||||
## Report (Отчёт)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool (доступность через стандартный командный интерфейс) |
|
||||
| `mainDataCompositionSchema` | пусто | основной макет СКД (`Report.X.Template.ОсновнаяСхемаКомпоновкиДанных`) |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `defaultSettingsForm` / `auxiliarySettingsForm` / `defaultVariantForm` | пусто | формы настроек / вариантов |
|
||||
| `variantsStorage` / `settingsStorage` | пусто | хранилища вариантов / настроек (`SettingsStorage.X`) |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "Report", "name": "АнализПродаж", "useStandardCommands": false,
|
||||
"mainDataCompositionSchema": "Report.АнализПродаж.Template.ОсновнаяСхемаКомпоновкиДанных",
|
||||
"attributes": ["Период: StandardPeriod"] }
|
||||
```
|
||||
|
||||
## DataProcessor (Обработка)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "DataProcessor", "name": "ЗагрузкаТаблиц", "useStandardCommands": false,
|
||||
"attributes": [{ "name": "Таблица", "type": "ValueTree" }, { "name": "Произвольные", "type": "" }] }
|
||||
```
|
||||
|
||||
> Реквизиты отчётов/обработок допускают платформенные типы-коллекции: `ValueTable`, `ValueTree`, `ValueList`,
|
||||
> `StandardPeriod`, `SpreadsheetDocument` и др., а также `"type": ""` — реквизит без типа.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Enum, Constant, DefinedType
|
||||
|
||||
## Enum (Перечисление)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `values` | `[]` | значения перечисления (массив имён или объектов) |
|
||||
|
||||
Значение — строка `"ИмяЗначения"` или объект `{ name, synonym }`.
|
||||
|
||||
```json
|
||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
||||
```
|
||||
|
||||
## Constant (Константа)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | `String` | тип значения (shorthand типа) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
|
||||
`valueType` принимает shorthand: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`,
|
||||
составной через `+`.
|
||||
|
||||
```json
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||
```
|
||||
|
||||
## DefinedType (Определяемый тип)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueTypes` | `[]` | состав типа (массив shorthand-типов) |
|
||||
| `valueType` | — | то же одной строкой (`"A + B"`) или строкой одного типа |
|
||||
|
||||
```json
|
||||
{ "type": "DefinedType", "name": "ДенежныеСредства",
|
||||
"valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
||||
```
|
||||
@@ -1,70 +0,0 @@
|
||||
# HTTPService, WebService (Веб-сервисы)
|
||||
|
||||
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
||||
|
||||
## HTTPService (HTTP-сервис)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||
|
||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||
- строка — URL-путь без методов: `"/health"`;
|
||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `synonym`, `comment`,
|
||||
`methods` — `{ "ИмяМетода": def }`.
|
||||
|
||||
`methods` — значение либо строка (только HTTP-метод), либо объект: `httpMethod`, `handler`,
|
||||
`synonym`, `comment`.
|
||||
|
||||
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||
Обработчик по умолчанию именуется `{ИмяШаблона}{ИмяМетода}`; в типовых конфигурациях он часто
|
||||
произвольный — тогда задавайте `handler` явно.
|
||||
|
||||
```json
|
||||
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
||||
"urlTemplates": {
|
||||
"Users": { "template": "/v1/users/{id}", "methods": { "Get": "GET", "Create": "POST", "Delete": "DELETE" } },
|
||||
"Health": "/health"
|
||||
} }
|
||||
```
|
||||
|
||||
## WebService (Веб-сервис, SOAP)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `namespace` | пусто | URI пространства имён WSDL |
|
||||
| `xdtoPackages` | пусто | список пакетов (см. ниже) |
|
||||
| `descriptorFileName` | `= name` + `.1cws` | имя файла дескриптора |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `operations` | `{}` | операции (см. ниже) |
|
||||
|
||||
`xdtoPackages` — **массив** значений: `"XDTOPackage.Имя"` — пакет конфигурации, любое другое
|
||||
значение — URI внешнего пространства имён (например `"http://v8.1c.ru/8.3/data/ext"`).
|
||||
|
||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||
`procedureName` (имя процедуры, по умолчанию = имя операции; синоним ключа — `handler`),
|
||||
`dataLockControlMode` (по умолчанию `Managed`), `synonym`, `comment`, `parameters`.
|
||||
|
||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||
- строка — XDTO-тип (`direction` = `In`);
|
||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`),
|
||||
`direction` (`In` / `Out` / `InOut`), `synonym`, `comment`.
|
||||
|
||||
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||
Тип из собственного пространства имён задаётся в нотации Кларка — `"{http://ваш.uri}ИмяТипа"`;
|
||||
компилятор сам объявит локальный `xmlns` в теге, как это делает платформа.
|
||||
|
||||
```json
|
||||
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
||||
"operations": {
|
||||
"TestConnection": { "returnType": "xs:boolean", "handler": "ПроверкаПодключения",
|
||||
"parameters": { "ErrorMessage": { "type": "xs:string", "direction": "Out" } } },
|
||||
"GetVersion": "xs:string"
|
||||
} }
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: meta-decompile
|
||||
description: Декомпиляция объекта метаданных 1С в JSON-заготовку формата meta-compile. Используй когда нужно получить черновик DSL-описания нового объекта по образцу другого. Не сохраняет UUID/модули/формы.
|
||||
argument-hint: <ObjectPath> [-OutputPath <out.json>]
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /meta-decompile — DSL-заготовка из XML объекта метаданных
|
||||
|
||||
Читает XML объекта метаданных (`Catalogs/Имя.xml` и т.п.) и эмитит компактный JSON в формате `/meta-compile`. Назначение — **взять существующий объект образцом и собрать по нему НОВЫЙ**: декомпилировать → поправить → скомпилировать под другим именем.
|
||||
|
||||
## ⚠️ Главное: это НЕ обратимая выгрузка
|
||||
|
||||
Компиляция черновика создаёт **новый объект с новой идентичностью**, а не копию исходного. В JSON **не** попадают: UUID (идентичность самого объекта и всех дочерних), тела модулей, формы, макеты, права. Захватываются только структура и свойства.
|
||||
|
||||
Отсюда правило: **никогда не компилируй черновик поверх объекта-источника и не выдавай его за «реимпорт»** — у пересобранного объекта другие UUID, поэтому все ссылки на исходный объект (из кода, других объектов, состава подсистем, предопределённых данных) сломаются, а код модулей и формы пропадут.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
**Собрать новый объект по образцу существующего** — получить DSL-заготовку рабочего объекта, переименовать и адаптировать состав, скомпилировать в новый. Быстрее, чем писать DSL с нуля для богатого объекта.
|
||||
|
||||
## Когда **не** использовать
|
||||
|
||||
- **Точечная правка существующего объекта** (добавить реквизит, ТЧ, свойство) → `/meta-edit`. Цикл decompile→compile тут вреден: даёт объект с новой идентичностью и теряет модули/формы.
|
||||
- **Сохранить / восстановить / перенести тот же объект** (бэкап, миграция между конфигурациями с сохранением ссылок) → штатная выгрузка 1С (`/db-dump-xml` ↔ `/db-load-xml`, CF), а не decompile.
|
||||
- **Просто понять структуру** объекта (реквизиты, ТЧ, типы) без пересборки → `/meta-info` (дешевле, не плодит файл).
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `ObjectPath` | Путь к XML объекта (`Catalogs/Имя.xml`), обязательный |
|
||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/meta-decompile/scripts/meta-decompile.py" -ObjectPath "<Объект.xml>" -OutputPath "<out.json>"
|
||||
```
|
||||
|
||||
Неподдерживаемый тип объекта или не-`MetaDataObject` root → ненулевой код выхода и сообщение в stderr.
|
||||
|
||||
## Workflow (сборка нового объекта по образцу)
|
||||
|
||||
1. `/meta-decompile <Образец.xml> -OutputPath draft.json` — получить заготовку.
|
||||
2. В `draft.json` **сменить `name`** на имя нового объекта и адаптировать состав (реквизиты/ТЧ/свойства). Ссылки на *другие* объекты (владельцы, ввод на основании, типы) — по имени, сохраняются как есть.
|
||||
3. `/meta-compile -JsonPath draft.json -OutputDir <ConfigDir>` — собрать (объект получит свежие UUID).
|
||||
4. `/meta-validate` + `/meta-info` — проверить.
|
||||
5. Модули, формы, макеты, права — добавить отдельно (в черновик они не попадают).
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
# Свойства объекта и свойства-списки
|
||||
|
||||
Справочник операций для обычных свойств объекта и свойств-списков (Owners, RegisterRecords, BasedOn, InputByString).
|
||||
|
||||
## modify-property
|
||||
|
||||
Изменение свойств объекта по имени свойства 1С (PascalCase, как в конфигураторе). Формат: `Ключ=Значение`
|
||||
(batch через `;;`):
|
||||
```powershell
|
||||
-Operation modify-property -Value "CodeLength=11 ;; DescriptionLength=150"
|
||||
-Operation modify-property -Value "Hierarchical=true"
|
||||
```
|
||||
|
||||
Свойство можно задать, даже если оно ещё не выставлено у объекта (например `FullTextSearch`, `DataHistory`).
|
||||
Опечатка в имени свойства → ошибка (правка не теряется молча). Допустимы имена свойств соответствующего типа объекта.
|
||||
|
||||
### Type — тип значения (Константа, ПВХ)
|
||||
|
||||
`Type=...` перестраивает дескриптор типа значения. Значение — тип 1С в том же синтаксисе,
|
||||
что у реквизитов: составной через `+`, с квалификаторами и ссылочными типами:
|
||||
```powershell
|
||||
-Operation modify-property -Value "Type=String(100) + Number(15,2) + CatalogRef.Номенклатура"
|
||||
```
|
||||
Структурные свойства (со вложенными узлами) в скалярный текст не превращаются: попытка задать
|
||||
такое свойство обычным `Ключ=Значение` (кроме `Type`) завершается ошибкой до записи файла.
|
||||
|
||||
## Свойства-списки
|
||||
|
||||
Свойства, значение которых — список ссылок. Управляются через inline `add-*` / `remove-*` / `set-*` и через JSON `modify.properties`.
|
||||
|
||||
| Свойство | Объекты | Inline-значение |
|
||||
|----------|---------|-----------------|
|
||||
| Owners | Catalog, ChartOfCharacteristicTypes | `Catalog.XXX` |
|
||||
| RegisterRecords | Document | `AccumulationRegister.XXX` |
|
||||
| BasedOn | Document, Catalog, BP, Task | `Document.XXX` |
|
||||
| InputByString | Catalog, ChartOf*, Task | `StandardAttribute.Description` |
|
||||
| DataLockFields | Catalog, Document, регистры и др. | `Организация` (короткое имя реквизита → полный путь) |
|
||||
| RegisteredDocuments | DocumentJournal | `Document.XXX` |
|
||||
|
||||
### add-owner / add-registerRecord / add-basedOn / add-registeredDocument
|
||||
|
||||
Полное имя метаданных `MetaType.Name`:
|
||||
```powershell
|
||||
-Operation add-owner -Value "Catalog.Контрагенты ;; Catalog.Организации"
|
||||
-Operation add-registerRecord -Value "AccumulationRegister.ОстаткиТоваров"
|
||||
-Operation add-basedOn -Value "Document.ЗаказКлиента"
|
||||
-Operation add-registeredDocument -Value "Document.РасходныйОрдер"
|
||||
```
|
||||
|
||||
### add-inputByString / add-dataLockField
|
||||
|
||||
Пути полей (короткое имя реквизита разворачивается в полный путь автоматически):
|
||||
```powershell
|
||||
-Operation add-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
||||
-Operation add-dataLockField -Value "Организация ;; Контрагент"
|
||||
```
|
||||
|
||||
### remove-owner / remove-registerRecord / remove-basedOn / remove-inputByString / remove-dataLockField / remove-registeredDocument
|
||||
|
||||
```powershell
|
||||
-Operation remove-owner -Value "Catalog.Контрагенты"
|
||||
-Operation remove-inputByString -Value "Catalog.МойСпр.StandardAttribute.Code"
|
||||
-Operation remove-dataLockField -Value "Организация"
|
||||
```
|
||||
|
||||
### set-owners / set-registerRecords / set-basedOn / set-inputByString / set-dataLockFields / set-registeredDocuments
|
||||
|
||||
Заменяют **весь список** (в отличие от add/remove):
|
||||
```powershell
|
||||
-Operation set-owners -Value "Catalog.Организации ;; Catalog.Контрагенты"
|
||||
-Operation set-registerRecords -Value "AccumulationRegister.Продажи ;; AccumulationRegister.ОстаткиТоваров"
|
||||
-Operation set-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
||||
```
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: mxl-decompile
|
||||
description: Декомпиляция табличного документа (MXL) в JSON-определение. Используй когда нужно получить редактируемое описание существующего макета
|
||||
argument-hint: <TemplatePath> [OutputPath]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /mxl-decompile — Декомпилятор макета в DSL
|
||||
|
||||
Принимает Template.xml табличного документа 1С и генерирует компактное JSON-определение (DSL). Обратная операция к `/mxl-compile`.
|
||||
|
||||
## Использование
|
||||
|
||||
```
|
||||
/mxl-decompile <TemplatePath> [OutputPath]
|
||||
```
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|--------------|:------------:|-----------------------------------------|
|
||||
| TemplatePath | да | Путь к Template.xml |
|
||||
| OutputPath | нет | Путь для JSON (если не указан — stdout) |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/mxl-decompile/scripts/mxl-decompile.py" -TemplatePath "<путь>/Template.xml" [-OutputPath "<путь>.json"]
|
||||
```
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
Декомпиляция существующего макета для анализа или доработки:
|
||||
|
||||
1. Вызвать `/mxl-decompile` для получения JSON из Template.xml
|
||||
2. Проанализировать или изменить JSON (добавить области, поменять стили)
|
||||
3. Вызвать `/mxl-compile` для генерации нового Template.xml
|
||||
4. Вызвать `/mxl-validate` для проверки
|
||||
|
||||
Формат JSON на выходе — тот же DSL, что принимает `/mxl-compile`; его полное описание живёт в навыке `/mxl-compile`.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: support-edit
|
||||
description: Переключение состояния поддержки типовой конфигурации 1С. Используй когда нужно включить возможность редактирования конфигурации или конкретного объекта на поддержке («на замке»)
|
||||
argument-hint: -Path <путь> -Set editable|off-support|locked | -Capability on|off
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /support-edit — переключение состояния поддержки 1С
|
||||
|
||||
Меняет правила поддержки типовой конфигурации: разрешает правку объекта/конфигурации «на замке», снимает с поддержки, включает/выключает возможность изменения. Это осознанное действие — обычно после отказа `support-guard` (готовую команду под конкретный случай печатает сам отказ).
|
||||
|
||||
Действует только на выгрузку; чтобы применить в информационной базе — загрузить выгрузку (полная загрузка).
|
||||
|
||||
## Команды
|
||||
|
||||
| Что нужно | Команда |
|
||||
|-----------|---------|
|
||||
| Разрешить правку объекта | `-Path <объект> -Set editable` |
|
||||
| Снять объект с поддержки | `-Path <объект> -Set off-support` |
|
||||
| Вернуть объект «на замок» | `-Path <объект> -Set locked` |
|
||||
| Разрешить добавление новых объектов в конфигурацию | `-Path <каталог дампа> -Set editable` |
|
||||
| Включить / выключить возможность изменения всей конфигурации | `-Path <каталог дампа> -Capability on` / `off` |
|
||||
|
||||
`-Path` — тот же путь, который отклонил `support-guard` (объект, форма, макет или каталог дампа).
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/support-edit/scripts/support-edit.py" -Path "Catalogs/Контрагенты.xml" -Set editable
|
||||
```
|
||||
|
||||
## editable или off-support?
|
||||
|
||||
- **editable** — правки разрешены, объект **продолжает получать обновления вендора** (при обновлении возможны конфликты слияния). Бери, когда хочешь дорабатывать и дальше получать обновления.
|
||||
- **off-support** — объект **снят с поддержки**: правки свободны, обновления вендора по нему больше не приходят. Это не удаление объекта. Бери, когда объект уводишь из-под обновлений.
|
||||
|
||||
## Если возможность изменения выключена
|
||||
|
||||
Если вся конфигурация read-only (типовая «из коробки»), пообъектный `-Set` не сработает — навык подскажет сначала выполнить `-Capability on`. Это включает возможность изменения (все объекты при этом остаются на замке), после чего открываешь нужные точечно через `-Set editable`.
|
||||
@@ -1,130 +0,0 @@
|
||||
# support-edit v1.0 — Toggle 1C configuration support state (Ext/ParentConfigurations.bin)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$TargetPath,
|
||||
[ValidateSet("editable","off-support","locked")]
|
||||
[string]$Set,
|
||||
[ValidateSet("on","off")]
|
||||
[string]$Capability
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
if ((-not $Set -and -not $Capability) -or ($Set -and $Capability)) {
|
||||
[Console]::Error.WriteLine("Укажите ровно одно: -Set editable|off-support|locked ЛИБО -Capability on|off")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Resolve target uuid + config root + bin (walk-up, same as support-guard) ---
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not (Test-Path $TargetPath)) {
|
||||
[Console]::Error.WriteLine("Путь не найден: $TargetPath")
|
||||
exit 1
|
||||
}
|
||||
$rp = (Resolve-Path $TargetPath).Path
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
|
||||
if (-not $cfgDir) {
|
||||
[Console]::Error.WriteLine("Не найден корень конфигурации (Configuration.xml) над путём: $rp")
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $binPath)) {
|
||||
Write-Host "Конфигурация не на поддержке (Ext/ParentConfigurations.bin отсутствует) — переключать нечего."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Read bin (UTF-8 text with BOM) ---
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) {
|
||||
Write-Host "Поддержка снята полностью (пустой ParentConfigurations.bin) — переключать нечего."
|
||||
exit 0
|
||||
}
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) {
|
||||
[Console]::Error.WriteLine("Неизвестный формат ParentConfigurations.bin")
|
||||
exit 1
|
||||
}
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
|
||||
function Save-Bin([string]$txt) {
|
||||
[System.IO.File]::WriteAllText($binPath, $txt, (New-Object System.Text.UTF8Encoding($true)))
|
||||
}
|
||||
|
||||
# === Capability (global G) ===
|
||||
if ($Capability) {
|
||||
$target = if ($Capability -eq 'on') { '0' } else { '1' }
|
||||
if ($G -eq [int]$target) {
|
||||
$word = if ($Capability -eq 'on') { 'включена' } else { 'выключена' }
|
||||
Write-Host "Возможность изменения конфигурации уже $word — изменений нет."
|
||||
exit 0
|
||||
}
|
||||
# G + X (per block) + bulk f1
|
||||
$text = [regex]::Replace($text, '^(\{6,)\d+(,)', "`${1}$target`$2")
|
||||
$text = [regex]::Replace($text, '([0-9a-f-]{36}),\d+,([0-9a-f-]{36})', "`$1,$target,`$2")
|
||||
$text = [regex]::Replace($text, '[0-2],0,([0-9a-f-]{36})', "$target,0,`$1")
|
||||
Save-Bin $text
|
||||
if ($Capability -eq 'on') {
|
||||
Write-Host "Возможность изменения конфигурации ВКЛЮЧЕНА. Все объекты поставщика — на замке."
|
||||
Write-Host "Включайте редактирование точечно: support-edit -Path <объект> -Set editable"
|
||||
} else {
|
||||
Write-Host "Возможность изменения конфигурации ВЫКЛЮЧЕНА. Вся конфигурация стала read-only; пообъектные правила сброшены."
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# === Per-object -Set ===
|
||||
if ($G -eq 1) {
|
||||
[Console]::Error.WriteLine("Возможность изменения конфигурации выключена — пообъектное переключение недоступно.`n Сначала: support-edit -Path $TargetPath -Capability on")
|
||||
exit 1
|
||||
}
|
||||
if (-not $elemUuid) {
|
||||
[Console]::Error.WriteLine("Не удалось определить объект по пути: $rp")
|
||||
exit 1
|
||||
}
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
$matches = [regex]::Matches($text, "([0-2]),0,$u")
|
||||
if ($matches.Count -eq 0) {
|
||||
Write-Host "Объект (uuid $elemUuid) не на поддержке (своё добавление или не найден в bin) — переключать нечего."
|
||||
exit 0
|
||||
}
|
||||
$newF1 = switch ($Set) { 'editable' { '1' } 'off-support' { '2' } 'locked' { '0' } }
|
||||
# Replacement string has no group refs — uuid is fixed, f1 is rewritten.
|
||||
$text = [regex]::Replace($text, "([0-2]),0,$u", "$newF1,0,$($elemUuid.ToLower())")
|
||||
Save-Bin $text
|
||||
$state = switch ($Set) {
|
||||
'editable' { "редактируется с сохранением поддержки (объект продолжит получать обновления вендора — возможны конфликты при обновлении)" }
|
||||
'off-support' { "снят с поддержки (обновления вендора по этому объекту прекращаются)" }
|
||||
'locked' { "на замке (правка запрещена)" }
|
||||
}
|
||||
Write-Host "Объект uuid $elemUuid → $state."
|
||||
Write-Host "Записей в bin изменено: $($matches.Count). Цель: $rp"
|
||||
exit 0
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# support-edit v1.0 — Toggle 1C configuration support state (Ext/ParentConfigurations.bin)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
parser = argparse.ArgumentParser(description="Toggle 1C support state", allow_abbrev=False)
|
||||
parser.add_argument("-Path", "-TargetPath", dest="Path", required=True, help="Путь к объекту/форме/макету или каталогу дампа")
|
||||
parser.add_argument("-Set", choices=["editable", "off-support", "locked"], default=None)
|
||||
parser.add_argument("-Capability", choices=["on", "off"], default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if (not args.Set and not args.Capability) or (args.Set and args.Capability):
|
||||
sys.stderr.write("Укажите ровно одно: -Set editable|off-support|locked ЛИБО -Capability on|off\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
target_path = args.Path
|
||||
if not os.path.exists(target_path):
|
||||
sys.stderr.write(f"Путь не найден: {target_path}\n")
|
||||
sys.exit(1)
|
||||
rp = os.path.abspath(target_path)
|
||||
elem_uuid = root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if not elem_uuid:
|
||||
elem_uuid = root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
|
||||
if not cfg_dir:
|
||||
sys.stderr.write(f"Не найден корень конфигурации (Configuration.xml) над путём: {rp}\n")
|
||||
sys.exit(1)
|
||||
if not os.path.exists(bin_path):
|
||||
print("Конфигурация не на поддержке (Ext/ParentConfigurations.bin отсутствует) — переключать нечего.")
|
||||
sys.exit(0)
|
||||
|
||||
raw = open(bin_path, "rb").read()
|
||||
if len(raw) <= 32:
|
||||
print("Поддержка снята полностью (пустой ParentConfigurations.bin) — переключать нечего.")
|
||||
sys.exit(0)
|
||||
text = raw[3:].decode("utf-8") if raw[:3] == b"\xef\xbb\xbf" else raw.decode("utf-8")
|
||||
hm = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not hm:
|
||||
sys.stderr.write("Неизвестный формат ParentConfigurations.bin\n")
|
||||
sys.exit(1)
|
||||
g = int(hm.group(1))
|
||||
k = int(hm.group(2))
|
||||
|
||||
|
||||
def save_bin(txt):
|
||||
open(bin_path, "wb").write(b"\xef\xbb\xbf" + txt.encode("utf-8"))
|
||||
|
||||
|
||||
# === Capability (global G) ===
|
||||
if args.Capability:
|
||||
target = "0" if args.Capability == "on" else "1"
|
||||
if g == int(target):
|
||||
word = "включена" if args.Capability == "on" else "выключена"
|
||||
print(f"Возможность изменения конфигурации уже {word} — изменений нет.")
|
||||
sys.exit(0)
|
||||
text = re.sub(r"^(\{6,)\d+(,)", r"\g<1>" + target + r"\g<2>", text)
|
||||
text = re.sub(r"([0-9a-f-]{36}),\d+,([0-9a-f-]{36})", r"\1," + target + r",\2", text)
|
||||
text = re.sub(r"[0-2],0,([0-9a-f-]{36})", target + r",0,\1", text)
|
||||
save_bin(text)
|
||||
if args.Capability == "on":
|
||||
print("Возможность изменения конфигурации ВКЛЮЧЕНА. Все объекты поставщика — на замке.")
|
||||
print("Включайте редактирование точечно: support-edit -Path <объект> -Set editable")
|
||||
else:
|
||||
print("Возможность изменения конфигурации ВЫКЛЮЧЕНА. Вся конфигурация стала read-only; пообъектные правила сброшены.")
|
||||
sys.exit(0)
|
||||
|
||||
# === Per-object -Set ===
|
||||
if g == 1:
|
||||
sys.stderr.write(
|
||||
"Возможность изменения конфигурации выключена — пообъектное переключение недоступно.\n"
|
||||
f" Сначала: support-edit -Path {target_path} -Capability on\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
if not elem_uuid:
|
||||
sys.stderr.write(f"Не удалось определить объект по пути: {rp}\n")
|
||||
sys.exit(1)
|
||||
u = re.escape(elem_uuid.lower())
|
||||
n = len(re.findall(r"[0-2],0," + u, text))
|
||||
if n == 0:
|
||||
print(f"Объект (uuid {elem_uuid}) не на поддержке (своё добавление или не найден в bin) — переключать нечего.")
|
||||
sys.exit(0)
|
||||
new_f1 = {"editable": "1", "off-support": "2", "locked": "0"}[args.Set]
|
||||
text = re.sub(r"[0-2],0," + u, new_f1 + ",0," + elem_uuid.lower(), text)
|
||||
save_bin(text)
|
||||
state = {
|
||||
"editable": "редактируется с сохранением поддержки (объект продолжит получать обновления вендора — возможны конфликты при обновлении)",
|
||||
"off-support": "снят с поддержки (обновления вендора по этому объекту прекращаются)",
|
||||
"locked": "на замке (правка запрещена)",
|
||||
}[args.Set]
|
||||
print(f"Объект uuid {elem_uuid} → {state}.")
|
||||
print(f"Записей в bin изменено: {n}. Цель: {rp}")
|
||||
sys.exit(0)
|
||||
@@ -1,515 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-template v1.11 — Add template to 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
TYPE_MAP = {
|
||||
"HTML": {"TemplateType": "HTMLDocument", "Ext": ".html"},
|
||||
"Text": {"TemplateType": "TextDocument", "Ext": ".txt"},
|
||||
"SpreadsheetDocument": {"TemplateType": "SpreadsheetDocument", "Ext": ".xml"},
|
||||
"BinaryData": {"TemplateType": "BinaryData", "Ext": ".bin"},
|
||||
"DataCompositionSchema": {"TemplateType": "DataCompositionSchema", "Ext": ".xml"},
|
||||
}
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
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 main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Add template to 1C object", allow_abbrev=False)
|
||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||
parser.add_argument("-TemplateName", required=True)
|
||||
parser.add_argument("-TemplateType", required=True,
|
||||
choices=["HTML", "Text", "SpreadsheetDocument", "BinaryData", "DataCompositionSchema"])
|
||||
parser.add_argument("-Synonym", default=None)
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
parser.add_argument("-SetMainSKD", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
object_name = args.ObjectName
|
||||
template_name = args.TemplateName
|
||||
template_type = args.TemplateType
|
||||
synonym = args.Synonym if args.Synonym is not None else template_name
|
||||
src_dir = args.SrcDir
|
||||
set_main_skd = args.SetMainSKD
|
||||
|
||||
tmpl = TYPE_MAP[template_type]
|
||||
|
||||
format_version = detect_format_version(os.path.abspath(src_dir))
|
||||
|
||||
# --- Checks ---
|
||||
|
||||
object_type_folders = [
|
||||
"Reports", "DataProcessors", "Documents", "Catalogs",
|
||||
"InformationRegisters", "AccumulationRegisters",
|
||||
"ChartsOfCharacteristicTypes", "ChartsOfAccounts", "ChartsOfCalculationTypes",
|
||||
"BusinessProcesses", "Tasks", "ExchangePlans",
|
||||
]
|
||||
|
||||
root_xml_path = os.path.join(src_dir, f"{object_name}.xml")
|
||||
if not os.path.exists(root_xml_path):
|
||||
candidates = []
|
||||
for folder in object_type_folders:
|
||||
probe = os.path.join(src_dir, folder, f"{object_name}.xml")
|
||||
if os.path.exists(probe):
|
||||
candidates.append(os.path.join(src_dir, folder))
|
||||
if len(candidates) == 1:
|
||||
src_dir = candidates[0]
|
||||
root_xml_path = os.path.join(src_dir, f"{object_name}.xml")
|
||||
print(f"[INFO] SrcDir расширен до: {src_dir}")
|
||||
elif len(candidates) > 1:
|
||||
print(f"Объект '{object_name}' найден в нескольких подпапках: {', '.join(candidates)}", file=sys.stderr)
|
||||
print(f"Укажи SrcDir явно", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Корневой файл объекта не найден: {root_xml_path}", file=sys.stderr)
|
||||
print(f"Ожидается: <SrcDir>/<ObjectName>.xml", file=sys.stderr)
|
||||
print(f"Подсказка: SrcDir должен указывать на папку типа объектов (например Reports), а не на корень конфигурации", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
processor_dir = os.path.join(src_dir, object_name)
|
||||
templates_dir = os.path.join(processor_dir, "Templates")
|
||||
template_meta_path = os.path.join(templates_dir, f"{template_name}.xml")
|
||||
|
||||
if os.path.exists(template_meta_path):
|
||||
print(f"Макет уже существует: {template_meta_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
assert_edit_allowed(root_xml_path, "editable")
|
||||
|
||||
# --- Create directories ---
|
||||
|
||||
template_ext_dir = os.path.join(templates_dir, template_name, "Ext")
|
||||
os.makedirs(template_ext_dir, exist_ok=True)
|
||||
|
||||
# --- 1. Template metadata (Templates/<TemplateName>.xml) ---
|
||||
|
||||
template_uuid = str(uuid.uuid4())
|
||||
|
||||
template_meta_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<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"'
|
||||
f' version="{format_version}">\n'
|
||||
f'\t<Template uuid="{template_uuid}">\n'
|
||||
'\t\t<Properties>\n'
|
||||
f'\t\t\t<Name>{template_name}</Name>\n'
|
||||
'\t\t\t<Synonym>\n'
|
||||
'\t\t\t\t<v8:item>\n'
|
||||
'\t\t\t\t\t<v8:lang>ru</v8:lang>\n'
|
||||
f'\t\t\t\t\t<v8:content>{synonym}</v8:content>\n'
|
||||
'\t\t\t\t</v8:item>\n'
|
||||
'\t\t\t</Synonym>\n'
|
||||
'\t\t\t<Comment/>\n'
|
||||
f'\t\t\t<TemplateType>{tmpl["TemplateType"]}</TemplateType>\n'
|
||||
'\t\t</Properties>\n'
|
||||
'\t</Template>\n'
|
||||
'</MetaDataObject>'
|
||||
)
|
||||
|
||||
write_text_with_bom(template_meta_path, template_meta_xml)
|
||||
|
||||
# --- 2. Template content (Templates/<TemplateName>/Ext/Template.<ext>) ---
|
||||
|
||||
template_file_path = os.path.join(template_ext_dir, f"Template{tmpl['Ext']}")
|
||||
|
||||
if template_type == "HTML":
|
||||
content = (
|
||||
'<!DOCTYPE html>\n'
|
||||
'<html>\n'
|
||||
'<head>\n'
|
||||
'\t<meta charset="UTF-8">\n'
|
||||
'\t<title></title>\n'
|
||||
'</head>\n'
|
||||
'<body>\n'
|
||||
'</body>\n'
|
||||
'</html>'
|
||||
)
|
||||
write_text_with_bom(template_file_path, content)
|
||||
|
||||
elif template_type == "Text":
|
||||
write_text_with_bom(template_file_path, "")
|
||||
|
||||
elif template_type == "SpreadsheetDocument":
|
||||
content = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<SpreadsheetDocument xmlns="http://v8.1c.ru/spreadsheet/document"'
|
||||
' xmlns:ss="http://v8.1c.ru/spreadsheet/document"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema">\n'
|
||||
'</SpreadsheetDocument>'
|
||||
)
|
||||
write_text_with_bom(template_file_path, content)
|
||||
|
||||
elif template_type == "BinaryData":
|
||||
with open(template_file_path, "wb") as f:
|
||||
pass # empty file
|
||||
|
||||
elif template_type == "DataCompositionSchema":
|
||||
content = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"\n'
|
||||
'\t\txmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"\n'
|
||||
'\t\txmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"\n'
|
||||
'\t\txmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"\n'
|
||||
'\t\txmlns:v8="http://v8.1c.ru/8.1/data/core"\n'
|
||||
'\t\txmlns:v8ui="http://v8.1c.ru/8.1/data/ui"\n'
|
||||
'\t\txmlns:xs="http://www.w3.org/2001/XMLSchema"\n'
|
||||
'\t\txmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n'
|
||||
'\t<dataSource>\n'
|
||||
'\t\t<name>ИсточникДанных1</name>\n'
|
||||
'\t\t<dataSourceType>Local</dataSourceType>\n'
|
||||
'\t</dataSource>\n'
|
||||
'</DataCompositionSchema>'
|
||||
)
|
||||
write_text_with_bom(template_file_path, content)
|
||||
|
||||
# --- 3. 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()
|
||||
|
||||
ns = "http://v8.1c.ru/8.3/MDClasses"
|
||||
child_objects = root.find(".//md:ChildObjects", NSMAP)
|
||||
if child_objects is None:
|
||||
print(f"Не найден элемент ChildObjects в {root_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Add <Template> to end of ChildObjects — idempotent (do not duplicate already-registered template)
|
||||
already_registered = child_objects.find(f"md:Template[.='{template_name}']", NSMAP) is not None
|
||||
|
||||
if not already_registered:
|
||||
template_elem = etree.SubElement(child_objects, f"{{{ns}}}Template")
|
||||
template_elem.text = template_name
|
||||
# Remove auto-appended element to reinsert with proper whitespace
|
||||
child_objects.remove(template_elem)
|
||||
|
||||
children = list(child_objects)
|
||||
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
|
||||
# Empty ChildObjects (self-closing)
|
||||
child_objects.text = "\n\t\t\t"
|
||||
child_objects.append(template_elem)
|
||||
template_elem.tail = "\n\t\t"
|
||||
else:
|
||||
if len(children) > 0:
|
||||
last_child = children[-1]
|
||||
# last_child.tail is the trailing whitespace before </ChildObjects>
|
||||
old_tail = last_child.tail
|
||||
last_child.tail = "\n\t\t\t"
|
||||
child_objects.append(template_elem)
|
||||
template_elem.tail = old_tail if old_tail else "\n\t\t"
|
||||
else:
|
||||
# Has text content but no element children
|
||||
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
|
||||
child_objects.append(template_elem)
|
||||
template_elem.tail = "\n\t\t"
|
||||
|
||||
# --- 4. MainDataCompositionSchema (for ExternalReport / Report) ---
|
||||
|
||||
main_dcs_updated = False
|
||||
if template_type == "DataCompositionSchema":
|
||||
report_like_types = ["ExternalReport", "Report"]
|
||||
object_type_node = None
|
||||
object_type_name = None
|
||||
for rt in report_like_types:
|
||||
node = root.find(f".//md:{rt}", NSMAP)
|
||||
if node is not None:
|
||||
object_type_node = node
|
||||
object_type_name = rt
|
||||
break
|
||||
|
||||
if object_type_node is not None:
|
||||
main_dcs = root.find(f".//md:{object_type_name}/md:Properties/md:MainDataCompositionSchema", NSMAP)
|
||||
if main_dcs is not None:
|
||||
is_empty = main_dcs.text is None or main_dcs.text.strip() == ""
|
||||
if is_empty or set_main_skd:
|
||||
obj_name_node = root.find(f".//md:{object_type_name}/md:Properties/md:Name", NSMAP)
|
||||
obj_name = obj_name_node.text if obj_name_node is not None else ""
|
||||
main_dcs.text = f"{object_type_name}.{obj_name}.Template.{template_name}"
|
||||
main_dcs_updated = True
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, root_xml_full)
|
||||
|
||||
print(f"[OK] Создан макет: {template_name} ({template_type})")
|
||||
if already_registered:
|
||||
print(f" Already registered: <Template>{template_name}</Template> in ChildObjects (skipped duplicate)")
|
||||
print(f" Метаданные: {template_meta_path}")
|
||||
print(f" Содержимое: {template_file_path}")
|
||||
if main_dcs_updated:
|
||||
print(f" MainDataCompositionSchema: {main_dcs.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,32 +0,0 @@
|
||||
// web-test cli/commands/status v1.1 — check session (active liveness probe)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { out } from '../util.mjs';
|
||||
import { SESSION_FILE, cleanup } from '../session.mjs';
|
||||
|
||||
export async function cmdStatus() {
|
||||
if (!existsSync(SESSION_FILE)) {
|
||||
out({ ok: false, ready: false, message: 'No active session' });
|
||||
process.exit(1);
|
||||
}
|
||||
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||
// The session file is written only after connect() finished, but the server process may have
|
||||
// died since (crash/reboot) and left the file behind. Don't trust the file — probe the
|
||||
// in-process /status endpoint for real liveness.
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/status`, { signal: AbortSignal.timeout(2000) });
|
||||
const body = await resp.json();
|
||||
if (body.connected) {
|
||||
out({ ok: true, ready: true, ...sess });
|
||||
} else {
|
||||
out({ ok: false, ready: false, reason: 'browser-disconnected', ...sess });
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
// Server unreachable → the file is stale (process gone). Self-heal by removing it so the
|
||||
// next status/start reads clean.
|
||||
cleanup();
|
||||
out({ ok: false, ready: false, reason: 'server-unreachable', ...sess });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,867 +0,0 @@
|
||||
// web-test cli/commands/test v1.9 — regression test runner
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname, basename, relative } from 'path';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps, softDeadline } from '../util.mjs';
|
||||
import { buildContext, buildScopedContext, setErrorShotDir } from '../exec-context.mjs';
|
||||
import { createAssertions } from '../test-runner/assertions.mjs';
|
||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
||||
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
||||
import { findSuiteRoot, startDirOf } from '../test-runner/suite-root.mjs';
|
||||
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs';
|
||||
|
||||
export async function cmdTest(rawArgs) {
|
||||
// Split off everything after `--` — those args belong to user-defined hooks
|
||||
// (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs
|
||||
// via the hookArgs field; the runner does not interpret them").
|
||||
const sepIdx = rawArgs.indexOf('--');
|
||||
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
||||
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
||||
|
||||
// Deadline budgets for the cleanup path. Every one of these calls reaches into Playwright
|
||||
// and can hang forever against a wedged renderer (page.evaluate has no timeout of its own),
|
||||
// so none of them may be awaited bare. A breach is always logged — silent swallowing is what
|
||||
// turned the original incident into a 29-minute mystery.
|
||||
//
|
||||
// These defaults are sized for a light stand. A heavy application legitimately needs more —
|
||||
// override per key via `deadlines: {...}` in webtest.config.mjs rather than editing this.
|
||||
const D = {
|
||||
screenshot: 10000,
|
||||
teardown: 15000,
|
||||
afterEach: 15000,
|
||||
setActive: 5000,
|
||||
resetState: 20000,
|
||||
startRecording: 15000,
|
||||
stopRecording: 40000, // ffmpeg has its own 30s inside
|
||||
closeContext: 20000,
|
||||
disconnect: 30000,
|
||||
hooks: 120000, // afterAll/cleanup only — prepare/beforeAll stay unbounded (see below)
|
||||
abortAll: 30000, // whole abort+cleanup sequence for one hung test
|
||||
probe: 2000,
|
||||
};
|
||||
|
||||
// Parse flags
|
||||
const opts = { bail: false, retry: 0, timeout: 30000, globalTimeout: 0, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||
let tags = null, grep = null, urlFlag = null;
|
||||
const positional = [];
|
||||
for (const a of ownArgs) {
|
||||
if (a.startsWith('--tags=')) tags = a.slice(7).split(',');
|
||||
else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i');
|
||||
else if (a.startsWith('--url=')) urlFlag = a.slice(6);
|
||||
else if (a === '--bail') opts.bail = true;
|
||||
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
||||
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
||||
else if (a.startsWith('--global-timeout=')) opts.globalTimeout = parseInt(a.slice(17)) || 0;
|
||||
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
||||
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
||||
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
||||
else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13);
|
||||
else if (a === '--record') opts.record = true;
|
||||
else if (!a.startsWith('--')) positional.push(a);
|
||||
}
|
||||
|
||||
// Positional args are ALWAYS test paths (one or many). URL comes from --url= or config
|
||||
// (see webtest.config.mjs). This matches pytest/jest/playwright; a positional that looks
|
||||
// like a URL is a mistake → fail fast with a hint instead of feeding it to page.goto().
|
||||
const isUrl = (s) => /^https?:\/\//i.test(s);
|
||||
let url = urlFlag || null;
|
||||
const testPaths = [...positional];
|
||||
if (testPaths.length === 0) {
|
||||
die('Usage: node run.mjs test <dir|file>... [--url=URL] [--tags=...] [--grep=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]');
|
||||
}
|
||||
for (const p of testPaths) {
|
||||
if (existsSync(resolve(p))) continue;
|
||||
if (isUrl(p)) {
|
||||
die(`"${p}" looks like a URL — use --url=<url>; positional args are test paths.`);
|
||||
}
|
||||
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
|
||||
}
|
||||
|
||||
// Suite root — the directory `webtest.config.mjs`, `_hooks.mjs`, `_allure/` and report paths
|
||||
// all hang off. It is NOT the passed path: walking up to the nearest marker is what makes
|
||||
// `test tests/myapp/sales/` work, as docs/web-test-regression-spec.md has always promised.
|
||||
// Resolving from the passed path instead lost the hooks of any subfolder run — silently, so
|
||||
// the run went ahead against an unprepared stand.
|
||||
const startDirs = testPaths.map(p => startDirOf(p));
|
||||
const roots = startDirs.map(d => findSuiteRoot(d));
|
||||
// Paths from different suites must not share hooks — that used to resolve to "first path
|
||||
// wins", silently running suite B's tests under suite A's preparation.
|
||||
const distinct = [...new Set(roots.map(r => r?.root ?? null))];
|
||||
if (distinct.length > 1) {
|
||||
const lines = testPaths.map((p, i) => ` ${p} → ${roots[i]?.root ?? '(корень не найден)'}`);
|
||||
die(`Paths belong to different suites — config and hooks would be ambiguous:\n${lines.join('\n')}\n` +
|
||||
`Run them separately, or pass one suite root and narrow with --grep= / --tags=.`);
|
||||
}
|
||||
const suiteRoot = roots[0]?.root ?? startDirs[0];
|
||||
const suiteRootFound = !!roots[0];
|
||||
const configPath = resolve(suiteRoot, 'webtest.config.mjs');
|
||||
let config = {};
|
||||
if (existsSync(configPath)) {
|
||||
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
|
||||
config = mod.default || {};
|
||||
}
|
||||
const severityIndex = buildSeverityIndex(config);
|
||||
|
||||
// Build context registry: name → url. Supports config.contexts or single config.url / CLI url.
|
||||
const contextSpecs = {};
|
||||
let defaultContextName = 'default';
|
||||
const defaultIsolation = config.isolation || 'tab';
|
||||
if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) {
|
||||
for (const [n, spec] of Object.entries(config.contexts)) {
|
||||
contextSpecs[n] = { ...spec };
|
||||
}
|
||||
defaultContextName = config.defaultContext || Object.keys(config.contexts)[0];
|
||||
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
|
||||
} else {
|
||||
const fallbackUrl = url || config.url;
|
||||
// Name the real problem: with no suite root there is no config to take a URL from — and,
|
||||
// more dangerously, no `_hooks.mjs` either. The old wording talked only about the URL and
|
||||
// sent readers looking in the wrong place.
|
||||
if (!fallbackUrl) {
|
||||
die(suiteRootFound
|
||||
? `No URL: ${configPath} defines neither "contexts" nor "url", and --url= was not given.`
|
||||
: `Suite root not found above "${testPaths[0]}" — no webtest.config.mjs / _hooks.mjs up to ` +
|
||||
`the repository (or working) directory, so there is no URL and no stand preparation.\n` +
|
||||
`Pass the suite root (e.g. tests/myapp/) and narrow with --grep= / --tags=, or give --url=.`);
|
||||
}
|
||||
contextSpecs.default = { url: fallbackUrl };
|
||||
}
|
||||
if (!contextSpecs[defaultContextName]) {
|
||||
die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||
}
|
||||
if (!url) url = contextSpecs[defaultContextName].url;
|
||||
|
||||
// Context-pool config (license management). All three optional; without them the runner keeps
|
||||
// its legacy behavior: default stays open, contexts accumulate, no eviction.
|
||||
// maxContexts — cap on simultaneous 1C sessions (null = unlimited).
|
||||
// contextPolicy — 'reuse' (keep open within the cap) | 'strict' (close a test's non-pinned
|
||||
// contexts right after it, to release licenses ASAP).
|
||||
// pinnedContexts — never evicted by LRU. Defaults to [defaultContext] so today's "default is
|
||||
// never closed between tests" holds; set [] to make default evictable.
|
||||
let maxContexts = null;
|
||||
if (config.maxContexts != null) {
|
||||
if (!Number.isInteger(config.maxContexts) || config.maxContexts < 1) {
|
||||
die(`Invalid maxContexts=${config.maxContexts} (expected a positive integer or omit for unlimited)`);
|
||||
}
|
||||
maxContexts = config.maxContexts;
|
||||
}
|
||||
const contextPolicy = config.contextPolicy == null ? 'reuse' : config.contextPolicy;
|
||||
if (!['reuse', 'strict'].includes(contextPolicy)) {
|
||||
die(`Invalid contextPolicy="${contextPolicy}" (expected 'reuse' or 'strict')`);
|
||||
}
|
||||
const pinnedContexts = Array.isArray(config.pinnedContexts) ? config.pinnedContexts : [defaultContextName];
|
||||
for (const n of pinnedContexts) {
|
||||
if (!contextSpecs[n]) die(`pinnedContexts entry "${n}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||
}
|
||||
const pinnedSet = new Set(pinnedContexts);
|
||||
// LRU usage order — oldest first, freshest last. Drives eviction under a maxContexts cap.
|
||||
const lruOrder = [];
|
||||
|
||||
// Apply config defaults (CLI flags override)
|
||||
if (!tags && config.tags) tags = config.tags;
|
||||
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
||||
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
||||
opts.globalTimeout = ownArgs.some(a => a.startsWith('--global-timeout=')) ? opts.globalTimeout : (config.globalTimeout || opts.globalTimeout);
|
||||
|
||||
// Per-key deadline overrides. Defaults suit a light stand; a heavy application may honestly
|
||||
// need longer (a big form's resetState, a slow close). Unknown keys are a typo, not a wish —
|
||||
// fail fast rather than silently ignoring an override the author believed was in effect.
|
||||
if (config.deadlines) {
|
||||
for (const [k, v] of Object.entries(config.deadlines)) {
|
||||
if (!(k in D)) die(`Invalid deadlines.${k} in config (expected one of: ${Object.keys(D).join(', ')})`);
|
||||
if (typeof v !== 'number' || !(v > 0)) die(`Invalid deadlines.${k}=${v} (expected a positive number of ms)`);
|
||||
D[k] = v;
|
||||
}
|
||||
}
|
||||
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
||||
browser.setPreserveClipboard(false);
|
||||
}
|
||||
opts.record = opts.record || !!config.record;
|
||||
opts.screenshot = opts.screenshot || config.screenshot || 'on-failure';
|
||||
if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) {
|
||||
die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`);
|
||||
}
|
||||
if (!['json', 'allure', 'junit'].includes(opts.format)) {
|
||||
die(`Invalid --format=${opts.format} (expected json|allure|junit)`);
|
||||
}
|
||||
if (opts.format === 'junit' && !opts.report) {
|
||||
die('--format=junit requires --report=path.xml');
|
||||
}
|
||||
// `--report=-` means "machine report to stdout" (Unix `-` convention).
|
||||
// Only meaningful for streamable formats (json/junit); allure is a directory.
|
||||
const reportToStdout = opts.report === '-';
|
||||
if (reportToStdout && opts.format === 'allure') {
|
||||
die('--report=- (stdout) is not valid with --format=allure: allure emits a directory of files, not a single stream. Use --report-dir=<dir> instead.');
|
||||
}
|
||||
const reportDir = opts.reportDir
|
||||
? resolve(opts.reportDir)
|
||||
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : suiteRoot);
|
||||
if (opts.screenshot !== 'off') {
|
||||
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
||||
// 1C-error screenshots (taken inside the action wrapper) default to a single
|
||||
// fixed file at the skill root — outside reportDir and shared by every test.
|
||||
// Point them at reportDir so each failure keeps its own attachable file.
|
||||
setErrorShotDir(reportDir);
|
||||
}
|
||||
|
||||
// Discover test files
|
||||
const testFiles = discoverTests(testPaths);
|
||||
if (!testFiles.length) die(`No *.test.mjs files found in ${testPaths.join(', ')}`);
|
||||
|
||||
// Import and filter tests
|
||||
const tests = [];
|
||||
let hasOnly = false;
|
||||
for (const file of testFiles) {
|
||||
const mod = await import('file:///' + file.replace(/\\/g, '/'));
|
||||
const base = {
|
||||
// Relative to the SUITE ROOT, not to the passed path — otherwise the same test gets a
|
||||
// different id depending on how it was launched (`sales/01-x.test.mjs` vs `01-x.test.mjs`),
|
||||
// and Allure history / JUnit trends treat the two as unrelated tests.
|
||||
file: relative(suiteRoot, file).replace(/\\/g, '/'),
|
||||
name: mod.name || basename(file, '.test.mjs'),
|
||||
tags: mod.tags || [],
|
||||
timeout: mod.timeout || opts.timeout,
|
||||
skip: mod.skip || false,
|
||||
only: mod.only || false,
|
||||
setup: mod.setup,
|
||||
teardown: mod.teardown,
|
||||
fn: mod.default,
|
||||
param: undefined,
|
||||
context: mod.context || null,
|
||||
contexts: Array.isArray(mod.contexts) ? mod.contexts : null,
|
||||
severity: typeof mod.severity === 'string' ? mod.severity : null,
|
||||
};
|
||||
if (base.only) hasOnly = true;
|
||||
if (Array.isArray(mod.params) && mod.params.length) {
|
||||
for (let i = 0; i < mod.params.length; i++) {
|
||||
const p = mod.params[i];
|
||||
const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`;
|
||||
tests.push({ ...base, name, param: p });
|
||||
}
|
||||
} else {
|
||||
tests.push(base);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter
|
||||
const filtered = tests.filter(t => {
|
||||
if (hasOnly && !t.only) return false;
|
||||
if (tags && !tags.some(tag => t.tags.includes(tag))) return false;
|
||||
if (grep && !grep.test(t.name)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Load hooks
|
||||
const hooksPath = resolve(suiteRoot, '_hooks.mjs');
|
||||
let hooks = {};
|
||||
if (existsSync(hooksPath)) {
|
||||
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
// Human-readable report goes to stdout (test-runner convention: jest/pytest/playwright).
|
||||
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
|
||||
const W = reportToStdout ? process.stderr : process.stdout;
|
||||
W.write(`\nweb-test -- ${url}\n`);
|
||||
// Always name the resolved suite root: a climb that landed on the wrong directory is then
|
||||
// visible in the first line of output instead of being diagnosed from symptoms later.
|
||||
const rel = (p) => relative(process.cwd(), p).replace(/\\/g, '/') || '.';
|
||||
const shownPaths = testPaths.map(p => rel(resolve(p))).filter(p => p !== rel(suiteRoot));
|
||||
W.write(`Running ${filtered.length} tests from ${rel(suiteRoot)}/`);
|
||||
W.write(shownPaths.length ? ` (paths: ${shownPaths.join(', ')})\n\n` : `\n\n`);
|
||||
if (!suiteRootFound) {
|
||||
// Not fatal — a one-off test outside any suite is legitimate. But a missing suite root also
|
||||
// means no `_hooks.mjs` was even looked for above, so the stand is whatever it was.
|
||||
process.stderr.write(`! no suite root (webtest.config.mjs / _hooks.mjs) found above ${rel(startDirs[0])} — running without hooks\n`);
|
||||
}
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const results = [];
|
||||
let passCount = 0, failCount = 0, skipCount = 0;
|
||||
|
||||
// Per-test diagnostics are BUFFERED and flushed right after that test's ✓/✗ line.
|
||||
// A test's cleanup runs before its result is printed, so writing straight to the stream put
|
||||
// `! …` lines ABOVE the test they belong to — i.e. visually under the PREVIOUS test's result.
|
||||
// Anyone reading the log (a model included) attributes them to the wrong test; that misreading
|
||||
// already cost this session a wrong conclusion. Outside a test (hooks, final teardown) there is
|
||||
// nothing to attach to, so lines go straight out.
|
||||
let diagSink = null;
|
||||
const emit = (line) => { if (diagSink) diagSink.push(line); else W.write(line); };
|
||||
const flushDiag = () => {
|
||||
if (!diagSink) return;
|
||||
for (const line of diagSink) W.write(line);
|
||||
diagSink = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded best-effort await: the replacement for `try { await x } catch {}`.
|
||||
* Same tolerance for failure, but a call that never settles can no longer stall the run,
|
||||
* and every breach leaves a visible line instead of a silent 29-minute stall.
|
||||
*/
|
||||
async function bounded(promise, ms, label) {
|
||||
const r = await softDeadline(promise, ms, label);
|
||||
if (!r.ok) emit(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset one context between tests — and only reuse it if the reset actually WORKED.
|
||||
*
|
||||
* Reusing a context whose UI was not cleaned leaks someone else's open form into the next test:
|
||||
* silent drift instead of a visible error, the worst possible outcome. Two ways to end up there,
|
||||
* and both must lead here:
|
||||
* - the reset breached its deadline (badly-sized budget, wedged page);
|
||||
* - the reset ran to completion but did not clean anything (a modal that refuses to close) —
|
||||
* this one used to pass as success, because `bounded` only reports timeouts and throws.
|
||||
* Either way: destroy the slot, ensureContext recreates a clean one. The cost is a relaunch,
|
||||
* never a wrong test result.
|
||||
*/
|
||||
async function resetOrAbort(cn, ctx) {
|
||||
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
|
||||
if (!sw.ok) return false;
|
||||
const r = await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
|
||||
if (r.ok && r.value?.clean) return true;
|
||||
|
||||
if (r.ok) {
|
||||
// Name what stayed open — otherwise the next investigation starts from archaeology.
|
||||
const v = r.value || {};
|
||||
const what = v.title ? `"${v.title}"` : `#${v.form}`;
|
||||
emit(` ! resetState(${cn}): not clean — form ${what}${v.modal ? ' (modal)' : ''} still open` +
|
||||
` after ${v.attempts} close attempt(s)` +
|
||||
`${v.lastError ? `, last error: ${v.lastError.message.split('\n')[0]}` : ''}\n`);
|
||||
}
|
||||
emit(` ! context "${cn}" left dirty — aborting it, the next test gets a fresh one\n`);
|
||||
await bounded(browser.abortContext(cn), D.closeContext, `abortContext(${cn})`);
|
||||
dropLru(lruOrder, cn);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bumped for every attempt. A timed-out test's body keeps running — a promise cannot be
|
||||
// cancelled, so when its pending call finally rejects, its own `finally` would go on to
|
||||
// drive the UI of whichever test is running by then. Silent cross-test corruption.
|
||||
//
|
||||
// The run shares one `ctx` object (hooks hold it too), so an epoch stamped on ctx could not
|
||||
// tell the zombie from the live caller — both are the same object. Each attempt therefore
|
||||
// gets its own Proxy view bound to its epoch; calls through a stale view throw.
|
||||
let abortEpoch = 0;
|
||||
function makeTestCtx(base, epoch) {
|
||||
return new Proxy(base, {
|
||||
get(target, prop, recv) {
|
||||
const v = Reflect.get(target, prop, recv);
|
||||
if (typeof v !== 'function') return v;
|
||||
return (...args) => {
|
||||
if (epoch !== abortEpoch) {
|
||||
throw new Error(`test abandoned (timeout) — blocked a late ${String(prop)}() call from its body; it would have hit the next test`);
|
||||
}
|
||||
return v.apply(target, args);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildReport(state) {
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
return {
|
||||
runner: 'web-test', url, startedAt, finishedAt: new Date().toISOString(),
|
||||
state,
|
||||
duration: totalDuration,
|
||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||
tests: results,
|
||||
};
|
||||
}
|
||||
|
||||
let allureWritten = false;
|
||||
/**
|
||||
* Record a finished test AND persist it immediately. The report used to be written only
|
||||
* after the loop, so a single hang destroyed every result collected so far.
|
||||
* writeAllure([tr]) is byte-identical to the batch call: it mints its own uuid per test and
|
||||
* severityIndex is read-only.
|
||||
*/
|
||||
function recordResult(tr) {
|
||||
results.push(tr);
|
||||
if (opts.format === 'allure') {
|
||||
try { writeAllure([tr], reportDir, severityIndex); allureWritten = true; } catch (e) { W.write(` ! allure write: ${e.message}\n`); }
|
||||
} else if (opts.format === 'json' && opts.report && !reportToStdout) {
|
||||
try { writeFileSync(resolve(opts.report), JSON.stringify(buildReport('partial'), null, 2)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
||||
const hookEnv = { hookArgs, log: hookLog, config };
|
||||
// Deliberately unbounded and allowed to throw: prepare() rebuilds the stand (db-create +
|
||||
// load + update), whose honest duration depends on the application's size — a deadline here
|
||||
// would cut a legitimate rebuild. And its failure must stay fatal: proceeding into a run
|
||||
// without a stand turns one clear error into a screenful of confusing ones.
|
||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||
|
||||
/** Force-release every open context (frees 1C licenses), then drop the browser. */
|
||||
async function shutdownAll() {
|
||||
for (const name of browser.listContexts()) {
|
||||
await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||
}
|
||||
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock ceiling for the whole run. This works even while a test is wedged: a pending
|
||||
* Playwright await does not block the event loop, it is merely an unsettled promise — which
|
||||
* is precisely why the original incident stalled quietly instead of crashing.
|
||||
* Report first (that's what the user needs), hygiene second, exit unconditionally.
|
||||
*/
|
||||
let globalTimer = null;
|
||||
let hardStopping = false;
|
||||
async function hardStop(reason) {
|
||||
if (hardStopping) return;
|
||||
hardStopping = true;
|
||||
W.write(`\n!! ${reason}: run exceeded --global-timeout=${opts.globalTimeout}ms — forcing shutdown\n`);
|
||||
abortEpoch++;
|
||||
// Last-resort exit if the shutdown itself wedges. Referenced on purpose: it must survive.
|
||||
const bailout = setTimeout(() => process.exit(3), 20000);
|
||||
try { writeFinalReport('aborted'); } catch (e) { W.write(` ! report: ${e.message}\n`); }
|
||||
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||
clearTimeout(bailout);
|
||||
process.exit(2);
|
||||
}
|
||||
if (opts.globalTimeout > 0) {
|
||||
globalTimer = setTimeout(() => { void hardStop('global-timeout'); }, opts.globalTimeout);
|
||||
}
|
||||
|
||||
// Lazy context creation
|
||||
async function ensureContext(name) {
|
||||
if (browser.hasContext(name)) return;
|
||||
const spec = contextSpecs[name];
|
||||
if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||
await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation });
|
||||
if (hooks.afterOpenContext && hookCtx) {
|
||||
try { await hooks.afterOpenContext(hookCtx, name, spec); }
|
||||
catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
}
|
||||
|
||||
let hookCtx = null;
|
||||
|
||||
function wrapCloseContextHook(target) {
|
||||
const orig = target.closeContext;
|
||||
if (typeof orig !== 'function') return;
|
||||
target.closeContext = async (name) => {
|
||||
if (hooks.beforeCloseContext) {
|
||||
try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
return await orig(name);
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Connect: create default context up front (hosts beforeAll / hooks). It is NOT permanently
|
||||
// pinned — under a maxContexts cap it becomes an LRU eviction candidate unless it is listed in
|
||||
// pinnedContexts. Register it in the LRU order.
|
||||
//
|
||||
// This one call needs its own catch: it sits in a try that has only a `finally`, and run.mjs
|
||||
// does not wrap cmdTest — so a throw here would escape as a raw stack trace and skip the
|
||||
// report entirely. A blocked startup (e.g. no free 1C licence) dooms the whole run anyway,
|
||||
// so say it once, keep the report, and leave.
|
||||
try {
|
||||
await ensureContext(defaultContextName);
|
||||
} catch (e) {
|
||||
W.write(`\n!! cannot open context "${defaultContextName}": ${e.message}\n\n`);
|
||||
try { writeFinalReport('aborted'); } catch {}
|
||||
// process.exit skips the `finally` below, and killing the process does NOT release a 1C
|
||||
// seance — so release what we hold explicitly before leaving.
|
||||
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||
process.exit(1);
|
||||
}
|
||||
touchLru(lruOrder, defaultContextName);
|
||||
|
||||
const ctx = buildContext({ noRecord: false });
|
||||
ctx.assert = createAssertions();
|
||||
ctx.log = (...a) => { /* per-test, overridden below */ };
|
||||
wrapCloseContextHook(ctx);
|
||||
hookCtx = ctx;
|
||||
|
||||
// Default context was created BEFORE hookCtx existed → fire afterOpenContext now.
|
||||
if (hooks.afterOpenContext) {
|
||||
try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); }
|
||||
catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
|
||||
if (hooks.beforeAll) await hooks.beforeAll(ctx);
|
||||
|
||||
let testIdx = 0;
|
||||
for (const t of filtered) {
|
||||
testIdx++;
|
||||
// Buffer this test's diagnostics; they are flushed under its own result line below.
|
||||
diagSink = [];
|
||||
const declaredContexts = t.contexts && t.contexts.length
|
||||
? t.contexts
|
||||
: [t.context || defaultContextName];
|
||||
|
||||
if (t.skip) {
|
||||
const reason = typeof t.skip === 'string' ? t.skip : '';
|
||||
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
||||
flushDiag();
|
||||
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const testContextNames = declaredContexts;
|
||||
try {
|
||||
// Make room in the license pool before opening this test's contexts. Already-open needed
|
||||
// contexts are reused (ensureContext no-ops); LRU-oldest non-pinned contexts are evicted.
|
||||
const plan = planEviction({
|
||||
open: browser.listContexts(),
|
||||
needed: testContextNames,
|
||||
pinned: pinnedSet,
|
||||
max: maxContexts,
|
||||
lruOrder,
|
||||
});
|
||||
if (plan.error) throw new Error(plan.error);
|
||||
// Needed-but-not-yet-open contexts — also serve as a parking fallback when eviction would
|
||||
// close the sole open context (can't closeContext the active slot with no survivor).
|
||||
const toOpenQueue = testContextNames.filter(n => !browser.hasContext(n));
|
||||
for (const name of plan.toEvict) {
|
||||
if (browser.getActiveContext() === name) {
|
||||
let survivor = browser.listContexts().find(n => n !== name);
|
||||
if (!survivor) {
|
||||
// `name` is the only open context. Open a needed one first to park on — room is
|
||||
// guaranteed because we free `name` right after and multi-context implies max>=2.
|
||||
if (browser.listContexts().length < maxContexts && toOpenQueue.length) {
|
||||
const parkName = toOpenQueue.shift();
|
||||
await ensureContext(parkName);
|
||||
survivor = parkName;
|
||||
} else {
|
||||
throw new Error(`cannot evict "${name}": it is the only open context and maxContexts=${maxContexts} leaves no room to switch. Use maxContexts>=2 when tests alternate contexts.`);
|
||||
}
|
||||
}
|
||||
await browser.setActiveContext(survivor);
|
||||
}
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
await browser.closeContext(name);
|
||||
dropLru(lruOrder, name);
|
||||
}
|
||||
for (const cn of testContextNames) await ensureContext(cn);
|
||||
await browser.setActiveContext(testContextNames[0]);
|
||||
touchLru(lruOrder, testContextNames);
|
||||
} catch (e) {
|
||||
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
||||
flushDiag();
|
||||
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||
failCount++;
|
||||
if (opts.bail) break;
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let testResult = null;
|
||||
const maxAttempts = 1 + opts.retry;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const output = [];
|
||||
let steps = [];
|
||||
let currentSteps = steps;
|
||||
let stepIdx = 0;
|
||||
const t0 = Date.now();
|
||||
|
||||
ctx.testInfo = {
|
||||
name: t.name,
|
||||
file: basename(t.file),
|
||||
filePath: t.file,
|
||||
tags: t.tags,
|
||||
timeout: t.timeout,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
param: t.param,
|
||||
contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])),
|
||||
primaryContext: testContextNames[0],
|
||||
};
|
||||
ctx.testResult = null;
|
||||
|
||||
let videoFile = null;
|
||||
if (opts.record) {
|
||||
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
||||
const rec = await bounded(browser.startRecording(videoFile, { force: true }), D.startRecording, 'startRecording');
|
||||
if (!rec.ok) videoFile = null;
|
||||
}
|
||||
|
||||
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
||||
ctx.step = async (name, fn) => {
|
||||
const s = { name, start: Date.now(), status: 'passed', steps: [] };
|
||||
currentSteps.push(s);
|
||||
const prev = currentSteps;
|
||||
currentSteps = s.steps;
|
||||
stepIdx++;
|
||||
const myIdx = stepIdx;
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
s.status = 'failed';
|
||||
s.error = e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
s.stop = Date.now();
|
||||
currentSteps = prev;
|
||||
if (opts.screenshot === 'every-step' && s.status === 'passed') {
|
||||
try {
|
||||
const slug = slugify(name);
|
||||
const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`);
|
||||
const png = await browser.screenshot();
|
||||
writeFileSync(file, png);
|
||||
s.screenshot = file;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scopedKeys = [];
|
||||
if (t.contexts && t.contexts.length) {
|
||||
for (const cn of t.contexts) {
|
||||
ctx[cn] = buildScopedContext(cn);
|
||||
wrapCloseContextHook(ctx[cn]);
|
||||
scopedKeys.push(cn);
|
||||
}
|
||||
}
|
||||
|
||||
const myEpoch = ++abortEpoch;
|
||||
let timedOut = false;
|
||||
|
||||
try {
|
||||
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
||||
if (t.setup) await t.setup(ctx);
|
||||
|
||||
let timeoutTimer;
|
||||
try {
|
||||
await Promise.race([
|
||||
t.fn(makeTestCtx(ctx, myEpoch), t.param),
|
||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => { timedOut = true; reject(new Error(`Timeout (${t.timeout}ms)`)); }, t.timeout); }),
|
||||
]);
|
||||
} finally {
|
||||
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
||||
// since the success path never calls process.exit(), node can't exit until
|
||||
// it fires (up to `timeout` ms after the last test finished).
|
||||
clearTimeout(timeoutTimer);
|
||||
}
|
||||
|
||||
// Bounded even on the green path: a test can pass and still leave the UI in a state
|
||||
// where resetState wedges — that would stall the run just as dead as a failure would.
|
||||
if (t.teardown) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
||||
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||
for (const cn of testContextNames) {
|
||||
if (!browser.hasContext(cn)) continue;
|
||||
await resetOrAbort(cn, ctx);
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||
}
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||
lastError = null;
|
||||
break;
|
||||
|
||||
} catch (e) {
|
||||
// ── Timeout: diagnose, then destroy what hung. Everything below this point that
|
||||
// goes through the renderer (screenshot, teardown, resetState) is pointless on a
|
||||
// wedged page and would itself hang — so on `hang` we skip straight to the abort.
|
||||
let diagnosis = null;
|
||||
if (timedOut) {
|
||||
const active = browser.getActiveContext();
|
||||
const probe = active ? await browser.probeContext(active, { ms: D.probe }) : null;
|
||||
const diag = active ? browser.getContextDiagnostics(active) : null;
|
||||
const verdict = !probe ? 'no-context'
|
||||
: !probe.browserAlive ? 'browser-dead'
|
||||
: !probe.rendererAlive ? 'hang'
|
||||
: diag?.net.inFlight > 0 ? 'slow-network'
|
||||
: 'slow';
|
||||
|
||||
const lines = [
|
||||
`verdict: ${verdict}` + (verdict === 'hang' ? ' (renderer unresponsive, browser alive)' : ''),
|
||||
` context "${active}" [${diag?.isolation}] · renderer probe: ${probe?.rendererAlive ? `ok in ${probe.rendererMs}ms` : `timed out at ${D.probe}ms`}` +
|
||||
` · browser probe: ${probe?.browserAlive ? `ok in ${probe.browserMs}ms` : `timed out at ${D.probe}ms`}`,
|
||||
` network: ${diag?.net.inFlight} in flight, last event ${diag?.msSinceLastNetEvent != null ? (diag.msSinceLastNetEvent / 1000).toFixed(1) + 's ago' : 'never'}` +
|
||||
` (${diag?.net.requests} req / ${diag?.net.responses} resp)`,
|
||||
];
|
||||
// Same failure, different remedy — say which, or the next person guesses.
|
||||
if (verdict === 'slow' || verdict === 'slow-network') {
|
||||
lines.push(' no hang detected — the test is simply slower than its declared timeout; raise `export const timeout`');
|
||||
}
|
||||
|
||||
if (verdict === 'hang' || verdict === 'browser-dead') {
|
||||
const ab = await bounded(browser.abortContext(active), D.abortAll, 'abortContext');
|
||||
const r = ab.ok ? ab.value : null;
|
||||
lines.push(` recovery: ${r ? `context aborted (logout: ${r.logout}, closed: ${r.closed}${r.escalated ? ', escalated to browser kill' : ''})` : 'abort failed'} — next test recreates it`);
|
||||
if (r?.notes?.length) lines.push(` notes: ${r.notes.join('; ')}`);
|
||||
if (active) dropLru(lruOrder, active);
|
||||
}
|
||||
diagnosis = { verdict, probe, net: diag?.net };
|
||||
e.message = `${e.message} — ${lines[0]}`;
|
||||
output.push(...lines);
|
||||
emit(lines.map(l => ` ${l}\n`).join(''));
|
||||
}
|
||||
|
||||
const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead');
|
||||
|
||||
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
||||
// Skipped on a dead page: it goes through the renderer, so it can only hang.
|
||||
let shotFile = e.onecError?.screenshot;
|
||||
if (!shotFile && opts.screenshot !== 'off' && !dead) {
|
||||
const shot = await bounded(browser.screenshot(), D.screenshot, 'screenshot');
|
||||
if (shot.ok) {
|
||||
try {
|
||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||
writeFileSync(shotFile, shot.value);
|
||||
} catch { shotFile = undefined; }
|
||||
}
|
||||
} else if (shotFile && dirname(resolve(shotFile)) !== reportDir) {
|
||||
// Shot came from a context built before setErrorShotDir (e.g. a server
|
||||
// session started earlier): reporters attach by basename, so anything
|
||||
// outside reportDir is a dead link. Move it in under a unique name.
|
||||
const dest = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||
try {
|
||||
renameSync(resolve(shotFile), dest);
|
||||
shotFile = dest;
|
||||
} catch {
|
||||
try {
|
||||
copyFileSync(resolve(shotFile), dest);
|
||||
try { unlinkSync(resolve(shotFile)); } catch {}
|
||||
shotFile = dest;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (t.teardown && !dead) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError, diagnosis };
|
||||
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
||||
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||
// resetState drives the UI (up to 10 × getFormState + closeForm, all page.evaluate).
|
||||
// On a dead page it cannot succeed — the slot is already gone anyway.
|
||||
if (!dead) {
|
||||
for (const cn of testContextNames) {
|
||||
if (!browser.hasContext(cn)) continue;
|
||||
await resetOrAbort(cn, ctx);
|
||||
}
|
||||
}
|
||||
for (const k of scopedKeys) delete ctx[k];
|
||||
|
||||
if (videoFile) {
|
||||
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||
}
|
||||
lastError = errInfo;
|
||||
const dur = elapsed(t0);
|
||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
||||
|
||||
// A wedged renderer is not flakiness — retrying just buys another full timeout
|
||||
// plus another abort. Stop after the first hang.
|
||||
if (dead) break;
|
||||
}
|
||||
}
|
||||
|
||||
// strict policy: release this test's non-pinned contexts right after it (all attempts done),
|
||||
// instead of keeping them for reuse. Frees 1C licenses ASAP on shared/tight stands. Parks
|
||||
// active on a survivor before closing; never closes the sole remaining context.
|
||||
if (contextPolicy === 'strict') {
|
||||
for (const name of testContextNames) {
|
||||
if (pinnedSet.has(name) || !browser.hasContext(name)) continue;
|
||||
if (browser.getActiveContext() === name) {
|
||||
const survivor = browser.listContexts().find(n => n !== name);
|
||||
if (!survivor) continue; // can't close the sole active context — leave it open
|
||||
try { await browser.setActiveContext(survivor); } catch {}
|
||||
}
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
try { await browser.closeContext(name); } catch {}
|
||||
dropLru(lruOrder, name);
|
||||
}
|
||||
}
|
||||
|
||||
recordResult(testResult);
|
||||
|
||||
if (testResult.status === 'passed') {
|
||||
passCount++;
|
||||
W.write(` ✓ ${t.name} (${testResult.duration}s)\n`);
|
||||
} else {
|
||||
failCount++;
|
||||
W.write(` ✗ ${t.name} (${testResult.duration}s)\n`);
|
||||
printSteps(W, testResult.steps, ' ');
|
||||
if (lastError?.message) W.write(` ${lastError.message}\n`);
|
||||
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
||||
}
|
||||
|
||||
flushDiag();
|
||||
|
||||
if (opts.bail && testResult.status === 'failed') break;
|
||||
}
|
||||
|
||||
// Out of the per-test scope (also on `break`): afterAll and the final teardown have no test
|
||||
// to nest under, so their diagnostics go straight to the stream again.
|
||||
flushDiag();
|
||||
|
||||
if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll');
|
||||
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
// Per-context teardown
|
||||
try {
|
||||
const remaining = browser.listContexts();
|
||||
if (remaining.length > 0) {
|
||||
const survivor = remaining[0];
|
||||
await bounded(browser.setActiveContext(survivor), D.setActive, `setActiveContext(${survivor})`);
|
||||
for (let i = remaining.length - 1; i >= 1; i--) {
|
||||
const name = remaining[i];
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
// closeContext goes through the page (logout + close). If it breaches, fall back to
|
||||
// abortContext: it logs out from Node, which is the path that survives a dead page.
|
||||
const cc = await bounded(browser.closeContext(name), D.closeContext, `closeContext(${name})`);
|
||||
if (!cc.ok) await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||
}
|
||||
if (hooks.beforeCloseContext && hookCtx) {
|
||||
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
||||
catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||
if (hooks.cleanup) await bounded(hooks.cleanup(hookEnv), D.hooks, 'hooks.cleanup');
|
||||
}
|
||||
|
||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
||||
|
||||
writeFinalReport('complete');
|
||||
|
||||
if (failCount > 0) process.exit(1);
|
||||
|
||||
/**
|
||||
* Allure results are already on disk (recordResult writes each test as it finishes), so this
|
||||
* only completes the formats that need whole-run totals. Also called from hardStop, where
|
||||
* `state` is 'aborted' and `results` holds whatever finished before the ceiling hit.
|
||||
*/
|
||||
function writeFinalReport(state) {
|
||||
const report = buildReport(state);
|
||||
if (opts.format === 'allure') {
|
||||
// Guard against a result-producing path that skipped recordResult; normally a no-op.
|
||||
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
|
||||
syncAllureExtras(suiteRoot, reportDir);
|
||||
} else if (opts.format === 'junit') {
|
||||
if (reportToStdout) process.stdout.write(buildJUnit(report, suiteRoot) + '\n');
|
||||
else writeFileSync(resolve(opts.report), buildJUnit(report, suiteRoot));
|
||||
} else if (reportToStdout) {
|
||||
out(report);
|
||||
} else if (opts.report) {
|
||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// web-test cli/test-runner/context-pool v1.0 — pure context-pool planner (LRU eviction).
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Decides which already-open contexts (each = one live 1C session = one license) to evict
|
||||
// so the next test's declared contexts fit within `maxContexts` simultaneous sessions.
|
||||
// Pure functions, no browser — unit-tested in context-pool.test.mjs.
|
||||
|
||||
/**
|
||||
* @param {object} p
|
||||
* @param {string[]} p.open currently open context names (live 1C sessions)
|
||||
* @param {string[]} p.needed context names the next test declares
|
||||
* @param {Set<string>|string[]} [p.pinned] never-evict context names
|
||||
* @param {number|null} [p.max] simultaneous-session cap; null/undefined = unlimited
|
||||
* @param {string[]} [p.lruOrder] usage order, oldest first / freshest last
|
||||
* @returns {{ toEvict: string[], error: string|null }}
|
||||
*/
|
||||
export function planEviction({ open = [], needed = [], pinned = [], max = null, lruOrder = [] }) {
|
||||
const pinnedSet = pinned instanceof Set ? pinned : new Set(pinned);
|
||||
const neededSet = new Set(needed);
|
||||
const openSet = new Set(open);
|
||||
|
||||
// Unlimited pool → never evict (back-compat: behaves like the pre-pool runner).
|
||||
if (max == null) return { toEvict: [], error: null };
|
||||
|
||||
// Lower bound that must stay live regardless of eviction: this test's needed contexts, plus
|
||||
// pinned contexts that are ALREADY open (pinned = "don't evict while open", NOT "always open" —
|
||||
// a pinned context that is currently closed does not count against this test's budget).
|
||||
const mustStay = new Set(needed);
|
||||
for (const p of pinnedSet) if (openSet.has(p)) mustStay.add(p);
|
||||
if (mustStay.size > max) {
|
||||
return {
|
||||
toEvict: [],
|
||||
error: `context pool exhausted: this test needs ${mustStay.size} simultaneous 1C sessions `
|
||||
+ `(declared contexts + already-open pinned) but maxContexts=${max}. `
|
||||
+ `Raise maxContexts, reduce declared contexts, or shrink pinnedContexts.`,
|
||||
};
|
||||
}
|
||||
|
||||
// projected = everything live once we open `needed`. If it already fits, nothing to evict.
|
||||
const projected = new Set([...open, ...needed]);
|
||||
if (projected.size <= max) return { toEvict: [], error: null };
|
||||
|
||||
// Evictable = open, not pinned, not needed — oldest first by lruOrder.
|
||||
const evictable = [];
|
||||
for (const name of lruOrder) {
|
||||
if (openSet.has(name) && !pinnedSet.has(name) && !neededSet.has(name)) evictable.push(name);
|
||||
}
|
||||
// Any open evictable missing from lruOrder → treat as oldest (evict first).
|
||||
for (const name of open) {
|
||||
if (!lruOrder.includes(name) && !pinnedSet.has(name) && !neededSet.has(name)) {
|
||||
evictable.unshift(name);
|
||||
}
|
||||
}
|
||||
|
||||
const toEvict = [];
|
||||
let size = projected.size;
|
||||
for (const name of evictable) {
|
||||
if (size <= max) break;
|
||||
toEvict.push(name);
|
||||
size--;
|
||||
}
|
||||
// Guaranteed size <= max here: after removing all evictable, projected collapses to
|
||||
// (open ∩ pinned) ∪ needed == mustStay, and mustStay.size <= max passed the guard above.
|
||||
return { toEvict, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move `names` to the fresh end of the LRU order (most-recently-used last). Mutates and returns
|
||||
* `lruOrder`. Idempotent per name — existing entries are relocated, not duplicated.
|
||||
*/
|
||||
export function touchLru(lruOrder, names) {
|
||||
for (const n of (Array.isArray(names) ? names : [names])) {
|
||||
const i = lruOrder.indexOf(n);
|
||||
if (i >= 0) lruOrder.splice(i, 1);
|
||||
lruOrder.push(n);
|
||||
}
|
||||
return lruOrder;
|
||||
}
|
||||
|
||||
/** Remove `names` from the LRU order (e.g. after a context is closed). Mutates and returns it. */
|
||||
export function dropLru(lruOrder, names) {
|
||||
for (const n of (Array.isArray(names) ? names : [names])) {
|
||||
const i = lruOrder.indexOf(n);
|
||||
if (i >= 0) lruOrder.splice(i, 1);
|
||||
}
|
||||
return lruOrder;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// web-test cli/test-runner/discover v1.4 — test file discovery + state reset between tests
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Accepts a single path or an array of paths (files and/or dirs). Each .test.mjs file is
|
||||
// taken directly; each directory is walked recursively (skipping _ / . prefixes). Results
|
||||
// are deduped and sorted — sorting preserves the numeric-prefix order the suite relies on
|
||||
// (00-, 01-, …) even when paths are listed out of order.
|
||||
export function discoverTests(testPaths) {
|
||||
const paths = Array.isArray(testPaths) ? testPaths : [testPaths];
|
||||
const files = [];
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
|
||||
const full = resolve(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.test.mjs')) files.push(full);
|
||||
}
|
||||
}
|
||||
for (const p of paths) {
|
||||
const full = resolve(p);
|
||||
if (full.endsWith('.test.mjs')) {
|
||||
if (existsSync(full)) files.push(full);
|
||||
} else if (existsSync(full)) {
|
||||
walk(full);
|
||||
}
|
||||
}
|
||||
return [...new Set(files)].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the context to a clean desktop between tests — and REPORT whether that worked.
|
||||
*
|
||||
* The verdict is the point. closeForm does not throw when a form refuses to close: it returns
|
||||
* `{closed:false}`, and this loop used to drop that on the floor, so a context with someone else's
|
||||
* modal still open went back into the pool as "clean" and the next test clicked into it. Measured
|
||||
* on the pilot's stand: 10 idle iterations, `closed:false` every time, state unchanged — and the
|
||||
* runner called it a success.
|
||||
*
|
||||
* @returns {Promise<{clean: boolean, attempts: number, form?: any, title?: string, modal?: boolean, lastError?: Error}>}
|
||||
* `clean:false` also when the check itself failed — not being able to confirm is not being clean.
|
||||
*/
|
||||
export async function resetState(ctx) {
|
||||
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
||||
let attempts = 0;
|
||||
let lastError = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
const state = await ctx.getFormState();
|
||||
// form === null means no form open (desktop). form === 0 is a real background form
|
||||
// 1C exposes in some states — must still close it to fully reset.
|
||||
if (state.form == null) return { clean: true, attempts };
|
||||
attempts++;
|
||||
const r = await ctx.closeForm({ save: false });
|
||||
// The platform found nothing closable → this is the desktop, however many forms sit on it.
|
||||
// Without this the check would be "form == null", which is only true for an EMPTY desktop:
|
||||
// on a real application the home page keeps its own forms (measured: form=5, formCount=3,
|
||||
// no cross), so the old rule declared a perfectly clean context dirty after every test.
|
||||
if (r?.nothingToClose) return { clean: true, attempts, desktop: true };
|
||||
// Deliberately NOT bailing out on `closed:false`: measured A/B on the live suite — a dirty
|
||||
// «Приходная накладная *» reports closed:false on the first round and closes on a later one,
|
||||
// so an early exit aborted a context that was about to be clean. `closed` compares form
|
||||
// numbers, so an intermediate step (a popup going away) reads as "nothing happened" even
|
||||
// though progress was made. The verdict below judges the END state, which is what matters.
|
||||
} catch (e) { lastError = e; break; }
|
||||
}
|
||||
|
||||
// Control check — the loop proves nothing on its own: it can also exit via `catch` above.
|
||||
try {
|
||||
const state = await ctx.getFormState();
|
||||
if (state.form == null) return { clean: true, attempts };
|
||||
return {
|
||||
clean: false, attempts, lastError,
|
||||
form: state.form,
|
||||
// state.title is the form's own caption; activeTab reads the open-windows panel, which the
|
||||
// user can switch off — keep it only as the fallback it always was.
|
||||
title: state.title || state.activeTab || null,
|
||||
modal: !!state.modal,
|
||||
};
|
||||
} catch (e) {
|
||||
return { clean: false, attempts, lastError: lastError || e };
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// web-test cli/test-runner/suite-root v1.0 — locate the suite root above a given test path
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
|
||||
// Files that MARK a suite root. Both count, not just the config: `webtest.config.mjs` is
|
||||
// optional (a single-URL suite may pass --url= instead), and a suite that ships only
|
||||
// `_hooks.mjs` must still be found — otherwise its stand preparation is silently skipped,
|
||||
// which is worse than any URL error.
|
||||
const MARKERS = ['webtest.config.mjs', '_hooks.mjs'];
|
||||
|
||||
// Files that BOUND the climb. A boundary never selects a root — it only stops the search,
|
||||
// so a wrong boundary degrades to "root not found" (= the pre-v1.9 behaviour plus a clear
|
||||
// message) and can never produce a wrong root. `package.json` is deliberately absent: it
|
||||
// occurs nested and would stop the climb below a legitimate suite root.
|
||||
const BOUNDARIES = ['.git', '.v8-project.json'];
|
||||
|
||||
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
||||
|
||||
/**
|
||||
* Walk up from `startPath` looking for a suite root.
|
||||
*
|
||||
* @param {string} startPath A test file or directory (absolute or cwd-relative).
|
||||
* @param {{cwd?: string}} [opts]
|
||||
* @returns {{root: string, marker: string} | null} null when no marker was found within bounds.
|
||||
*
|
||||
* Stops after examining the first directory that contains `.git` / `.v8-project.json`
|
||||
* (that directory IS examined for markers), or — when neither is met — after examining `cwd`.
|
||||
* A path outside `cwd` degenerates to the filesystem root; the marker requirement still
|
||||
* makes a wrong hit unlikely, and the resolved root is printed in the run banner.
|
||||
*/
|
||||
export function findSuiteRoot(startPath, { cwd = process.cwd() } = {}) {
|
||||
const full = resolve(startPath);
|
||||
let dir = isDir(full) ? full : dirname(full);
|
||||
const cwdAbs = resolve(cwd);
|
||||
|
||||
while (true) {
|
||||
for (const m of MARKERS) {
|
||||
if (existsSync(resolve(dir, m))) return { root: dir, marker: m };
|
||||
}
|
||||
const atBoundary = BOUNDARIES.some(b => existsSync(resolve(dir, b))) || dir === cwdAbs;
|
||||
const parent = dirname(dir);
|
||||
if (atBoundary || parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory a path contributes to root resolution — its own dir for a file, itself for
|
||||
* a directory. Also the fallback root when no marker is found (pre-v1.9 behaviour).
|
||||
*/
|
||||
export function startDirOf(testPath) {
|
||||
const full = resolve(testPath);
|
||||
return isDir(full) ? full : dirname(full);
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
// web-test dom shared v1.10 — embedded JS function constants
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Shared function strings embedded into page.evaluate() generators.
|
||||
* Не экспортируются наружу через dom.mjs facade — внутренняя кухня.
|
||||
*/
|
||||
|
||||
/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id),
|
||||
* e.g. when a second form (drill-down) creates its own alongside a stale one from the first
|
||||
* form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */
|
||||
export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() {
|
||||
const all = document.querySelectorAll('#modalSurface');
|
||||
for (const el of all) { if (el.offsetWidth > 0) return true; }
|
||||
return false;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Click point INSIDE a grid row's first visible text cell — NOT the row-line centre.
|
||||
*
|
||||
* A wide multi-column row's centre `x = line.x + line.width/2` lands far beyond the
|
||||
* form's horizontal viewport (the `.gridLine` spans ALL columns, frozen + scrollable),
|
||||
* so `mouse.click` at that X falls on an overlay outside the visible grid and the row
|
||||
* is never hit — the click silently does nothing. Seen on narrow modal selection forms
|
||||
* with many columns (множественный выбор) and the `not_selectable` bug on selection forms.
|
||||
*
|
||||
* Picks the first visible non-checkbox cell that HAS text (so center-clicking never
|
||||
* toggles a checkbox/picture mark), skips the first column on tree grids (it holds the
|
||||
* expand toggle), and clamps X near the left edge (`min(width/2, 60)`) so a wide first
|
||||
* column still lands in the viewport.
|
||||
*
|
||||
* @param line a `.gridLine` element
|
||||
* @param body the grid's `.gridBody` (for tree detection); may be null
|
||||
* @returns `{ x, y }` rounded, or `null` when the row has no usable cell.
|
||||
*/
|
||||
export const ROW_CLICK_POINT_FN = `function rowClickPoint(line, body) {
|
||||
const isTree = !!(body && body.querySelector('.gridBoxTree'));
|
||||
let cells = [...line.children]
|
||||
.filter(b => b.offsetWidth > 0)
|
||||
.map(b => ({ r: b.getBoundingClientRect(), checkbox: !!b.querySelector('.checkbox'), hasText: !!b.querySelector('.gridBoxText') }));
|
||||
if (isTree && cells.length > 1) cells = cells.slice(1);
|
||||
const pick = cells.find(c => !c.checkbox && c.hasText) || cells.find(c => !c.checkbox) || cells[0];
|
||||
if (!pick) return null;
|
||||
return { x: Math.round(pick.r.x + Math.min(pick.r.width / 2, 60)), y: Math.round(pick.r.y + pick.r.height / 2) };
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Click point inside a stretched text container (group title, hyperlink decoration) —
|
||||
* NOT the container's centre.
|
||||
*
|
||||
* `<base>#title_text` is a flex box; the clickable thing is the nested
|
||||
* `<label class="ellipsis" for="<groupId>">` sized by the text and pinned left. The box
|
||||
* stretches to the width of the group's content, so a group holding a wide table gets a
|
||||
* title 1295px wide around a 166px label: the geometric centre lands on empty space and
|
||||
* the click silently does nothing (measured on the stand — collapsed title 173px, expanded
|
||||
* 1295px, which is why the FIRST toggle worked and every later one did not).
|
||||
*
|
||||
* Same clamp as rowClickPoint: aim near the left edge so a wide box still lands on text.
|
||||
*
|
||||
* @param el container element (`.staticTextHyper` / title text)
|
||||
* @returns `{ x, y }` rounded.
|
||||
*/
|
||||
export const TEXT_CLICK_POINT_FN = `function textClickPoint(el) {
|
||||
const inner = el.firstElementChild;
|
||||
const r = (inner && inner.offsetWidth > 0 ? inner : el).getBoundingClientRect();
|
||||
return { x: Math.round(r.x + Math.min(r.width / 2, 60)), y: Math.round(r.y + r.height / 2) };
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Collapsed state of a form group — single source of truth for getFormState().groups[]
|
||||
* and the click-target resolver.
|
||||
*
|
||||
* 1C lays the form out FLAT: a group's content is not nested inside it but follows as
|
||||
* absolutely-positioned siblings of `<base>#title_div`. Anything derived from "the first
|
||||
* sibling" is unreliable — measured on live forms:
|
||||
* • before a container child comes an empty `.logicGroupContainer` (height 0), spelled
|
||||
* `<child>#group_div` for a table but `<child>_div` for a nested group;
|
||||
* • that wrapper's own display FLIPS between runs (block before the first toggle, none
|
||||
* after) — this is the "readings synced after the first toggle" from the bug report;
|
||||
* • a group's leading nodes can stay `display:none` by their own logic (a table whose
|
||||
* command bar is hidden) while the visible content sits further down the chain.
|
||||
* Neither the wrappers' geometry nor the group's own `<base>_div` can serve as the signal:
|
||||
* all of them are always zero-height.
|
||||
*
|
||||
* Signals, in order:
|
||||
* 1. PopUp — the panel `<base>#panel_div` carries the state directly.
|
||||
* 2. Caret (`ControlRepresentation=Picture`) — `<base>#titleBtn img` is the `hideshow`
|
||||
* sprite, frame `gx`: 0 collapsed, non-zero expanded. Note the polarity is OPPOSITE
|
||||
* to tree nodes in dom/grid.mjs (gx=0 = expanded there) — different sprite.
|
||||
* 3. Otherwise (`TitleHyperlink`, which has no caret, no aria-expanded and no state class
|
||||
* on the title): ownership by INDENT. A group's children sit deeper than its title
|
||||
* (`#title_div` at left:12px → children at 22px), while a free element between groups
|
||||
* sits at the title's own level. So walk the siblings, skip hidden nodes and wrappers
|
||||
* (they are not positioned — left comes back `auto`), and the first VISIBLE node
|
||||
* decides: deeper than the title ⇒ own content ⇒ expanded; same level or shallower
|
||||
* ⇒ that's already someone else, stop. Nothing own and visible ⇒ collapsed, which is
|
||||
* sound because a group with every element hidden is not rendered by the platform at all.
|
||||
* The baseline is the leftmost part of the title BLOCK, not `#title_div` alone: with a
|
||||
* caret the text is pushed right by its width (measured live: caret box 12px, title
|
||||
* 33px, own children 22px), so anchoring on the title alone would read the group's own
|
||||
* child as foreign. Only matters if a caret is present but signal 2 did not fire.
|
||||
* The walk is capped: a group's own nodes sit right after its title, whereas the LAST
|
||||
* collapsed group on a form has no boundary behind it at all — measured live, the first
|
||||
* node with height came 107 siblings later, deep inside an unrelated branch, and would
|
||||
* have been mistaken for the group's content.
|
||||
*
|
||||
* @param base element id prefix without suffix, e.g. `form1_ГруппаТовары`
|
||||
* @returns `true` collapsed, `false` expanded, `null` when the layout is unrecognised.
|
||||
*/
|
||||
export const GROUP_STATE_FN = `function groupCollapsed(base) {
|
||||
const panelDiv = document.getElementById(base + '#panel_div');
|
||||
if (panelDiv) return getComputedStyle(panelDiv).display === 'none';
|
||||
const caret = document.querySelector('[id="' + base + '#titleBtn"] img');
|
||||
const src = caret ? (caret.getAttribute('src') || '') : '';
|
||||
if (src.indexOf('hideshow') !== -1) {
|
||||
const gx = src.match(/[?&]gx=(\\d+)/);
|
||||
if (gx) return gx[1] === '0';
|
||||
}
|
||||
const titleDiv = document.getElementById(base + '#title_div');
|
||||
if (!titleDiv) return null;
|
||||
let titleLeft = parseFloat(getComputedStyle(titleDiv).left);
|
||||
const caretDiv = document.getElementById(base + '#titleBtn_div');
|
||||
const caretLeft = caretDiv ? parseFloat(getComputedStyle(caretDiv).left) : NaN;
|
||||
if (!isNaN(caretLeft) && (isNaN(titleLeft) || caretLeft < titleLeft)) titleLeft = caretLeft;
|
||||
if (isNaN(titleLeft)) return null;
|
||||
let candidates = false, scanned = 0;
|
||||
for (let n = titleDiv.nextElementSibling; n && scanned < 20; n = n.nextElementSibling, scanned++) {
|
||||
if (n.offsetWidth === 0 && n.offsetHeight === 0) { candidates = true; continue; }
|
||||
const left = parseFloat(getComputedStyle(n).left);
|
||||
if (isNaN(left)) { candidates = true; continue; }
|
||||
if (left > titleLeft) return false;
|
||||
break;
|
||||
}
|
||||
return candidates ? true : null;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Single source of truth for column derivation on HEADERLESS grids (no `.gridHead`).
|
||||
* 1C still puts `colindex` on body cells, so anchoring works without a header.
|
||||
* Returns ordered descriptors consumed identically by readers (readTable, getFormState)
|
||||
* and resolvers (findCellCoords, findGridCell, scanGridRows) so a synthesized name like
|
||||
* "Колонка1" always maps to the same physical cell on both read and write.
|
||||
*
|
||||
* Descriptor: { name, kind:'data'|'checkbox'|'picture', colindex, subTarget:'checkbox'|'title'|'text'|null }
|
||||
* - colindex — anchor: find the cell via line.children box with matching getAttribute('colindex').
|
||||
* - subTarget — node inside that box: 'checkbox' → .checkbox, 'title' → .gridBoxTitle,
|
||||
* 'text' → .gridBoxText, null → box itself.
|
||||
*
|
||||
* A COMBINED mark-box (one box holding BOTH .checkbox AND non-empty .gridBoxTitle, e.g. the
|
||||
* value-list checkbox mark-lists) is split into TWO logical columns sharing one colindex:
|
||||
* "(checkbox)" (subTarget:checkbox) + "КолонкаN" (subTarget:title). Data columns are numbered
|
||||
* КолонкаN among themselves (checkbox/picture don't consume a number); duplicate
|
||||
* "(checkbox)"/"(picture)" get a " 2", " 3" suffix.
|
||||
*/
|
||||
export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
|
||||
function picInfo(cell) {
|
||||
if (!cell) return null;
|
||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||
if (!dib) return null;
|
||||
const bg = dib.style.backgroundImage || '';
|
||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
const body = grid.querySelector('.gridBody');
|
||||
if (!body) return [];
|
||||
const line = body.querySelector('.gridLine');
|
||||
if (!line) return [];
|
||||
const cols = [];
|
||||
let dataN = 0;
|
||||
const uniq = (base) => {
|
||||
if (!cols.some(c => c.name === base)) return base;
|
||||
let n = 2; while (cols.some(c => c.name === base + ' ' + n)) n++;
|
||||
return base + ' ' + n;
|
||||
};
|
||||
[...line.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
if (ci == null) return;
|
||||
const chk = box.querySelector('.checkbox');
|
||||
const titleEl = box.querySelector('.gridBoxTitle');
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const titleTxt = ((titleEl ? titleEl.innerText : '') || '').trim();
|
||||
if (chk && titleTxt) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: 'title' });
|
||||
} else if (chk) {
|
||||
cols.push({ name: uniq('(checkbox)'), kind: 'checkbox', colindex: ci, subTarget: 'checkbox' });
|
||||
} else if (picInfo(box)) {
|
||||
cols.push({ name: uniq('(picture)'), kind: 'picture', colindex: ci, subTarget: null });
|
||||
} else {
|
||||
cols.push({ name: 'Колонка' + (++dataN), kind: 'data', colindex: ci, subTarget: textEl ? 'text' : (titleEl ? 'title' : null) });
|
||||
}
|
||||
});
|
||||
return cols;
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Single source of truth for columns of a grid WITH a header — the headed twin of
|
||||
* synthHeaderlessColumns above, and for the same reason: a column name must map to the same
|
||||
* physical cell for readers (readTable) and resolvers (click, row search, filter, fill).
|
||||
*
|
||||
* Column identity is `colindex` — 1С's own column id, present on both header boxes and body
|
||||
* cells. Geometry is the FALLBACK, used only for cells that have no header of their own
|
||||
* (sub-rows of a merged header, e.g. «Субконто Дт» over three stacked cells).
|
||||
*
|
||||
* Why colindex first: a wide header (ERP task list, «Исполнитель» spanning x 1085…1515) covers
|
||||
* the narrow headers below it («Срок» 1085…1251, «Выполнена» 1251…1515). Matching a cell by its
|
||||
* center-x alone puts the «Исполнитель» cell (center 1300) into the «Выполнена» group — which
|
||||
* both fakes a merged header (phantom «Выполнена 1/2») and glues foreign values together.
|
||||
* The write path (grid-edit.mjs) already resolves cells by colindex for exactly this reason.
|
||||
*
|
||||
* Column: { name, text, title, ci, x, right, y, h, fixed, kind?, subIdx? }
|
||||
* - ci — anchor; null for expanded sub-columns (their cells carry a different colindex).
|
||||
* - subIdx — set on «Имя 1/2/3» columns expanded from ONE header over several sub-rows;
|
||||
* such a cell is found by its Y order inside the header's x-range.
|
||||
*/
|
||||
export const COLUMN_MODEL_FN = HEADERLESS_GRID_FN + `
|
||||
function picInfoShared(cell) {
|
||||
if (!cell) return null;
|
||||
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||
if (!dib) return null;
|
||||
const bg = dib.style.backgroundImage || '';
|
||||
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||
return { gx: m ? m[1] : '0' };
|
||||
}
|
||||
|
||||
function buildColumnModel(grid) {
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const empty = { columns: [], byCi: {}, groups: new Map(), subRows: {}, multiRow: {}, headless: !head };
|
||||
if (!body) return empty;
|
||||
|
||||
if (!head) {
|
||||
const cols = synthHeaderlessColumns(grid).map(c => ({
|
||||
name: c.name, text: c.name, title: '', ci: c.colindex, subTarget: c.subTarget,
|
||||
kind: c.kind, x: 0, right: 0, y: 0, h: 0, fixed: false,
|
||||
}));
|
||||
const byCi = {};
|
||||
cols.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||
return { columns: cols, byCi, groups: new Map(), subRows: {}, multiRow: {}, headless: true };
|
||||
}
|
||||
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
const lines = [...body.querySelectorAll('.gridLine')];
|
||||
const cellByCi = (line, ci) => [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === ci);
|
||||
const columns = [];
|
||||
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = ((textEl || box).innerText || '').trim().replace(/\\n/g, ' ');
|
||||
const title = (box.getAttribute('title') || '').trim();
|
||||
const r = box.getBoundingClientRect();
|
||||
const base = { ci, x: r.x, right: r.x + r.width, y: r.y, h: r.height,
|
||||
fixed: box.classList.contains('gridBoxFix') };
|
||||
if (text) { columns.push(Object.assign(base, { name: text, text: text, title: title })); return; }
|
||||
|
||||
// Unnamed header — a column only if its cells hold a checkbox or a picture. 1С doesn't
|
||||
// expose the technical name, so it is named by the header tooltip.
|
||||
// Sample SEVERAL rows: a picture bound to a Boolean draws nothing for false, so an empty
|
||||
// first row is not evidence that the column has no pictures at all.
|
||||
let kind = null;
|
||||
for (const line of lines.slice(0, 10)) {
|
||||
const cell = ci != null ? cellByCi(line, ci) : null;
|
||||
if (!cell) continue;
|
||||
if (cell.querySelector('.checkbox')) { kind = 'checkbox'; break; }
|
||||
if (picInfoShared(cell)) { kind = 'picture'; break; }
|
||||
}
|
||||
if (!kind && picInfoShared(box)) kind = 'picture';
|
||||
if (!kind) return;
|
||||
let name = kind === 'checkbox' ? '(checkbox)' : (title || '(picture)');
|
||||
if (columns.some(c => c.name === name)) {
|
||||
let n = 2;
|
||||
while (columns.some(c => c.name === name + ' ' + n)) n++;
|
||||
name = name + ' ' + n;
|
||||
}
|
||||
columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind }));
|
||||
});
|
||||
|
||||
// Column GROUPS («Цена» over «План»/«Факт»/«Откл.»). 1С puts the group caption and its leaves
|
||||
// into the SAME head line, differing by y and width. A group caption is not a column: it has no
|
||||
// cells of its own, and leaf names repeat across groups («План» under both «Цена» and
|
||||
// «Количество») — keyed by bare name, values of different groups collided into one key.
|
||||
// The caption is told apart from a genuine wide column (pattern «Исполнитель» over «Срок»/
|
||||
// «Выполнена», see 24-multirow-header) by ONE reliable fact: its colindex never appears among
|
||||
// body cells. Geometry alone cannot tell them apart — both sit above narrower boxes.
|
||||
// Leaves are renamed «Группа / Лист», the same convention the spreadsheet reader uses.
|
||||
const bodyCi = new Set();
|
||||
lines.slice(0, 5).forEach(line => {
|
||||
[...line.children].forEach(b => {
|
||||
if (b.offsetWidth === 0) return;
|
||||
const ci = b.getAttribute('colindex');
|
||||
if (ci != null) bodyCi.add(ci);
|
||||
});
|
||||
});
|
||||
const covers = (g, c) => { const cx = c.x + (c.right - c.x) / 2; return c.y > g.y && cx >= g.x && cx < g.right; };
|
||||
const groupHdrs = columns.filter(g => g.ci != null && !bodyCi.has(g.ci) && columns.some(c => c !== g && covers(g, c)));
|
||||
if (groupHdrs.length) {
|
||||
columns.forEach(c => {
|
||||
if (groupHdrs.indexOf(c) >= 0) return;
|
||||
const parents = groupHdrs.filter(g => covers(g, c)).sort((a, b) => a.y - b.y);
|
||||
if (!parents.length) return;
|
||||
c.name = parents.map(g => g.text).concat(c.name).join(' / ');
|
||||
c.group = parents.map(g => g.text).join(' / ');
|
||||
});
|
||||
groupHdrs.forEach(g => { const at = columns.indexOf(g); if (at >= 0) columns.splice(at, 1); });
|
||||
}
|
||||
|
||||
const keyOf = c => Math.round(c.x) + ':' + Math.round(c.right);
|
||||
const groups = new Map();
|
||||
columns.forEach(c => { const k = keyOf(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); });
|
||||
for (const hdrs of groups.values()) hdrs.sort((a, b) => a.y - b.y);
|
||||
const byCi = {};
|
||||
columns.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||
|
||||
// Sub-rows per x-group, measured on the first data line. A cell belongs to the group of its
|
||||
// OWN header whenever colindex says so; only header-less cells are placed geometrically.
|
||||
const subRows = {};
|
||||
if (lines[0]) {
|
||||
[...lines[0].children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const ci = box.getAttribute('colindex');
|
||||
const own = ci != null ? byCi[ci] : null;
|
||||
let key = null;
|
||||
const r = box.getBoundingClientRect();
|
||||
if (own) key = keyOf(own);
|
||||
else {
|
||||
const cx = r.x + r.width / 2;
|
||||
for (const [k, hdrs] of groups) {
|
||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) { key = k; break; }
|
||||
}
|
||||
}
|
||||
if (key == null) return;
|
||||
(subRows[key] = subRows[key] || []).push({ y: r.y });
|
||||
});
|
||||
Object.keys(subRows).forEach(k => subRows[k].sort((a, b) => a.y - b.y));
|
||||
}
|
||||
|
||||
// Stacked headers (2+ over several sub-rows) → match by Y order.
|
||||
// ONE header over several sub-rows → merged header: expand into «Имя 1..N».
|
||||
const multiRow = {};
|
||||
for (const [k, hdrs] of groups) {
|
||||
const subs = subRows[k];
|
||||
if (!subs || subs.length <= 1) continue;
|
||||
if (hdrs.length >= 2) { multiRow[k] = hdrs; continue; }
|
||||
const base = hdrs[0];
|
||||
const at = columns.indexOf(base);
|
||||
columns.splice(at, 1);
|
||||
if (base.ci != null && byCi[base.ci] === base) delete byCi[base.ci];
|
||||
const expanded = [];
|
||||
for (let si = 0; si < subs.length; si++) {
|
||||
const col = Object.assign({}, base, {
|
||||
name: base.name + ' ' + (si + 1), ci: null,
|
||||
y: base.y + si, h: base.h / subs.length, subIdx: si,
|
||||
});
|
||||
columns.splice(at + si, 0, col);
|
||||
expanded.push(col);
|
||||
}
|
||||
groups.set(k, expanded);
|
||||
multiRow[k] = expanded;
|
||||
}
|
||||
|
||||
return { columns: columns, byCi: byCi, groups: groups, subRows: subRows, multiRow: multiRow, headless: false };
|
||||
}
|
||||
|
||||
/** Cell → column. colindex first; geometry only for cells without a header of their own. */
|
||||
function columnForCell(model, box) {
|
||||
const ci = box.getAttribute('colindex');
|
||||
if (ci != null && model.byCi[ci]) return model.byCi[ci];
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
const fixed = box.classList.contains('gridBoxFix');
|
||||
for (const k of Object.keys(model.multiRow)) {
|
||||
const hdrs = model.multiRow[k];
|
||||
if (cx < hdrs[0].x || cx >= hdrs[0].right) continue;
|
||||
const subs = model.subRows[k];
|
||||
if (subs) {
|
||||
const si = subs.findIndex(s => Math.abs(s.y - r.y) < 5);
|
||||
if (si >= 0 && si < hdrs.length) return hdrs[si];
|
||||
}
|
||||
let best = hdrs[0], bd = Infinity;
|
||||
for (const h of hdrs) { const d = Math.abs(r.y - h.y); if (d < bd) { bd = d; best = h; } }
|
||||
return best;
|
||||
}
|
||||
return model.columns.find(c => cx >= c.x && cx < c.right && c.fixed === fixed) || null;
|
||||
}
|
||||
|
||||
/** Column → cell inside a given line. Mirror of columnForCell, same precedence. */
|
||||
function cellForColumn(model, line, col) {
|
||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
||||
if (col.subIdx != null) {
|
||||
const inGroup = boxes
|
||||
.filter(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right && b.classList.contains('gridBoxFix') === col.fixed;
|
||||
})
|
||||
.sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
|
||||
return inGroup[col.subIdx] || null;
|
||||
}
|
||||
if (col.ci != null) {
|
||||
const hit = boxes.find(b => b.getAttribute('colindex') === col.ci);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return boxes
|
||||
.filter(b => b.classList.contains('gridBoxFix') === col.fixed)
|
||||
.find(b => {
|
||||
const r = b.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
return cx >= col.x && cx < col.right;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/** Column by user-supplied name: exact → «Группа / Имя» suffix → substring. */
|
||||
function resolveColumnByName(model, name) {
|
||||
const lo = s => (s || '').toLowerCase().replace(/ё/g, 'е').trim();
|
||||
const cand = c => [c.name, c.text, c.title].filter(Boolean);
|
||||
const n = lo(name);
|
||||
const suffix = lo(' / ' + name);
|
||||
return model.columns.find(c => cand(c).some(t => lo(t) === n))
|
||||
|| model.columns.find(c => cand(c).some(t => lo(t).endsWith(suffix)))
|
||||
|| model.columns.find(c => cand(c).some(t => lo(t).includes(n)))
|
||||
|| null;
|
||||
}`;
|
||||
|
||||
// Селекторы детекции формы. EDIT_SEL — «редактируемые» контролы (поля/кнопки): их наличие
|
||||
// исторически = «это форма». Но страницы настроек/справки (напр. «Интернет-поддержка и сервисы»)
|
||||
// собраны только из гиперссылок/frameButton/групп и НЕ имеют ни одного из этих трёх → форма не
|
||||
// детектировалась (form=null). ANY_SEL добавляет контентные/интерактивные классы декораций, чтобы
|
||||
// такие формы регистрировались. form0 (рабочий стол, тоже полон гиперссылок) исключается фильтром n>0.
|
||||
const FORM_DETECT_EDIT_SEL = 'input.editInput[id], textarea[id], a.press[id]';
|
||||
const FORM_DETECT_ANY_SEL = FORM_DETECT_EDIT_SEL + ', .staticTextHyper[id], .frameButton[id], .checkbox[id], .radio[id], .tumblerItem[id], .grid[id]';
|
||||
|
||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForm() {
|
||||
const editSel = ${JSON.stringify(FORM_DETECT_EDIT_SEL)};
|
||||
const anySel = ${JSON.stringify(FORM_DETECT_ANY_SEL)};
|
||||
const editCounts = {}; // строгие поля/кнопки
|
||||
const anyCounts = {}; // + контентные декорации
|
||||
document.querySelectorAll(anySel).forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (!m) return;
|
||||
anyCounts[m[1]] = (anyCounts[m[1]] || 0) + 1;
|
||||
if (el.matches(editSel)) editCounts[m[1]] = (editCounts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(anyCounts).map(Number);
|
||||
if (!nums.length) return null;
|
||||
const candidates = nums.filter(n => n > 0);
|
||||
if (!candidates.length) return nums[0];
|
||||
// When modal surface is visible, prefer the highest-numbered form (modal dialog)
|
||||
if (hasVisibleModal()) {
|
||||
const maxForm = Math.max(...candidates);
|
||||
if (anyCounts[maxForm] >= 1) return maxForm;
|
||||
}
|
||||
// Двухуровневый выбор: пока есть формы с редактируемыми контролами — выбираем по ним (прежнее
|
||||
// поведение, обычные формы не сдвигаются). Только когда у ВСЕХ кандидатов их нет (info-страница) —
|
||||
// выбираем по расширенному счёту.
|
||||
const editable = candidates.filter(n => editCounts[n] > 0);
|
||||
const pool = editable.length ? editable : candidates;
|
||||
const metric = editable.length ? editCounts : anyCounts;
|
||||
return pool.reduce((best, n) => metric[n] > metric[best] ? n : best);
|
||||
}`;
|
||||
|
||||
/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }.
|
||||
* Works even when the open-windows tab bar is hidden. */
|
||||
export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + `
|
||||
function detectForms() {
|
||||
const anySel = ${JSON.stringify(FORM_DETECT_ANY_SEL)};
|
||||
const counts = {};
|
||||
document.querySelectorAll(anySel).forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const m = el.id.match(/^form(\\d+)_/);
|
||||
if (m) counts[m[1]] = (counts[m[1]] || 0) + 1;
|
||||
});
|
||||
const nums = Object.keys(counts).map(Number);
|
||||
return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() };
|
||||
}`;
|
||||
|
||||
/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */
|
||||
export const READ_FORM_FN = HEADERLESS_GRID_FN + GROUP_STATE_FN + `
|
||||
function readForm(p) {
|
||||
const result = {};
|
||||
const fields = [];
|
||||
const buttons = [];
|
||||
const formTabs = [];
|
||||
const texts = [];
|
||||
const hyperlinks = [];
|
||||
// Normalize non-breaking spaces to regular spaces
|
||||
const nbsp = s => (s || '').replace(/\\u00a0/g, ' ');
|
||||
|
||||
// Fields (inputs)
|
||||
document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const actions = [];
|
||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select');
|
||||
if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open');
|
||||
if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear');
|
||||
if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick');
|
||||
const field = { name, value: el.value || '' };
|
||||
// Multi-value reference fields keep their value in .chipsItem chips, not in input.value
|
||||
if (!field.value) {
|
||||
const labelEl = document.getElementById(p + name);
|
||||
if (labelEl) {
|
||||
const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')]
|
||||
.map(c => nbsp(c.innerText?.trim() || ''))
|
||||
.filter(Boolean);
|
||||
if (chipTexts.length) field.value = chipTexts.join(', ');
|
||||
}
|
||||
}
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (el.type && el.type !== 'text') field.type = el.type;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (actions.length) field.actions = actions;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Textareas
|
||||
document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text')
|
||||
|| document.getElementById(p + name + '#title_div');
|
||||
const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' '));
|
||||
const field = { name, value: el.value || '', type: 'textarea' };
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.readOnly) field.readonly = true;
|
||||
if (el.disabled) field.disabled = true;
|
||||
if (document.activeElement === el) field.focused = true;
|
||||
if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Checkboxes
|
||||
document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = nbsp(titleEl?.innerText?.trim() || '');
|
||||
const field = {
|
||||
name,
|
||||
value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'),
|
||||
type: 'checkbox'
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
if (el.classList.contains('checkboxDisabled')) field.disabled = true;
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
// Radio buttons — base element is option 0, others are #N#radio (N >= 1)
|
||||
const radioGroups = {};
|
||||
document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const id = el.id.replace(p, '');
|
||||
const m = id.match(/^(.+?)#(\\d+)#radio$/);
|
||||
if (m) {
|
||||
// Options 1, 2, ... have explicit #N#radio suffix
|
||||
const [, groupName, idx] = m;
|
||||
if (!radioGroups[groupName]) radioGroups[groupName] = [];
|
||||
const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx);
|
||||
radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') });
|
||||
} else if (!id.includes('#')) {
|
||||
// Base element = option 0 (no #0#radio suffix)
|
||||
if (!radioGroups[id]) radioGroups[id] = [];
|
||||
const labelEl = document.getElementById(p + id + '#0#radio_text');
|
||||
const label = nbsp(labelEl?.innerText?.trim() || 'option0');
|
||||
radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') });
|
||||
}
|
||||
});
|
||||
for (const [name, options] of Object.entries(radioGroups)) {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const selected = options.find(o => o.selected);
|
||||
const field = {
|
||||
name,
|
||||
value: selected?.label || '',
|
||||
type: 'radio',
|
||||
options: options.map(o => o.label)
|
||||
};
|
||||
if (label && label !== name) field.label = label;
|
||||
if (document.getElementById(p + name)?.classList.contains('radioDisabled')) field.disabled = true;
|
||||
fields.push(field);
|
||||
}
|
||||
|
||||
// Buttons (a.press)
|
||||
document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const idName = el.id.replace(p, '');
|
||||
if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return;
|
||||
const span = el.querySelector('.submenuText') || el.querySelector('span');
|
||||
const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || '');
|
||||
if (!text && !el.classList.contains('pressCommand')) return;
|
||||
const btn = { name: text || idName };
|
||||
if (el.classList.contains('pressDefault')) btn.default = true;
|
||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||
// Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress)
|
||||
if (!text) {
|
||||
const tip = nbsp(el.title || el.parentElement?.title || '');
|
||||
if (tip) btn.tooltip = tip;
|
||||
}
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Frame buttons
|
||||
document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = nbsp(el.innerText?.trim() || '');
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
if (!text && !idName) return;
|
||||
// frameButton disabled uses the same class as a.press buttons (pressDisabled).
|
||||
const btn = { name: text || idName, frame: true };
|
||||
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Tumbler items. Disabled state lives on the group element .frameTumbler
|
||||
// (class tumblerDisabled), not on the individual segments.
|
||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const text = el.innerText?.trim();
|
||||
const idName = el.id?.replace(p, '') || '';
|
||||
const btn = { name: text || idName, tumbler: true };
|
||||
if (el.closest('.frameTumbler')?.classList.contains('tumblerDisabled')) btn.disabled = true;
|
||||
buttons.push(btn);
|
||||
});
|
||||
|
||||
// Tabs — scoped to form by checking ancestor IDs
|
||||
document.querySelectorAll('[data-content]').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
let node = el.parentElement;
|
||||
let inForm = false;
|
||||
while (node) {
|
||||
if (node.id && node.id.startsWith(p)) { inForm = true; break; }
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!inForm) return;
|
||||
const tab = { name: el.dataset.content };
|
||||
if (el.classList.contains('select')) tab.active = true;
|
||||
formTabs.push(tab);
|
||||
});
|
||||
|
||||
// Static texts and hyperlinks
|
||||
document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const name = el.id.replace(p, '');
|
||||
if (name.endsWith('_div') || name.includes('#title')) return;
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
if (el.classList.contains('staticTextHyper')) {
|
||||
hyperlinks.push({ name: text });
|
||||
} else {
|
||||
const titleEl = document.getElementById(p + name + '#title_text');
|
||||
const label = titleEl?.innerText?.trim() || '';
|
||||
const entry = { name, value: text };
|
||||
if (label) entry.label = label;
|
||||
texts.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Tables/grids — collect ALL visible grids
|
||||
const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.filter(g => g.offsetWidth > 0 && g.offsetHeight > 0);
|
||||
if (allGrids.length > 0) {
|
||||
const tables = allGrids.map(grid => {
|
||||
const name = grid.id ? grid.id.replace(p, '') : '';
|
||||
const head = grid.querySelector('.gridHead');
|
||||
const body = grid.querySelector('.gridBody');
|
||||
const columns = [];
|
||||
if (head) {
|
||||
const headLine = head.querySelector('.gridLine') || head;
|
||||
[...headLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const textEl = box.querySelector('.gridBoxText');
|
||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
||||
if (text) {
|
||||
const r = box.getBoundingClientRect();
|
||||
columns.push({ text, ci: box.getAttribute('colindex'), x: r.x, right: r.x + r.width, y: r.y, h: r.height });
|
||||
} else {
|
||||
// Unnamed column — check if data cells contain checkboxes
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine) {
|
||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
||||
const idx = visibleHeaders.indexOf(box);
|
||||
const cells = [...firstLine.children].filter(c => c.offsetWidth > 0);
|
||||
if (cells[idx]?.querySelector('.checkbox')) {
|
||||
columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Column groups → «Группа / Лист». Mirrors buildColumnModel: a group caption owns no
|
||||
// cells, so its colindex is absent from the body; leaf names repeat across groups.
|
||||
const dataLines = [...(body?.querySelectorAll('.gridLine') || [])].slice(0, 5);
|
||||
if (dataLines.length && columns.length > 0) {
|
||||
const bodyCi = new Set();
|
||||
dataLines.forEach(line => [...line.children].forEach(b => {
|
||||
if (b.offsetWidth === 0) return;
|
||||
const ci = b.getAttribute('colindex');
|
||||
if (ci != null) bodyCi.add(ci);
|
||||
}));
|
||||
const covers = (g, c) => { const cx = c.x + (c.right - c.x) / 2; return c.y > g.y && cx >= g.x && cx < g.right; };
|
||||
const grpHdrs = columns.filter(g => g.ci != null && !bodyCi.has(g.ci) && columns.some(c => c !== g && covers(g, c)));
|
||||
if (grpHdrs.length) {
|
||||
columns.forEach(c => {
|
||||
if (grpHdrs.indexOf(c) >= 0) return;
|
||||
const parents = grpHdrs.filter(g => covers(g, c)).sort((a, b) => a.y - b.y);
|
||||
if (parents.length) c.text = parents.map(g => g.text).concat(c.text).join(' / ');
|
||||
});
|
||||
grpHdrs.forEach(g => { const at = columns.indexOf(g); if (at >= 0) columns.splice(at, 1); });
|
||||
}
|
||||
}
|
||||
// Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3)
|
||||
const firstLine = body?.querySelector('.gridLine');
|
||||
if (firstLine && columns.length > 0) {
|
||||
const xGrp = new Map();
|
||||
columns.forEach(c => {
|
||||
const k = Math.round(c.x) + ':' + Math.round(c.right);
|
||||
if (!xGrp.has(k)) xGrp.set(k, []);
|
||||
xGrp.get(k).push(c);
|
||||
});
|
||||
for (const [k, hdrs] of xGrp) {
|
||||
if (hdrs.length !== 1) continue;
|
||||
let cnt = 0;
|
||||
[...firstLine.children].forEach(box => {
|
||||
if (box.offsetWidth === 0) return;
|
||||
const r = box.getBoundingClientRect();
|
||||
const cx = r.x + r.width / 2;
|
||||
if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++;
|
||||
});
|
||||
if (cnt > 1) {
|
||||
const base = hdrs[0];
|
||||
const baseIdx = columns.indexOf(base);
|
||||
columns.splice(baseIdx, 1);
|
||||
for (let si = 0; si < cnt; si++) {
|
||||
columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (body) {
|
||||
// Headerless grid — synthesize columns by colindex (single source).
|
||||
synthHeaderlessColumns(grid).forEach(c => columns.push({ text: c.name, x: 0, right: 0, y: 0, h: 0 }));
|
||||
}
|
||||
const colNames = columns.map(c => c.text);
|
||||
const rowCount = body ? body.querySelectorAll('.gridLine').length : 0;
|
||||
// Visual label from group title (e.g. "Входящие:" for grid "Входящие")
|
||||
const titleEl = document.getElementById(p + name + '#title_div')
|
||||
|| document.getElementById(p + 'Группа' + name + '#title_div');
|
||||
const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null;
|
||||
return { name, columns: colNames, rowCount, ...(label ? { label } : {}) };
|
||||
});
|
||||
result.tables = tables;
|
||||
// Backward compat: table = first grid summary
|
||||
const first = tables[0];
|
||||
result.table = { present: true, columns: first.columns, rowCount: first.rowCount };
|
||||
}
|
||||
|
||||
// Active filters (train badges above grid: *СостояниеПросмотра)
|
||||
const filters = [];
|
||||
document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const titleEl = el.querySelector('.trainName');
|
||||
const valueEl = el.querySelector('.trainTitle');
|
||||
if (!titleEl && !valueEl) return;
|
||||
const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim();
|
||||
const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || '';
|
||||
if (field || value) filters.push({ field, value });
|
||||
});
|
||||
// Also check search field value
|
||||
const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id));
|
||||
if (searchInput?.value) {
|
||||
filters.push({ type: 'search', value: searchInput.value });
|
||||
}
|
||||
if (filters.length) result.filters = filters;
|
||||
|
||||
// Navigation panel (FormNavigationPanel) — lives in parent page{N} container
|
||||
const navigation = [];
|
||||
const formEl = document.querySelector('[id^="' + p + '"]');
|
||||
if (formEl) {
|
||||
let pageEl = formEl.parentElement;
|
||||
while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement;
|
||||
if (pageEl) {
|
||||
pageEl.querySelectorAll('.navigationItem').forEach(el => {
|
||||
if (el.offsetWidth === 0) return;
|
||||
const nameEl = el.querySelector('.navigationItemName');
|
||||
const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' ');
|
||||
if (!text) return;
|
||||
const nav = { name: text };
|
||||
if (el.classList.contains('select')) nav.active = true;
|
||||
navigation.push(nav);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Iframes
|
||||
let iframeCount = 0;
|
||||
document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++;
|
||||
});
|
||||
if (iframeCount) result.iframes = iframeCount;
|
||||
|
||||
// Collapsible / popup groups — surface that part of the form is hidden + its state.
|
||||
// Идентификация раскрываемой группы (есть заголовок <base>#title_text и один из признаков):
|
||||
// • заголовок — гиперссылка (.staticTextHyper: ControlRepresentation=TitleHyperlink);
|
||||
// • рядом кнопка-каретка <base>#titleBtn (ControlRepresentation=Picture);
|
||||
// • есть панель <base>#panel_div — это ВСПЛЫВАЮЩАЯ (popup) группа.
|
||||
// Обычные (несворачиваемые) группы не имеют ничего из этого — их не показываем.
|
||||
// Состояние — groupCollapsed (GROUP_STATE_FN), общая с резолвером цели клика.
|
||||
const groups = [];
|
||||
document.querySelectorAll('[id^="' + p + '"][id$="#title_text"]').forEach(tt => {
|
||||
if (tt.offsetWidth === 0 && tt.offsetHeight === 0) return;
|
||||
const base = tt.id.slice(0, -('#title_text'.length));
|
||||
const panelDiv = document.getElementById(base + '#panel_div'); // popup-маркер
|
||||
const isHyper = tt.classList.contains('staticTextHyper');
|
||||
const hasBtn = !!document.getElementById(base + '#titleBtn');
|
||||
if (!isHyper && !hasBtn && !panelDiv) return; // обычная (несворачиваемая) группа
|
||||
const g = { name: base.replace(p, ''), title: nbsp(tt.innerText?.trim() || '') };
|
||||
if (panelDiv) g.behavior = 'popup';
|
||||
const collapsed = groupCollapsed(base);
|
||||
if (collapsed !== null) g.collapsed = collapsed;
|
||||
groups.push(g);
|
||||
});
|
||||
|
||||
if (fields.length) result.fields = fields;
|
||||
if (buttons.length) result.buttons = buttons;
|
||||
if (formTabs.length) result.tabs = formTabs;
|
||||
if (navigation.length) result.navigation = navigation;
|
||||
if (texts.length) result.texts = texts;
|
||||
if (hyperlinks.length) result.hyperlinks = hyperlinks;
|
||||
if (groups.length) result.groups = groups;
|
||||
|
||||
// Group DCS report settings into readable format
|
||||
if (result.fields) {
|
||||
const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/;
|
||||
const dcsGroups = {};
|
||||
const dcsNames = new Set();
|
||||
for (const f of result.fields) {
|
||||
const m = f.name.match(dcsRe);
|
||||
if (!m) continue;
|
||||
if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) };
|
||||
dcsGroups[m[1]][m[3]] = f;
|
||||
dcsNames.add(f.name);
|
||||
}
|
||||
const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n);
|
||||
if (dcsEntries.length) {
|
||||
result.reportSettings = dcsEntries.map(([, g]) => {
|
||||
const cb = g['Использование'];
|
||||
const val = g['Значение'];
|
||||
if (!cb && !val) return null;
|
||||
// No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled
|
||||
const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim();
|
||||
const s = { name: label, enabled: cb ? !!cb.value : true };
|
||||
if (val) {
|
||||
s.value = val.value || '';
|
||||
if (val.actions && val.actions.length) s.actions = val.actions;
|
||||
}
|
||||
return s;
|
||||
}).filter(Boolean);
|
||||
result.fields = result.fields.filter(f => !dcsNames.has(f.name));
|
||||
if (!result.fields.length) delete result.fields;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}`;
|
||||
@@ -1,54 +0,0 @@
|
||||
// web-test dom/form-state v1.1 — combined detectForm + readForm + open tabs + form caption
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Combined: detect form + read form + read open tabs.
|
||||
* Single evaluate call instead of 3. Used by browser.getFormState().
|
||||
*/
|
||||
export function getFormStateScript() {
|
||||
return `(() => {
|
||||
${DETECT_FORM_FN}
|
||||
${DETECT_FORMS_FN}
|
||||
${READ_FORM_FN}
|
||||
const formNum = detectForm();
|
||||
const meta = detectForms();
|
||||
if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' };
|
||||
const p = 'form' + formNum + '_';
|
||||
const formData = readForm(p);
|
||||
// Open tabs bar (present only when tab panel is enabled in 1C settings)
|
||||
const openTabs = [];
|
||||
document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => {
|
||||
const text = el.innerText?.trim();
|
||||
if (!text) return;
|
||||
const entry = { name: text };
|
||||
if (el.classList.contains('select')) entry.active = true;
|
||||
openTabs.push(entry);
|
||||
});
|
||||
const activeTab = openTabs.find(t => t.active)?.name || null;
|
||||
// Caption of the ACTIVE form. Lives in an attribute, not in text — the div itself is empty:
|
||||
// <div class="toplineBox" data-title="Контрагенты">
|
||||
// <div id="VW_page1headerTopLine_title" class="toplineBoxTitle" title="Контрагенты"></div>
|
||||
// Header numbering (VW_page<M>) does not match form numbering (form<N>), so the header cannot
|
||||
// be picked by form number. Several headers can be visible at once — with a selection form up,
|
||||
// BOTH the parent form's header and the pop-up's are visible — so "first visible" would report
|
||||
// the parent's caption for the pop-up: a plausible, wrong answer.
|
||||
// Priority is therefore the one already measured for the close cross (dom/forms.mjs
|
||||
// closeCrossScript): floating window (ps<N>, highest index = topmost) → the form's own header →
|
||||
// and only then the open-windows tab, which the user can switch off in 1C settings.
|
||||
// Anchored on ids, not on the visible text, so a non-Russian locale keeps working.
|
||||
const heads = [...document.querySelectorAll('[id*="headerTopLine_title"]')]
|
||||
.filter(e => e.offsetWidth > 0 && e.offsetHeight > 0);
|
||||
const floating = heads.filter(e => /ps\\d+headerTopLine_title$/.test(e.id));
|
||||
const own = heads.filter(e => /^VW_page\\d+headerTopLine_title$/.test(e.id));
|
||||
const head = floating.pop() || own.pop() || null;
|
||||
let title = head
|
||||
? (head.getAttribute('title') || head.parentElement?.getAttribute('data-title') || null)
|
||||
: null;
|
||||
if (!title) title = activeTab;
|
||||
const result = { form: formNum, activeTab, title, openForms: meta.allForms, formCount: meta.formCount, ...formData };
|
||||
if (meta.modal) result.modal = true;
|
||||
if (openTabs.length) result.openTabs = openTabs;
|
||||
return result;
|
||||
})()`;
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// web-test dom/row-state v1.0 — leading row-state sprite decoding
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Ведущая иконка состояния строки списка.
|
||||
*
|
||||
* 1С рисует состояние строки (проведён / помечен на удаление / выполнена / …) отдельным спрайтом,
|
||||
* приходящим из `e1cib/convertPicture?url=e1csys/<dir>/<file>.zip&…&gx=<N>`, где `gx` — индекс кадра.
|
||||
* Это НЕ то же самое, что именованные pic-колонки (`pictureCollection/picture/<id>`) — у тех другой
|
||||
* URL и другой смысл (значение ячейки), их разбирает picInfo() в grid.mjs.
|
||||
*
|
||||
* Ключ словаря — ПОЛНЫЙ путь спрайта, не имя файла: `basic/folder.zip` (справочники) и
|
||||
* `accnt/folder.zip` (план счетов) — разные файлы с одинаковым именем и РАЗНОЙ раскладкой gx
|
||||
* (у basic gx=1 — элемент, у accnt gx=1 — предопределённый). Нормализация до имени файла склеила бы
|
||||
* их и дала неверную расшифровку.
|
||||
*
|
||||
* Набор осей свой у каждого спрайта: docList → deleted+posted, folder → deleted+predefined,
|
||||
* Task → deleted+completed. Неприменимая ось не эмитится. Незнакомый путь или нерасшифрованный gx →
|
||||
* только `_rowPic`, без булевых: ОТСУТСТВИЕ БУЛЕВА ЗНАЧИТ «НЕ ЗНАЮ», а не false.
|
||||
*
|
||||
* Раскладки сняты живьём на ERP и подтверждены отрисовкой кадров спрайта (convertPicture&scale).
|
||||
*/
|
||||
export const ROW_STATE_FN = `function rowStateInfo(line) {
|
||||
// Словарь: полный путь → (gx → набор осей). null = кадр не расшифрован (только _rowPic).
|
||||
const SPRITES = {
|
||||
// Документы, журналы документов. Спрайт — СЕТКА: база + 3·пометка_удаления.
|
||||
// База: 0 записан, 1 проведён, 2 «загнутый угол» (не расшифрован).
|
||||
// Кадров ровно 6 (gx 6..8 пустые). gx4 (проведён+помечен) на практике недостижим:
|
||||
// пометка проведённого документа его распроводит.
|
||||
'e1csys/basic/docList.zip': function (gx) {
|
||||
if (gx > 5) return null;
|
||||
const base = gx % 3, deleted = gx >= 3;
|
||||
if (base === 2) return { deleted: deleted }; // база не расшифрована → posted не эмитим
|
||||
return { deleted: deleted, posted: base === 1 };
|
||||
},
|
||||
// Справочники, планы видов характеристик. Ось группа/элемент СОЗНАТЕЛЬНО не выводим —
|
||||
// _kind живёт от .gridListH/.gridListV, на нём завязана иерархия.
|
||||
//
|
||||
// Кадры 4/5 — не «неизвестное состояние», а ВТОРОЕ измерение: у справочника с иерархией
|
||||
// элементов (ERP «Партнеры») родитель — элемент, а не папка, и платформа берёт кадр 4.
|
||||
// Кадры попарно байт-в-байт равны: gx4 ≡ gx1 (чистый элемент), gx5 ≡ gx3 (элемент + красный
|
||||
// крест), gx8 ≡ gx7. Что означает второе измерение — знать не нужно: обе НАШИ оси кадр
|
||||
// задаёт однозначно (крест есть → помечен).
|
||||
'e1csys/basic/folder.zip': {
|
||||
0: { deleted: false, predefined: false }, // группа
|
||||
1: { deleted: false, predefined: false }, // элемент
|
||||
2: { deleted: true, predefined: false }, // группа, помечена
|
||||
3: { deleted: true, predefined: false }, // элемент, помечен
|
||||
4: { deleted: false, predefined: false }, // элемент-узел иерархии (кадр ≡ gx1)
|
||||
5: { deleted: true, predefined: false }, // элемент-узел, помечен (кадр ≡ gx3)
|
||||
6: { deleted: false, predefined: true }, // группа, предопределённая
|
||||
7: { deleted: false, predefined: true }, // элемент, предопределённый
|
||||
8: { deleted: false, predefined: true }, // кадр ≡ gx7
|
||||
},
|
||||
// План счетов — ОТДЕЛЬНЫЙ спрайт с той же basename, но иной раскладкой.
|
||||
'e1csys/accnt/folder.zip': {
|
||||
0: { deleted: false, predefined: false },
|
||||
1: { deleted: false, predefined: true },
|
||||
2: { deleted: true, predefined: false },
|
||||
},
|
||||
// Задачи. Бит-поле: 1·Выполнена + 2·ПометкаУдаления. Сверено с данными на ERP.
|
||||
'e1csys/bp/Task.zip': function (gx) {
|
||||
if (gx > 3) return null;
|
||||
return { completed: (gx & 1) === 1, deleted: (gx & 2) === 2 };
|
||||
},
|
||||
// Бизнес-процессы. Бит-поле: 4·Стартован + 2·Завершён + 1·ПометкаУдаления. Сверено с данными.
|
||||
'e1csys/bp/BusinessProcess.zip': function (gx) {
|
||||
if (gx > 7) return null;
|
||||
return { started: (gx & 4) === 4, finished: (gx & 2) === 2, deleted: (gx & 1) === 1 };
|
||||
},
|
||||
// Планы видов расчёта.
|
||||
'e1csys/calc/calcKindImg.zip': {
|
||||
0: { deleted: false, predefined: false },
|
||||
1: { deleted: true, predefined: false },
|
||||
2: { deleted: false, predefined: true },
|
||||
},
|
||||
// Планы обмена. gx 1, 4..7 не сняты → только _rowPic.
|
||||
'e1csys/backend/DataExchangeImages.zip': {
|
||||
0: { thisNode: false },
|
||||
2: { thisNode: true },
|
||||
},
|
||||
};
|
||||
|
||||
function decodeUrl(s) {
|
||||
// В DOM путь закодирован ДВАЖДЫ: url=e1csys%252Fbasic%252FdocList.zip
|
||||
let v = s;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let d;
|
||||
try { d = decodeURIComponent(v); } catch (e) { break; }
|
||||
if (d === v) break;
|
||||
v = d;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Ведущих значков в строке может быть несколько в РАЗНЫХ боксах: вид операции
|
||||
// (pictureCollection, произвольная картинка конфигурации), tree-toggle и состояние.
|
||||
// Поэтому перебираем все .dIB строки, а не берём первый .gridBoxImg.
|
||||
const dibs = line.querySelectorAll('.gridBoxImg .dIB');
|
||||
for (let i = 0; i < dibs.length; i++) {
|
||||
const d = dibs[i];
|
||||
if (d.getAttribute('tree') === 'true') continue;
|
||||
const bg = d.style.backgroundImage || '';
|
||||
if (!bg.includes('convertPicture')) continue;
|
||||
const um = bg.match(/[?&]url=([^&"')]+)/);
|
||||
if (!um) continue;
|
||||
const path = decodeUrl(um[1]);
|
||||
if (!/\\.zip$/.test(path)) continue;
|
||||
const gm = bg.match(/[?&]gx=(\\d+)/);
|
||||
const gx = gm ? parseInt(gm[1], 10) : 0;
|
||||
|
||||
const entry = SPRITES[path];
|
||||
const axes = typeof entry === 'function' ? entry(gx) : (entry ? entry[gx] : null);
|
||||
return { rowPic: path + ':' + gx, axes: axes || null };
|
||||
}
|
||||
return null;
|
||||
}`;
|
||||
@@ -1,48 +0,0 @@
|
||||
// web-test engine/core/deadline v1.0 — wall-clock bounds for calls that can hang
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Why this exists: `try { await x } catch {}` guards against a REJECTION, not against
|
||||
// a promise that never settles. A Playwright call against a wedged renderer (page.evaluate
|
||||
// has no timeout at all) does exactly that — it neither resolves nor rejects, and the runner
|
||||
// waits forever. Every such call must be bounded by a wall-clock timer instead.
|
||||
//
|
||||
// Neither helper CANCELS the underlying work — that is impossible for a promise. They only
|
||||
// stop *waiting* on it. Whoever breaks a deadline must also destroy the thing that hung
|
||||
// (see session.abortContext) or the pending call keeps holding its resources.
|
||||
|
||||
export class DeadlineError extends Error {
|
||||
constructor(label, ms) {
|
||||
super(`${label} timed out after ${ms}ms`);
|
||||
this.name = 'DeadlineError';
|
||||
this.label = label;
|
||||
this.ms = ms;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await `promise`, but give up after `ms`.
|
||||
* @throws {DeadlineError} when the deadline is reached first.
|
||||
*/
|
||||
export function withDeadline(promise, ms, label = 'operation') {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
Promise.resolve(promise),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms); }),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort variant: never throws, reports what happened instead.
|
||||
* Use it where the old code said `try { await x } catch {}` — the point is that a breach
|
||||
* becomes VISIBLE (callers are expected to log `err`) rather than silently swallowed.
|
||||
* @returns {Promise<{ok: boolean, value?: any, err?: Error, timedOut: boolean, ms: number}>}
|
||||
*/
|
||||
export async function softDeadline(promise, ms, label = 'operation') {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const value = await withDeadline(promise, ms, label);
|
||||
return { ok: true, value, timedOut: false, ms: Date.now() - t0 };
|
||||
} catch (err) {
|
||||
return { ok: false, err, timedOut: err instanceof DeadlineError, ms: Date.now() - t0 };
|
||||
}
|
||||
}
|
||||
@@ -1,741 +0,0 @@
|
||||
// web-test core/session v1.20 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { softDeadline } from './deadline.mjs';
|
||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||
import { join as pathJoin } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
browser, page, sessionPrefix, seanceId, recorder, highlightMode,
|
||||
contexts, activeContextName, activeMode, persistentUserDataDir,
|
||||
setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode,
|
||||
setActiveContextName, setActiveMode, setPersistentUserDataDir,
|
||||
isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID,
|
||||
} from './state.mjs';
|
||||
import { closeModals } from './errors.mjs';
|
||||
import { stopRecording } from '../recording/capture.mjs';
|
||||
import { getPageState } from '../nav/navigation.mjs';
|
||||
|
||||
/**
|
||||
* Find the 1C browser extension in Chrome/Edge user profiles.
|
||||
* Returns the path to the latest version, or null if not found.
|
||||
* Can be overridden via extensionPath in .v8-project.json.
|
||||
*/
|
||||
function findExtension(overridePath) {
|
||||
if (overridePath) {
|
||||
try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {}
|
||||
return null;
|
||||
}
|
||||
const localAppData = process.env.LOCALAPPDATA;
|
||||
if (!localAppData) return null;
|
||||
const browsers = [
|
||||
pathJoin(localAppData, 'Google', 'Chrome', 'User Data'),
|
||||
pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'),
|
||||
];
|
||||
for (const userData of browsers) {
|
||||
try { if (!statSync(userData).isDirectory()) continue; } catch { continue; }
|
||||
let profiles;
|
||||
try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; }
|
||||
for (const profile of profiles) {
|
||||
const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID);
|
||||
try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; }
|
||||
let versions;
|
||||
try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; }
|
||||
if (versions.length > 0) {
|
||||
const best = pathJoin(extDir, versions[versions.length - 1]);
|
||||
try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* isConnected moved to core/state.mjs */
|
||||
|
||||
/**
|
||||
* Wait for the 1C client to come up — or for the startup shell to say why it won't.
|
||||
*
|
||||
* Why this exists: when 1C has no free licence it renders a blocking startup dialog INSTEAD of
|
||||
* the application. The old code just waited out INIT_TIMEOUT and returned success, leaving a
|
||||
* session-less slot behind; the first engine call then produced a plainly wrong diagnosis
|
||||
* ("Section panel is in icon-only mode…"). Measured: 66s wasted, then a lie.
|
||||
*
|
||||
* Two blockers are recognised, both by id:
|
||||
* #messageBoxText — the startup message box (no free licence, and any other startup error)
|
||||
* #authWindow — the login dialog: the publication wants credentials, which the engine
|
||||
* cannot supply. Landing here used to be treated as legitimate ("login
|
||||
* page"), but that was fiction: closeModals() presses Escape 5x right after
|
||||
* this wait, which dismisses the dialog and leaves a blank page reported as
|
||||
* a healthy start. Nobody could ever log in by hand either.
|
||||
*
|
||||
* Contract: throw ONLY on positive evidence — a visible dialog. A missing client marker is NOT
|
||||
* evidence (an unknown or merely slow start must keep its old behaviour), hence the fallback.
|
||||
*
|
||||
* Anchors are ids, never text: the platform's wording is locale-dependent and only gets quoted
|
||||
* into the message. Verified on this stand — on a healthy start neither #messageBoxText nor
|
||||
* #authWindow exists at all, in the loaded client or at any point while the shell boots (polled
|
||||
* every 100ms, including bpdemo's 19.5s auto-login boot). The offsetWidth/text conjuncts guard a
|
||||
* future build that pre-renders them hidden. offsetWidth (not offsetParent — that is null for
|
||||
* position:fixed, and #ps0win is an overlay) matches the convention in errors.mjs.
|
||||
*/
|
||||
async function waitForClientOrStartupBlock(pg, url, timeout = INIT_TIMEOUT) {
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await pg.waitForFunction(() => {
|
||||
if (document.querySelector('#themesCell_theme_0')) return 'client';
|
||||
const box = document.querySelector('#messageBoxText');
|
||||
if (box && box.offsetWidth > 0 && box.textContent.trim()) return 'blocked';
|
||||
const auth = document.querySelector('#authWindow');
|
||||
if (auth && auth.offsetWidth > 0) return 'auth';
|
||||
return false;
|
||||
}, null, { timeout }).then(h => h.jsonValue());
|
||||
} catch {
|
||||
// Neither appeared: unchanged legacy behaviour — a login page or a slow start is not an error.
|
||||
await pg.waitForTimeout(5000);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'client') return;
|
||||
|
||||
// Re-confirm before accusing: costs 600ms on a path that is already lost, and buys immunity to
|
||||
// a dialog that merely flickered while the shell drew itself.
|
||||
await pg.waitForTimeout(600);
|
||||
const evidence = await pg.evaluate(() => {
|
||||
// innerText, not textContent: the latter concatenates without any rendered whitespace
|
||||
// ("лицензии!Выберите…", "Веб-клиентсеанс: 5") — unreadable in an error message.
|
||||
const visibleText = (el) => (el && el.offsetWidth > 0) ? (el.innerText || '').trim() : '';
|
||||
if (document.querySelector('#themesCell_theme_0')) return null; // the client won after all
|
||||
const text = visibleText(document.querySelector('#messageBoxText'));
|
||||
if (text) return { kind: 'blocked', text, seances: visibleText(document.querySelector('#seancesToFinish')) };
|
||||
const auth = document.querySelector('#authWindow');
|
||||
if (auth && auth.offsetWidth > 0) return { kind: 'auth', text: visibleText(auth) };
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
if (!evidence) return; // flicker — carry on exactly as before
|
||||
|
||||
const oneLine = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||
|
||||
if (evidence.kind === 'auth') {
|
||||
// The publication asks a human for credentials. The engine cannot answer, and until now it
|
||||
// did something worse than fail: it waited 66s and then closeModals()'s Escape dismissed the
|
||||
// dialog, leaving a blank page reported as a healthy start. Note a seance IS already created
|
||||
// here (unlike the licence case) and holds a licence — callers release it before rethrowing.
|
||||
throw new Error(
|
||||
`1C requires interactive login before the web client loads: "${oneLine(evidence.text)}"` +
|
||||
'\n The engine cannot supply credentials — publish the infobase with a user' +
|
||||
' (web-publish -UserName … → Usr=/Pwd= in the vrd) or put one in the connection string.' +
|
||||
`\n URL: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`1C startup blocked before the web client loaded: "${oneLine(evidence.text)}"` +
|
||||
(evidence.seances ? `\n Sessions the platform offers to terminate: ${oneLine(evidence.seances)}` : '') +
|
||||
'\n The engine does not press this dialog\'s buttons: its countdown auto-start may terminate' +
|
||||
' someone else\'s session on this machine.' +
|
||||
`\n If this is a licence shortage — release 1C sessions and retry. URL: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser and navigate to 1C web client URL.
|
||||
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
||||
*/
|
||||
export async function connect(url, { extensionPath } = {}) {
|
||||
if (isConnected()) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
} else {
|
||||
const extPath = findExtension(extensionPath);
|
||||
if (extPath) {
|
||||
// Launch with 1C browser extension via persistent context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
const context = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: [
|
||||
'--start-maximized',
|
||||
'--disable-extensions-except=' + extPath,
|
||||
'--load-extension=' + extPath,
|
||||
],
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setBrowser(context); // persistent context IS the browser
|
||||
setPage(context.pages()[0] || await context.newPage());
|
||||
} else {
|
||||
// Fallback: launch without extension
|
||||
setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] }));
|
||||
const context = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
setPage(await context.newPage());
|
||||
}
|
||||
|
||||
// Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js)
|
||||
page.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Capture seanceId from network requests for graceful logout
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
page.on('request', req => {
|
||||
if (seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); }
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
}
|
||||
|
||||
// Wait for 1C to initialize — or fail fast if the startup shell blocks the client.
|
||||
// MUST run before closeModals(): that presses Escape 5x, which dismisses the auth dialog and
|
||||
// destroys the evidence (measured — one Escape blanks the page).
|
||||
try {
|
||||
await waitForClientOrStartupBlock(page, url);
|
||||
} catch (e) {
|
||||
// On the auth dialog a 1C seance already exists and holds a licence, and killing the process
|
||||
// does NOT release it. cmdStart has no catch and run.mjs does not wrap the command, so the
|
||||
// error escapes and the process dies — release the seance here, while we still can.
|
||||
await softDeadline(disconnect(), 20000, 'disconnect(startup-block)');
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Try to close startup modals (Путеводитель etc.)
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||
* Silent — if page is closed or session info missing, just returns.
|
||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||
*/
|
||||
async function logoutSlot(slot, waitMs = 500) {
|
||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||
try {
|
||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
await slot.page.evaluate(async (url) => {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
}, logoutUrl);
|
||||
await slot.page.waitForTimeout(waitMs);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate the 1C session and close the browser.
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
const wasMultiContext = contexts.size > 0;
|
||||
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (wasMultiContext) {
|
||||
saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
// Deadline-bounded: an unresponsive slot must not hold up the shutdown of the others.
|
||||
// nodeLogout is the fallback that needs no renderer — it is what keeps the license
|
||||
// from leaking when the page is the thing that died.
|
||||
const own = await softDeadline(logoutSlot(slot), 3000, 'logoutSlot');
|
||||
if (!own.ok) await nodeLogout(slot, 3000);
|
||||
}
|
||||
contexts.clear();
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
}
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license (single-session connect path).
|
||||
// Skipped after the multi-context path: `page` still mirrors the last active slot, which
|
||||
// was just logged out above — re-sending it would pay a hung page's cost a second time.
|
||||
if (!wasMultiContext) {
|
||||
await softDeadline(logoutSlot({ page, sessionPrefix, seanceId }, 1000), 4000, 'logoutSlot');
|
||||
}
|
||||
await softDeadline(browser.close(), 10000, 'browser.close');
|
||||
// Floor: if Chromium ignored close(), take the process out — never leave an orphan.
|
||||
try { browser.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
try { browser.process?.()?.kill('SIGKILL'); } catch {}
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
// Clean up persistent user data dir
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a running browser server via CDP WebSocket.
|
||||
* Sets module state so all functions (getFormState, clickElement, etc.) work.
|
||||
*/
|
||||
export async function attach(wsEndpoint, session = {}) {
|
||||
if (isConnected()) return;
|
||||
setBrowser(await chromium.connect(wsEndpoint));
|
||||
const ctx = browser.contexts()[0];
|
||||
setPage(ctx?.pages()[0]);
|
||||
if (!page) throw new Error('No page found in browser');
|
||||
setSessionPrefix(session.sessionPrefix || null);
|
||||
setSeanceId(session.seanceId || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from browser without closing it.
|
||||
* Returns session state for persistence.
|
||||
*/
|
||||
export function detach() {
|
||||
const session = { sessionPrefix, seanceId };
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Get current session state (for saving between reconnections). */
|
||||
export function getSession() {
|
||||
return { sessionPrefix, seanceId };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-context support (used by run.mjs cmdTest only)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save current module-level state into the active slot before switching.
|
||||
* No-op if no active slot.
|
||||
*/
|
||||
function saveActiveSlot() {
|
||||
if (!activeContextName) return;
|
||||
const slot = contexts.get(activeContextName);
|
||||
if (!slot) return;
|
||||
slot.page = page;
|
||||
slot.sessionPrefix = sessionPrefix;
|
||||
slot.seanceId = seanceId;
|
||||
slot.highlightMode = highlightMode;
|
||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||
}
|
||||
|
||||
/** Load a slot's state into module-level vars and mark it active. */
|
||||
function activateSlot(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||
setPage(slot.page);
|
||||
setSessionPrefix(slot.sessionPrefix);
|
||||
setSeanceId(slot.seanceId);
|
||||
setHighlightMode(slot.highlightMode || false);
|
||||
setActiveContextName(name);
|
||||
}
|
||||
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
|
||||
// Network counters feed the hang/slow diagnosis (see probeContext). These events are
|
||||
// emitted by the BROWSER process, so they keep flowing even when the page's JS thread is
|
||||
// wedged and page.evaluate() can no longer answer. Supporting colour only, not a verdict:
|
||||
// a wedged renderer cannot issue requests, so "quiet" looks the same as "idle waiting".
|
||||
// Counters only — never buffer URLs, this runs for the whole suite.
|
||||
slot.net = { lastEventAt: Date.now(), inFlight: 0, requests: 0, responses: 0 };
|
||||
const settled = () => { slot.net.inFlight = Math.max(0, slot.net.inFlight - 1); slot.net.lastEventAt = Date.now(); };
|
||||
pg.on('requestfinished', settled);
|
||||
pg.on('requestfailed', settled);
|
||||
pg.on('response', () => { slot.net.responses++; slot.net.lastEventAt = Date.now(); });
|
||||
|
||||
pg.on('request', req => {
|
||||
slot.net.requests++;
|
||||
slot.net.inFlight++;
|
||||
slot.net.lastEventAt = Date.now();
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
slot.sessionPrefix = m[1];
|
||||
slot.seanceId = m[2];
|
||||
if (activeContextName === name) {
|
||||
setSessionPrefix(m[1]);
|
||||
setSeanceId(m[2]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or navigate) a named browser context.
|
||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||
* persistent profile.
|
||||
*
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
/**
|
||||
* Navigate the active slot to `url` and settle: client up, or a startup block raised.
|
||||
*
|
||||
* On a block the slot MUST NOT survive. It is registered before this runs, and the runner's
|
||||
* ensureContext is `if (browser.hasContext(name)) return;` — so a broken slot left in the registry
|
||||
* would silently serve every later test the refusal dialog, i.e. exactly the blindness being fixed.
|
||||
* abortContext also handles the tab-mode last-page teardown, so the next createContext relaunches.
|
||||
*/
|
||||
async function openAndSettle(name, url) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try {
|
||||
await waitForClientOrStartupBlock(page, url);
|
||||
} catch (e) {
|
||||
await softDeadline(abortContext(name), 10000, 'abortContext(startup-block)');
|
||||
throw e;
|
||||
}
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
return await openAndSettle(name, url);
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||
}
|
||||
if (activeMode && activeMode !== isolation) {
|
||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||
}
|
||||
|
||||
// First context: launch browser. Subsequent: reuse existing.
|
||||
let isFirstContext = !browser;
|
||||
if (isFirstContext) {
|
||||
const extPath = findExtension(extensionPath);
|
||||
const launchArgs = ['--start-maximized'];
|
||||
if (extPath) {
|
||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||
}
|
||||
if (isolation === 'tab') {
|
||||
// Persistent context: extension loads reliably, one window with tabs per context
|
||||
setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()));
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}));
|
||||
} else {
|
||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||
setBrowser(await chromium.launch({ headless: false, args: launchArgs }));
|
||||
}
|
||||
setActiveMode(isolation);
|
||||
}
|
||||
|
||||
// Save current active before switching
|
||||
saveActiveSlot();
|
||||
|
||||
// Create slot — page differs by mode
|
||||
let newCtx, newPage;
|
||||
if (activeMode === 'tab') {
|
||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||
newCtx = browser;
|
||||
if (isFirstContext) {
|
||||
newPage = browser.pages()[0] || await browser.newPage();
|
||||
} else {
|
||||
newPage = await browser.newPage();
|
||||
}
|
||||
} else {
|
||||
// Window mode: each slot owns its BrowserContext + page
|
||||
newCtx = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
newPage = await newCtx.newPage();
|
||||
}
|
||||
|
||||
const slot = {
|
||||
context: newCtx,
|
||||
page: newPage,
|
||||
sessionPrefix: null,
|
||||
seanceId: null,
|
||||
highlightMode: false,
|
||||
};
|
||||
contexts.set(name, slot);
|
||||
|
||||
attachSessionListeners(newPage, slot, name);
|
||||
activateSlot(name);
|
||||
|
||||
return await openAndSettle(name, url);
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
export async function setActiveContext(name) {
|
||||
if (activeContextName === name) return;
|
||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||
// up to the moment of the switch (avoids a "jump" in video time).
|
||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||
saveActiveSlot();
|
||||
activateSlot(name);
|
||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||
// re-attach its screencast to the newly active page.
|
||||
if (recorder && recorder._attachPage) {
|
||||
await recorder._attachPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function listContexts() {
|
||||
return [...contexts.keys()];
|
||||
}
|
||||
|
||||
export function getActiveContext() {
|
||||
return activeContextName;
|
||||
}
|
||||
|
||||
export function hasContext(name) {
|
||||
return contexts.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||
* (window mode), remove from registry. Cannot close the currently active
|
||||
* context — caller must setActiveContext to another first. This keeps the
|
||||
* recorder/page invariants simple: recorder is always attached to the
|
||||
* active slot, which closeContext never touches.
|
||||
*
|
||||
* @throws if name is not registered or equals the active context.
|
||||
*/
|
||||
export async function closeContext(name) {
|
||||
if (!contexts.has(name)) {
|
||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
}
|
||||
if (name === activeContextName) {
|
||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||
}
|
||||
const slot = contexts.get(name);
|
||||
await logoutSlot(slot);
|
||||
if (activeMode === 'tab') {
|
||||
try { await slot.page.close(); } catch {}
|
||||
} else {
|
||||
try { await slot.context.close(); } catch {}
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a 1C seance straight from Node — no renderer, no CDP, no browser involved.
|
||||
*
|
||||
* Measured on the webtest stand: the seance is identified by `seanceId` in the URL and the
|
||||
* client holds NO cookies at all (context.cookies() → []), so this request is equivalent to
|
||||
* the one logoutSlot makes from inside the page. Verified end-to-end: after this call the
|
||||
* web client reports "сеанс был завершен" on its next action.
|
||||
*
|
||||
* This is the only logout that still works when the renderer is wedged — which is exactly
|
||||
* when a license would otherwise leak until the server-side seance timeout.
|
||||
*
|
||||
* @returns {Promise<boolean>} true only on a 2xx answer (a 401/404 must not pass for success).
|
||||
*/
|
||||
async function nodeLogout(slot, ms = 3000) {
|
||||
if (!slot?.sessionPrefix || !slot?.seanceId) return false;
|
||||
try {
|
||||
const res = await fetch(`${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"root":{}}',
|
||||
signal: AbortSignal.timeout(ms),
|
||||
});
|
||||
return res.status >= 200 && res.status < 300;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this context's browser alive, and is its renderer still answering?
|
||||
*
|
||||
* The two probes separate the failure modes that look identical from the outside:
|
||||
* browserAlive && !rendererAlive → the page's JS thread is wedged: a hang. Nothing that
|
||||
* goes through the renderer (evaluate, screenshot, clicks) can ever come back.
|
||||
* both alive → nothing is broken; the test simply outran its timeout.
|
||||
*
|
||||
* cookies() is served by the browser process (measured: 1ms against a wedged renderer),
|
||||
* page.evaluate() is not — that asymmetry is the whole trick.
|
||||
*
|
||||
* @returns {Promise<{browserAlive: boolean, rendererAlive: boolean, browserMs: number, rendererMs: number, pageClosed: boolean}>}
|
||||
*/
|
||||
export async function probeContext(name, { ms = 2000 } = {}) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot?.page) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||
if (slot.page.isClosed()) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||
|
||||
const [b, r] = await Promise.all([
|
||||
softDeadline(slot.page.context().cookies(), ms, 'browser probe'),
|
||||
softDeadline(slot.page.evaluate(() => 1), ms, 'renderer probe'),
|
||||
]);
|
||||
return {
|
||||
browserAlive: b.ok,
|
||||
rendererAlive: r.ok,
|
||||
browserMs: b.ms,
|
||||
rendererMs: r.ms,
|
||||
pageClosed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read-only view of a slot's network activity. Never hands out the slot itself. */
|
||||
export function getContextDiagnostics(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) return null;
|
||||
const net = slot.net || { lastEventAt: 0, inFlight: 0, requests: 0, responses: 0 };
|
||||
return {
|
||||
name,
|
||||
isolation: activeMode,
|
||||
net: { ...net },
|
||||
msSinceLastNetEvent: net.lastEventAt ? Date.now() - net.lastEventAt : null,
|
||||
pageClosed: slot.page ? slot.page.isClosed() : true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-release an unresponsive context — including the ACTIVE one, which closeContext
|
||||
* refuses to touch. Every step is wall-clock bounded, so this path is never at the mercy
|
||||
* of the thing that hung: it is bounded by timers, never by browser cooperation.
|
||||
*
|
||||
* Ordering matters: logout FIRST (a dead page can't release its own license), close second.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.logoutMs=3000] budget per logout attempt
|
||||
* @param {number} [opts.closeMs=5000] budget for page.close()/context.close()
|
||||
* @param {string} [opts.parkOn] context to activate afterwards (default: any survivor)
|
||||
* @returns {Promise<{name, logout: 'node'|'page'|'sibling'|'failed'|'skipped', closed: 'page'|'context'|'browser-killed'|'failed', escalated: boolean, notes: string[]}>}
|
||||
*/
|
||||
export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, parkOn } = {}) {
|
||||
const out = { name, logout: 'skipped', closed: 'failed', escalated: false, notes: [] };
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) { out.notes.push('not registered'); return out; }
|
||||
|
||||
// The recorder follows the active page; if we are about to close that page, stop it first
|
||||
// or the CDP screencast is dead for the rest of the run.
|
||||
if (recorder && activeContextName === name) {
|
||||
const r = await softDeadline(stopRecording(), 10000, 'stopRecording');
|
||||
if (!r.ok) out.notes.push(`stopRecording: ${r.err.message.split('\n')[0]}`);
|
||||
}
|
||||
|
||||
// ── Logout cascade: first success wins. node first — it needs neither renderer nor CDP.
|
||||
if (slot.seanceId && slot.sessionPrefix) {
|
||||
if (await nodeLogout(slot, logoutMs)) {
|
||||
out.logout = 'node';
|
||||
} else {
|
||||
const own = await softDeadline(logoutSlot(slot, 0), logoutMs, 'logoutSlot');
|
||||
if (own.ok) {
|
||||
out.logout = 'page';
|
||||
} else {
|
||||
// Same origin ⇒ same seance namespace; a live sibling can post the logout for us.
|
||||
const sibling = [...contexts.entries()].find(([n, s]) =>
|
||||
n !== name && s.page && !s.page.isClosed() && s.sessionPrefix === slot.sessionPrefix);
|
||||
if (sibling) {
|
||||
const url = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
const sib = await softDeadline(
|
||||
sibling[1].page.evaluate(async (u) => {
|
||||
const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
return r.status;
|
||||
}, url), logoutMs, 'sibling logout');
|
||||
if (sib.ok && sib.value >= 200 && sib.value < 300) out.logout = 'sibling';
|
||||
else out.logout = 'failed';
|
||||
} else {
|
||||
out.logout = 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (out.logout === 'failed') out.notes.push('license may leak until the 1C seance times out');
|
||||
}
|
||||
|
||||
// In tab mode `browser` is a persistent BrowserContext (see createContext): closing its LAST
|
||||
// page leaves Chromium with no windows and it exits, so the next createContext() would fail
|
||||
// with "Failed to open a new tab". When this is the only slot, tear the browser down on
|
||||
// purpose and reset state — the next createContext() then relaunches cleanly.
|
||||
const lastPageInTabMode = activeMode === 'tab' && contexts.size === 1;
|
||||
|
||||
// ── Close. runBeforeUnload:false is what survives a wedged renderer: the browser process
|
||||
// tears the target down instead of asking the page's JS to agree.
|
||||
if (activeMode === 'tab') {
|
||||
// tab mode: slot.context IS the shared browser — closing it would kill every context.
|
||||
const c = await softDeadline(slot.page.close({ runBeforeUnload: false }), closeMs, 'page.close');
|
||||
if (c.ok) out.closed = 'page';
|
||||
} else {
|
||||
const c = await softDeadline(slot.context.close(), closeMs, 'context.close');
|
||||
if (c.ok) out.closed = 'context';
|
||||
}
|
||||
|
||||
// ── Escalate: if even the close hung, the browser itself is suspect. Kill it and reset
|
||||
// state, otherwise the next createContext() would call newPage() on a dead object and
|
||||
// every remaining test would fail.
|
||||
if (out.closed === 'failed') {
|
||||
out.escalated = true;
|
||||
out.notes.push('close breached its deadline — killing the browser');
|
||||
const b = browser;
|
||||
await softDeadline(Promise.resolve(b?.close?.()), 5000, 'browser.close');
|
||||
try { b?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
try { b?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
out.closed = 'browser-killed';
|
||||
contexts.clear();
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
contexts.delete(name);
|
||||
|
||||
if (lastPageInTabMode) {
|
||||
out.notes.push('last tab closed — browser torn down, next createContext relaunches it');
|
||||
await softDeadline(Promise.resolve(browser?.close?.()), 5000, 'browser.close');
|
||||
try { browser?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||
setBrowser(null);
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
setActiveMode(null);
|
||||
if (persistentUserDataDir) {
|
||||
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||
setPersistentUserDataDir(null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Park the active pointer on a survivor (or nothing). Deliberately NOT via
|
||||
// setActiveContext()/saveActiveSlot() — those would write the dying page back into a slot.
|
||||
if (activeContextName === name) {
|
||||
const survivor = (parkOn && contexts.has(parkOn)) ? parkOn : [...contexts.keys()][0];
|
||||
if (survivor) {
|
||||
activateSlot(survivor);
|
||||
if (recorder) { try { await recorder._attachPage(page); } catch { /* recording is best-effort */ } }
|
||||
} else {
|
||||
// No slots left: isConnected() goes false and engine calls fail fast until the next
|
||||
// ensureContext() recreates one. That is the intended contract, not a leak.
|
||||
setPage(null);
|
||||
setSessionPrefix(null);
|
||||
setSeanceId(null);
|
||||
setActiveContextName(null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// web-test forms/click-group v1.2 — click handler for collapsible/popup form-group titles.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Reuses the tree/grid expand vocabulary so the model has ONE mental model:
|
||||
// clickElement('<group title>', { expand: true }) — reveal (idempotent)
|
||||
// clickElement('<group title>', { expand: false }) — hide (idempotent)
|
||||
// clickElement('<group title>', { toggle: true }) — flip
|
||||
// clickElement('<group title>') — flip (bare click)
|
||||
//
|
||||
// target.collapsed comes from findClickTargetScript (groupCollapsed in dom/_shared.mjs).
|
||||
|
||||
import { page } from '../core/state.mjs';
|
||||
import { scrollGroupIntoViewScript } from '../../dom.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { modifierClick, returnFormState } from '../core/helpers.mjs';
|
||||
import { shouldClickToggle } from '../table/grid-toggle.mjs';
|
||||
|
||||
// Group captions repeat across a form's blocks («Показать детализацию» in every block), and a
|
||||
// collapsible group swaps its own caption when expanded (CollapsedRepresentationTitle:
|
||||
// «Показать детализацию» ↔ «Скрыть детализацию»). Compare normalised so a real caption swap
|
||||
// is not confused with nbsp/ё spelling differences.
|
||||
const norm = (s) => (s || '').replace(/ /g, ' ').replace(/ё/gi, 'е').trim().toLowerCase();
|
||||
|
||||
export async function clickFormGroupTarget(target, ctx) {
|
||||
const { formNum, modifier, toggle, expand } = ctx;
|
||||
// shouldClickToggle expects { isExpanded }; with an unknown state (undefined) always click.
|
||||
const state = target.collapsed == null ? null : { isExpanded: !target.collapsed };
|
||||
const shouldClick = shouldClickToggle(state, expand, toggle);
|
||||
if (shouldClick) {
|
||||
// The target is clicked by coordinates, and on a long form those go stale or fall outside
|
||||
// the viewport — the click then silently does nothing. Scroll the title into view and take
|
||||
// a fresh point.
|
||||
const pt = await page.evaluate(scrollGroupIntoViewScript(formNum, target.label));
|
||||
await modifierClick(pt?.x ?? target.x, pt?.y ?? target.y, modifier);
|
||||
}
|
||||
await waitForStable(formNum);
|
||||
const result = await returnFormState({
|
||||
clicked: { kind: 'formGroup', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) },
|
||||
hint: shouldClick
|
||||
? 'Group toggled. Call getFormState — its groups[].collapsed and revealed/hidden content update.'
|
||||
: 'Group already in desired state.',
|
||||
});
|
||||
|
||||
// The technical name is the stable key: it finds the entry in groups[] and clicks the same
|
||||
// group again. The caption cannot do that — it changes when the group expands.
|
||||
const after = (result.groups || []).find(g => g.name === target.label);
|
||||
if (target.label) result.clicked.group = target.label;
|
||||
if (after) {
|
||||
result.clicked.title = after.title;
|
||||
if (norm(after.title) !== norm(target.name)) {
|
||||
result.hint += ` The group is now titled "${after.title}"`
|
||||
+ ` — click it by that caption or by its technical name "${target.label}".`;
|
||||
}
|
||||
}
|
||||
|
||||
// Postcondition: a click that toggled nothing is a silent lie (same rule as the disabled
|
||||
// guard in core/click.mjs). Checked ONLY when a click actually happened and the state is
|
||||
// readable: with collapsed == null (unrecognised layout) there is nothing to compare against.
|
||||
if (shouldClick && target.collapsed != null) {
|
||||
if (after && after.collapsed === target.collapsed) {
|
||||
throw new Error(`clickElement: group "${target.name}" did not toggle `
|
||||
+ `(collapsed stayed ${after.collapsed})`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// web-test forms/close v1.20 — Close current form via Escape, handle save-changes confirmation.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||
import { detectFormScript, closeCrossScript } from '../../dom.mjs';
|
||||
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||
import { waitForStable } from '../core/wait.mjs';
|
||||
import { returnFormState } from '../core/helpers.mjs';
|
||||
import { getFormState } from './state.mjs';
|
||||
|
||||
/**
|
||||
* Which button actually CLOSES the form on this confirmation?
|
||||
*
|
||||
* 1C asks two different questions with the same two buttons:
|
||||
* «Данные были изменены. Сохранить изменения?» → Да = save+close, Нет = close without saving
|
||||
* «Виза сохранена не будет. Закрыть согласование?» → Да = close, Нет = STAY IN THE FORM
|
||||
* A hard-coded «Нет» for save:false is right for the first and exactly backwards for the second:
|
||||
* the caller asks to close and gets a form that stays open (measured — that is how a modal leaked
|
||||
* into the next test).
|
||||
*
|
||||
* Decide by the QUESTION, not by the whole message: both texts contain the root «сохран», but only
|
||||
* the interrogative sentence says what the buttons mean. Unknown wording keeps the legacy answer.
|
||||
*/
|
||||
function pickConfirmationLabel(message, save) {
|
||||
const question = (String(message || '').match(/[^.!?]*\?/g) || []).pop() || '';
|
||||
if (/сохранит/i.test(question)) return save ? 'Да' : 'Нет'; // "…Сохранить изменения?"
|
||||
if (/закрыт/i.test(question)) return 'Да'; // "…Закрыть согласование?" — Да = закрыть
|
||||
return save ? 'Да' : 'Нет'; // unknown → as before
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current form/dialog: Escape first, the modal window cross if Escape does nothing.
|
||||
* @param {Object} [opts]
|
||||
* @param {boolean} [opts.save] - Handle a confirmation automatically. The button is chosen by the
|
||||
* MEANING of the question (see pickConfirmationLabel), not by a fixed label:
|
||||
* true → save and close
|
||||
* false → close without saving
|
||||
* undefined → return confirmation as hint for caller to decide
|
||||
*/
|
||||
export async function closeForm({ save } = {}) {
|
||||
ensureConnected();
|
||||
await dismissPendingErrors();
|
||||
// If platform dialogs are open, close them instead of pressing Escape
|
||||
const pd = await detectPlatformDialogs();
|
||||
if (pd.length) {
|
||||
await closePlatformDialogs();
|
||||
await page.waitForTimeout(300);
|
||||
return returnFormState({ closed: true, closedPlatformDialogs: pd });
|
||||
}
|
||||
const beforeForm = await page.evaluate(detectFormScript());
|
||||
await page.keyboard.press('Escape');
|
||||
await waitForStable(beforeForm);
|
||||
let state = await getFormState();
|
||||
let err = await checkForErrors();
|
||||
let usedCross = false;
|
||||
let nothingToClose = false;
|
||||
|
||||
// Escape did nothing and raised no question. On a real stand that is the norm, not the exception:
|
||||
// Escape closed neither a modal nor even a plain list there. Fall back to the cross a human would
|
||||
// click. Only when nothing moved, so ordinary forms keep the cheap Escape path.
|
||||
if (!err?.confirmation && state.form === beforeForm) {
|
||||
const crossId = await page.evaluate(closeCrossScript());
|
||||
if (crossId) {
|
||||
await page.click(`#${crossId}`).catch(() => {});
|
||||
await waitForStable(beforeForm);
|
||||
state = await getFormState();
|
||||
err = await checkForErrors();
|
||||
usedCross = true;
|
||||
} else {
|
||||
// No cross anywhere: the platform itself says this surface is not closable — i.e. we are on
|
||||
// the desktop. This is what tells resetState "clean" without knowing anything about the
|
||||
// application: a home page with three forms on it looks exactly like an empty one here.
|
||||
nothingToClose = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (err?.confirmation) {
|
||||
if (save === true || save === false) {
|
||||
const label = pickConfirmationLabel(err.confirmation.message, save);
|
||||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||
const btns = await page.$$(btnSel);
|
||||
for (const b of btns) {
|
||||
const txt = (await b.textContent()).trim();
|
||||
if (txt === label) {
|
||||
if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording
|
||||
await b.click({ force: true });
|
||||
await waitForStable(beforeForm);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterForm = await page.evaluate(detectFormScript());
|
||||
// Report which button was pressed: on a "…Закрыть?" question it is «Да» even for save:false,
|
||||
// and a silent surprise there is what cost the last investigation its afternoon.
|
||||
return returnFormState({ closed: afterForm !== beforeForm, confirmationAnswered: label, closedViaCross: usedCross || undefined });
|
||||
}
|
||||
state.confirmation = err.confirmation;
|
||||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||
return state;
|
||||
}
|
||||
return returnFormState({
|
||||
closed: state.form !== beforeForm,
|
||||
closedViaCross: usedCross || undefined,
|
||||
nothingToClose: nothingToClose || undefined,
|
||||
});
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: xdto-compile
|
||||
description: Создание пакета XDTO 1С из XML-схемы (XSD). Используй когда нужно добавить в конфигурацию пакет XDTO — под обмен, интеграцию, веб-сервис или внешний XML-формат
|
||||
argument-hint: -XsdPath <файл.xsd>|-Xsd <схема> -OutputDir <каталог-исходников> [-Name <имя>] [-Synonym <синоним>] [-Comment <текст>] [-Force]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /xdto-compile — Создание пакета XDTO из XML-схемы
|
||||
|
||||
Собирает пакет XDTO по XML-схеме: `XDTOPackages/<Имя>.xml`,
|
||||
`XDTOPackages/<Имя>/Ext/Package.bin` и регистрацию в `Configuration.xml`.
|
||||
|
||||
Вход — обычная XML-схема, писать её нужно так же, как для любого другого инструмента.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `XsdPath` | один из двух | Путь к файлу XML-схемы. Псевдоним — `-Path` |
|
||||
| `Xsd` | один из двух | Схема строкой, вместо `-XsdPath` |
|
||||
| `OutputDir` | да | Каталог исходников конфигурации или расширения — там, где лежит `Configuration.xml` |
|
||||
| `Name` | нет | Имя объекта метаданных. По умолчанию — из `xs:appinfo`, иначе имя файла XSD, санированное под идентификатор 1С |
|
||||
| `Synonym` | нет | Синоним (строка). По умолчанию — из `xs:appinfo`, иначе имя пакета. Для нескольких языков задавай синоним в схеме, блоком `xs:appinfo` |
|
||||
| `Comment` | нет | Комментарий. По умолчанию — из `xs:appinfo` |
|
||||
| `Force` | нет | Перезаписать существующий пакет. Без него навык откажется затирать уже собранный пакет |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/xdto-compile/scripts/xdto-compile.py" -XsdPath "<схема.xsd>" -OutputDir "<каталог-исходников>"
|
||||
```
|
||||
|
||||
Примеры:
|
||||
```powershell
|
||||
... -XsdPath bank.xsd -OutputDir src -Name ОбменСБанком -Synonym "Обмен с банком"
|
||||
... -XsdPath fss.xsd -OutputDir src -Force
|
||||
```
|
||||
|
||||
## Читай предупреждения
|
||||
|
||||
XSD выразительнее модели XDTO. Всё, что не переносится один в один, навык переносит
|
||||
приближённо и **пишет об этом**:
|
||||
|
||||
```
|
||||
Предупреждения (2) — конструкции XSD без точного соответствия в модели XDTO:
|
||||
! Документ : вложенная xs:choice уплощена в последовательность — выбор одного из вариантов не сохранён
|
||||
! Документ : кратность на вложенной частице (<xs:sequence minOccurs/maxOccurs>) не выражается в модели XDTO
|
||||
```
|
||||
|
||||
Такое сообщение означает, что пакет собран, но схема упрощена. Если упрощение
|
||||
недопустимо — меняй схему (например, разноси варианты `xs:choice` по разным типам),
|
||||
а не игнорируй.
|
||||
|
||||
Что переносится приближённо: вложенные `xs:sequence`/`xs:choice` (уплощаются в плоский
|
||||
список свойств), `xs:all` (становится последовательностью), кратность на частице,
|
||||
`substitutionGroup`, `xs:key`/`keyref`/`unique`, `xs:redefine`.
|
||||
|
||||
`xs:group` и `xs:attributeGroup` раскрываются по ссылке — их содержимое попадает в тип.
|
||||
`xs:include` игнорируется: зависимости в XDTO разрешаются только по namespace,
|
||||
поэтому включаемую схему нужно собрать отдельным пакетом и заменить `include` на `import`.
|
||||
|
||||
## Посмотреть, что получилось
|
||||
|
||||
Модель пакета лежит в `Ext/Package.bin`. Несмотря на расширение, это текстовый XML,
|
||||
но читать его напрямую обычно незачем: состав собранного пакета показывает
|
||||
`/xdto-info`, а полную схему — `/xdto-decompile`.
|
||||
|
||||
## Зависимости между пакетами
|
||||
|
||||
`<xs:import namespace="…"/>` разрешается по namespace среди пакетов конфигурации
|
||||
или расширения. Если пакета с таким пространством имён нет, платформа при загрузке
|
||||
молча подменит тип на `xs:anyType` — без ошибки. Собирай сначала зависимости, потом
|
||||
зависящий пакет, и проверяй результат через `/xdto-validate`.
|
||||
|
||||
Какие пакеты уже собраны, видно в `ChildObjects` файла `Configuration.xml`.
|
||||
|
||||
## Что XSD выразить не может
|
||||
|
||||
Две вещи модель XDTO умеет, а XML Schema — нет: `nillable` у атрибута и `qualified`
|
||||
у отдельного свойства. Они пишутся атрибутами из пространства имён модели:
|
||||
|
||||
```xml
|
||||
<xs:attribute name="Представление" type="xs:string"
|
||||
xmlns:xdto="http://v8.1c.ru/8.1/xdto" xdto:nillable="true"/>
|
||||
```
|
||||
|
||||
Схема остаётся валидной — валидаторы такие атрибуты игнорируют. Полный список
|
||||
и таблица соответствий XSD ↔ XDTO — в [xsd-reference.md](xsd-reference.md).
|
||||
|
||||
Свойства объекта метаданных можно задать прямо в схеме:
|
||||
|
||||
```xml
|
||||
<xs:annotation>
|
||||
<xs:appinfo>
|
||||
<xdto:package xmlns:xdto="http://v8.1c.ru/8.1/xdto">
|
||||
<xdto:name>ОбменСБанком</xdto:name>
|
||||
<xdto:synonym lang="ru">Обмен с банком</xdto:synonym>
|
||||
</xdto:package>
|
||||
</xs:appinfo>
|
||||
</xs:annotation>
|
||||
```
|
||||
|
||||
## Типичный workflow
|
||||
|
||||
1. Получить XSD от контрагента (или выгрузить схему существующего пакета: `/xdto-decompile`)
|
||||
2. `/xdto-compile -XsdPath <файл> -OutputDir <каталог-исходников>` — прочитать предупреждения
|
||||
3. `/xdto-validate <каталог-исходников>/XDTOPackages/<Имя>` — убедиться, что типы разрешились
|
||||
4. `/db-load-xml` + `/db-update`
|
||||
|
||||
Правка существующего пакета: точечно — `/xdto-edit`; переработать схему целиком —
|
||||
`/xdto-decompile` → правка XSD → `/xdto-compile -Force`.
|
||||
@@ -1,980 +0,0 @@
|
||||
# xdto-compile v1.2 — Build a 1C XDTO package from an XML Schema (XSD)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true, ParameterSetName='File')]
|
||||
[Alias('Path')]
|
||||
[string]$XsdPath,
|
||||
[Parameter(Mandatory=$true, ParameterSetName='Inline')]
|
||||
[string]$Xsd,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputDir,
|
||||
[string]$Name,
|
||||
[object]$Synonym,
|
||||
[string]$Comment,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Эти пространства имён предоставляет сама платформа — пакетов в конфигурации
|
||||
# для них нет и быть не должно (выведено по корпусу: импортируются, но
|
||||
# targetNamespace с таким значением ни у одного пакета нет)
|
||||
$PLATFORM_NS = @(
|
||||
"http://v8.1c.ru/8.1/data/core",
|
||||
"http://v8.1c.ru/8.1/data/enterprise",
|
||||
"http://v8.1c.ru/8.1/data/enterprise/current-config",
|
||||
"http://v8.1c.ru/8.1/data-composition-system/settings",
|
||||
"http://v8.1c.ru/8.3/data/ext",
|
||||
"http://www.w3.org/2001/XMLSchema"
|
||||
)
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
$XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
$XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
$MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
$V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
# Версия формата выгрузки — из Configuration.xml проекта (климб вверх от каталога исходников).
|
||||
# Её задаёт платформа выгрузки: 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.20 пакет расходился с выгрузкой платформы.
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
$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 Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.get_LocalName() }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
$mode = "deny"
|
||||
try {
|
||||
$pj = Find-V8Project $cfgDir
|
||||
if ($pj) {
|
||||
$cfg = Get-Content -Path $pj -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($cfg.PSObject.Properties.Name -contains 'editingAllowedCheck' -and $cfg.editingAllowedCheck) {
|
||||
$mode = [string]$cfg.editingAllowedCheck
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return $mode
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath) {
|
||||
try {
|
||||
$mode = $null
|
||||
$d = $targetPath
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$cfgXml = Join-Path $d "Configuration.xml"
|
||||
$supportBin = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
# Автономный объект (внешняя обработка/отчёт) — граница климба
|
||||
foreach ($x in @(Get-ChildItem -Path $d -Filter "*.xml" -File -ErrorAction SilentlyContinue)) {
|
||||
if (Test-ExternalObjectRoot $x.FullName) { return }
|
||||
}
|
||||
if (Test-Path $cfgXml) {
|
||||
if (Test-Path $supportBin) {
|
||||
$mode = Get-EditMode $d
|
||||
if ($mode -eq "off") { return }
|
||||
$msg = "Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). Правка может быть запрещена."
|
||||
if ($mode -eq "warn") { Write-Warning $msg; return }
|
||||
throw "$msg Снимите с поддержки (/support-edit) или задайте editingAllowedCheck в .v8-project.json."
|
||||
}
|
||||
return
|
||||
}
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
} catch [System.Management.Automation.RuntimeException] {
|
||||
throw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# --- Load the schema ---
|
||||
|
||||
if ($PSCmdlet.ParameterSetName -eq 'Inline') {
|
||||
$xsdText = $Xsd
|
||||
$defaultName = "Package"
|
||||
} else {
|
||||
if (-not (Test-Path $XsdPath -PathType Leaf)) { throw "Файл XSD не найден: $XsdPath" }
|
||||
$xsdText = [System.IO.File]::ReadAllText($XsdPath)
|
||||
$defaultName = [System.IO.Path]::GetFileNameWithoutExtension($XsdPath)
|
||||
}
|
||||
|
||||
$xdoc = New-Object System.Xml.XmlDocument
|
||||
$xdoc.PreserveWhitespace = $false
|
||||
try { $xdoc.LoadXml($xsdText) } catch { throw "Не удалось разобрать XSD: $($_.Exception.Message)" }
|
||||
|
||||
$schema = $xdoc.DocumentElement
|
||||
if ($schema.get_LocalName() -ne "schema" -or $schema.NamespaceURI -ne $XS_NS) {
|
||||
throw "Ожидался корневой <xs:schema> в пространстве имён $XS_NS"
|
||||
}
|
||||
|
||||
$targetNs = $schema.GetAttribute("targetNamespace")
|
||||
|
||||
# --- Emit-tree primitives -----------------------------------------------------
|
||||
# A node carries attributes in canonical order; QName values keep their namespace
|
||||
# so prefixes can be assigned per depth at serialization time (the dNpN scheme).
|
||||
|
||||
function New-Node([string]$tag) {
|
||||
return [pscustomobject]@{ Tag = $tag; Attrs = (New-Object System.Collections.ArrayList); Children = (New-Object System.Collections.ArrayList); Text = $null; Prefix = $null; DeclareNs = $null }
|
||||
}
|
||||
function Add-Attr($node, [string]$name, $value) {
|
||||
# $value НЕ типизируем: [string]$null коэрсится в "" и атрибут ложно появляется
|
||||
if ($null -eq $value) { return }
|
||||
[void]$node.Attrs.Add([pscustomobject]@{ Name = $name; Value = [string]$value; Ns = $null; Local = $null })
|
||||
}
|
||||
function Add-QAttr($node, [string]$name, $ns, $local) {
|
||||
if ($null -eq $local) { return }
|
||||
[void]$node.Attrs.Add([pscustomobject]@{ Name = $name; Value = $null; Ns = [string]$ns; Local = [string]$local })
|
||||
}
|
||||
function Add-QListAttr($node, [string]$name, $pairs, [bool]$clark) {
|
||||
if (-not $pairs -or $pairs.Count -eq 0) { return }
|
||||
[void]$node.Attrs.Add([pscustomobject]@{ Name = $name; Value = $null; Ns = $null; Local = $null; List = $pairs; Clark = $clark })
|
||||
}
|
||||
function Add-Child($node, $child) { if ($child) { [void]$node.Children.Add($child) } }
|
||||
|
||||
# Canonical attribute order per element — derived by topological sort over the
|
||||
# whole 8.3.24 corpus (acc + erp, 760 packages), see docs/1c-xdto-spec.md.
|
||||
$ATTR_ORDER = @{
|
||||
"package" = @("targetNamespace", "elementFormQualified", "attributeFormQualified")
|
||||
"import" = @("namespace")
|
||||
"objectType" = @("name", "base", "open", "abstract", "mixed", "ordered", "sequenced")
|
||||
"property" = @("name", "ref", "type", "lowerBound", "upperBound", "nillable", "fixed", "default", "form", "localName", "qualified")
|
||||
"valueType" = @("name", "base", "variety", "itemType", "length", "memberTypes", "minExclusive", "maxExclusive", "minInclusive", "maxInclusive", "minLength", "maxLength", "totalDigits", "fractionDigits", "whiteSpace")
|
||||
"typeDef" = @("xsi:type", "base", "mixed", "open", "ordered", "sequenced", "variety", "itemType", "length", "memberTypes", "minExclusive", "maxExclusive", "minInclusive", "maxInclusive", "minLength", "maxLength", "totalDigits", "fractionDigits", "whiteSpace")
|
||||
"enumeration" = @("xsi:type")
|
||||
}
|
||||
|
||||
function Sort-Attrs($node) {
|
||||
$order = $ATTR_ORDER[$node.Tag]
|
||||
if (-not $order) { return $node.Attrs }
|
||||
$sorted = New-Object System.Collections.ArrayList
|
||||
foreach ($n in $order) {
|
||||
foreach ($a in $node.Attrs) { if ($a.Name -eq $n) { [void]$sorted.Add($a) } }
|
||||
}
|
||||
foreach ($a in $node.Attrs) { if ($order -notcontains $a.Name) { [void]$sorted.Add($a) } }
|
||||
return $sorted
|
||||
}
|
||||
|
||||
function Esc([string]$s) {
|
||||
if ($null -eq $s) { return "" }
|
||||
return $s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """)
|
||||
}
|
||||
function EscText([string]$s) {
|
||||
if ($null -eq $s) { return "" }
|
||||
return $s.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
}
|
||||
|
||||
# --- Serializer with the dNpN prefix scheme ---
|
||||
|
||||
$out = New-Object System.Text.StringBuilder
|
||||
|
||||
function Serialize-Node($node, [int]$depth, $inherited) {
|
||||
$indent = "`t" * ($depth - 1)
|
||||
$attrs = Sort-Attrs $node
|
||||
|
||||
# Namespaces needing a NEW declaration here: те, что ещё не в области видимости.
|
||||
# Сериализатор платформы объявляет префикс на первом узле, где он нужен, а
|
||||
# потомки его переиспользуют — отсюда d2p1 у property внутри objectType.
|
||||
$localNs = New-Object System.Collections.ArrayList
|
||||
function Need-Prefix([string]$ns) {
|
||||
if (-not $ns -or $ns -eq $XS_NS -or $ns -eq $XSI_NS) { return }
|
||||
if ($inherited.ContainsKey($ns)) { return }
|
||||
if (-not $localNs.Contains($ns)) { [void]$localNs.Add($ns) }
|
||||
}
|
||||
foreach ($a in $attrs) {
|
||||
if ($a.PSObject.Properties.Name -contains 'List' -and $a.List) {
|
||||
# Нотация Кларка несёт ns в значении и префикса не требует; редкие
|
||||
# случаи, где платформа его всё же объявила, приходят зеркалом declareNs
|
||||
if (-not $a.Clark) { foreach ($p in $a.List) { Need-Prefix $p.Ns } }
|
||||
} elseif ($a.Ns) { Need-Prefix $a.Ns }
|
||||
}
|
||||
# Свойство с qualified платформа сериализует с явным префиксом пространства
|
||||
# имён XDTO — и в имени тега, и в имени самого атрибута
|
||||
$hasQualified = $false
|
||||
foreach ($a in $attrs) { if ($a.Name -eq "qualified") { $hasQualified = $true } }
|
||||
if ($hasQualified) { Need-Prefix $XDTO_NS }
|
||||
if ($node.DeclareNs) { Need-Prefix $node.DeclareNs }
|
||||
|
||||
$prefixOf = @{}
|
||||
foreach ($k in $inherited.Keys) { $prefixOf[$k] = $inherited[$k] }
|
||||
$nsDecls = ""
|
||||
for ($i = 0; $i -lt $localNs.Count; $i++) {
|
||||
# Осмысленный префикс из исходника (зеркало xdto:prefix) имеет приоритет
|
||||
$px = if ($i -eq 0 -and $node.Prefix) { $node.Prefix } else { "d${depth}p$($i + 1)" }
|
||||
$prefixOf[$localNs[$i]] = $px
|
||||
$nsDecls += " xmlns:$px=`"$(Esc $localNs[$i])`""
|
||||
}
|
||||
function QVal([string]$ns, [string]$local) {
|
||||
if (-not $ns) { return $local }
|
||||
if ($ns -eq $XS_NS) { return "xs:$local" }
|
||||
if ($ns -eq $XSI_NS) { return "xsi:$local" }
|
||||
return "$($prefixOf[$ns]):$local"
|
||||
}
|
||||
|
||||
$attrText = ""
|
||||
foreach ($a in $attrs) {
|
||||
if ($a.PSObject.Properties.Name -contains 'List' -and $a.List) {
|
||||
$vals = @()
|
||||
foreach ($p in $a.List) {
|
||||
if ($a.Clark) { $vals += $(if ($p.Ns) { "{$($p.Ns)}$($p.Local)" } else { $p.Local }) }
|
||||
else { $vals += (QVal $p.Ns $p.Local) }
|
||||
}
|
||||
$attrText += " $($a.Name)=`"$(Esc ($vals -join ' '))`""
|
||||
} elseif ($a.Ns -or $a.Local) {
|
||||
$attrText += " $($a.Name)=`"$(Esc (QVal $a.Ns $a.Local))`""
|
||||
} elseif ($a.Name -eq "qualified") {
|
||||
$attrText += " $($prefixOf[$XDTO_NS]):qualified=`"$(Esc $a.Value)`""
|
||||
} else {
|
||||
$attrText += " $($a.Name)=`"$(Esc $a.Value)`""
|
||||
}
|
||||
}
|
||||
|
||||
$tagName = $node.Tag
|
||||
if ($hasQualified) { $tagName = "$($prefixOf[$XDTO_NS]):$($node.Tag)" }
|
||||
|
||||
$hasChildren = $node.Children.Count -gt 0
|
||||
# Пустое значение пишется самозакрывающимся тегом: <enumeration/>, а не <enumeration></enumeration>
|
||||
$hasText = ($null -ne $node.Text -and $node.Text -ne "")
|
||||
|
||||
if (-not $hasChildren -and -not $hasText) {
|
||||
[void]$out.Append("$indent<$tagName$nsDecls$attrText/>`r`n")
|
||||
return
|
||||
}
|
||||
if ($hasText -and -not $hasChildren) {
|
||||
[void]$out.Append("$indent<$tagName$nsDecls$attrText>$(EscText $node.Text)</$tagName>`r`n")
|
||||
return
|
||||
}
|
||||
[void]$out.Append("$indent<$tagName$nsDecls$attrText>`r`n")
|
||||
foreach ($c in $node.Children) { Serialize-Node $c ($depth + 1) $prefixOf }
|
||||
[void]$out.Append("$indent</$tagName>`r`n")
|
||||
}
|
||||
|
||||
# --- XSD reading helpers ---
|
||||
|
||||
# Предупреждения о том, что XSD выражает, а модель XDTO — нет. Молча ронять
|
||||
# такие конструкции нельзя: пакет соберётся, а половина свойств исчезнет.
|
||||
$script:warnings = New-Object System.Collections.ArrayList
|
||||
function Warn([string]$msg) {
|
||||
if (-not $script:warnings.Contains($msg)) { [void]$script:warnings.Add($msg) }
|
||||
}
|
||||
|
||||
function XA([System.Xml.XmlElement]$el, [string]$name) {
|
||||
if ($el.HasAttribute($name)) { return $el.GetAttribute($name) }
|
||||
return $null
|
||||
}
|
||||
function MA([System.Xml.XmlElement]$el, [string]$name) {
|
||||
# xdto: mirror attribute — the literal value to write into Package.bin.
|
||||
# Префикс не фиксируем: ищем по namespace, а не по строке "xdto:".
|
||||
$a = $el.Attributes.GetNamedItem($name, $XDTO_NS)
|
||||
if ($null -eq $a) { return $null }
|
||||
return $a.Value
|
||||
}
|
||||
function XChildren([System.Xml.XmlElement]$el, [string]$local) {
|
||||
$res = New-Object System.Collections.ArrayList
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.NamespaceURI -eq $XS_NS -and $c.get_LocalName() -eq $local) { [void]$res.Add($c) }
|
||||
}
|
||||
# ArrayList, а не @(): PowerShell разворачивает массив из одного элемента при return
|
||||
return ,$res
|
||||
}
|
||||
function XFirst([System.Xml.XmlElement]$el, [string]$local) {
|
||||
$r = XChildren $el $local
|
||||
if ($r.Count -gt 0) { return $r[0] }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Split a QName from the XSD into (ns, local) using that element's prefix scope
|
||||
function Split-QName([System.Xml.XmlElement]$el, [string]$qname) {
|
||||
if ($null -eq $qname -or $qname -eq "") { return $null }
|
||||
$parts = $qname.Split(":")
|
||||
if ($parts.Count -eq 2) {
|
||||
$ns = $el.GetNamespaceOfPrefix($parts[0])
|
||||
$local = $parts[1]
|
||||
} else {
|
||||
# Прощающий ввод: голое имя типа трактуем как тип целевого пространства
|
||||
$ns = $el.GetNamespaceOfPrefix("")
|
||||
if (-not $ns) { $ns = $targetNs }
|
||||
$local = $parts[0]
|
||||
}
|
||||
return [pscustomobject]@{ Ns = $ns; Local = $local }
|
||||
}
|
||||
function Split-QNameList([System.Xml.XmlElement]$el, [string]$list) {
|
||||
if (-not $list) { return @() }
|
||||
$res = @()
|
||||
foreach ($q in ($list -split "\s+")) { if ($q) { $res += (Split-QName $el $q) } }
|
||||
return $res
|
||||
}
|
||||
|
||||
$FACETS = @("length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive", "whiteSpace")
|
||||
|
||||
# --- simpleType -> valueType / typeDef(ValueType) ---
|
||||
|
||||
function Fill-SimpleType($node, [System.Xml.XmlElement]$st) {
|
||||
$restriction = XFirst $st "restriction"
|
||||
$list = XFirst $st "list"
|
||||
$union = XFirst $st "union"
|
||||
|
||||
if ($list) {
|
||||
$it = Split-QName $list (XA $list "itemType")
|
||||
$mv = MA $list "variety"
|
||||
Add-Attr $node "variety" $(if ($null -ne $mv) { $mv } else { "List" })
|
||||
if ($it) { Add-QAttr $node "itemType" $it.Ns $it.Local }
|
||||
return
|
||||
}
|
||||
if ($union) {
|
||||
$mv = MA $union "variety"
|
||||
Set-AttrValue $node "variety" $(if ($null -ne $mv) { $mv } else { "Union" })
|
||||
$members = @(Split-QNameList $union (XA $union "memberTypes"))
|
||||
# По умолчанию нотация Кларка — так записано 125 из 135 memberTypes корпуса
|
||||
$useClark = ((MA $union "memberTypesForm") -ne "prefixed")
|
||||
if ($members.Count -gt 0) { Add-QListAttr $node "memberTypes" $members $useClark }
|
||||
$node.DeclareNs = MA $union "declareNs"
|
||||
foreach ($anon in (XChildren $union "simpleType")) {
|
||||
# typeDef в контексте простого типа xsi:type не несёт (40 узлов корпуса)
|
||||
$td = New-Node "typeDef"
|
||||
Fill-SimpleType $td $anon
|
||||
Add-Child $node $td
|
||||
}
|
||||
return
|
||||
}
|
||||
if ($restriction) {
|
||||
$b = Split-QName $restriction (XA $restriction "base")
|
||||
if ($b) { Add-QAttr $node "base" $b.Ns $b.Local }
|
||||
$mv = MA $restriction "variety"
|
||||
if ($null -ne $mv) { Add-Attr $node "variety" $mv }
|
||||
# Анонимный базовый тип внутри xs:restriction — typeDef без xsi:type
|
||||
$anonBase = XFirst $restriction "simpleType"
|
||||
if ($anonBase) {
|
||||
$td = New-Node "typeDef"
|
||||
Fill-SimpleType $td $anonBase
|
||||
Add-Child $node $td
|
||||
}
|
||||
foreach ($f in $FACETS) {
|
||||
foreach ($fe in (XChildren $restriction $f)) { Add-Attr $node $f (XA $fe "value") }
|
||||
}
|
||||
foreach ($pe in (XChildren $restriction "pattern")) {
|
||||
$pn = New-Node "pattern"; $pn.Text = (XA $pe "value"); Add-Child $node $pn
|
||||
}
|
||||
foreach ($en in (XChildren $restriction "enumeration")) {
|
||||
$enode = New-Node "enumeration"
|
||||
$mt = MA $en "type"
|
||||
if ($null -ne $mt) {
|
||||
$q = Split-QName $en $mt
|
||||
Add-QAttr $enode "xsi:type" $q.Ns $q.Local
|
||||
}
|
||||
$enode.Text = (XA $en "value")
|
||||
Add-Child $node $enode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PropKey($p) {
|
||||
foreach ($a in $p.Attrs) { if ($a.Name -eq "name") { return $a.Value } }
|
||||
foreach ($a in $p.Attrs) { if ($a.Name -eq "ref") { return "@" + $a.Local } }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Восстановить исходный порядок свойств по зеркалу xdto:order
|
||||
function Reorder-Properties($node, $names) {
|
||||
$props = @($node.Children | Where-Object { $_.Tag -eq "property" })
|
||||
if ($props.Count -lt 2) { return }
|
||||
$byKey = @{}
|
||||
foreach ($p in $props) {
|
||||
$k = Get-PropKey $p
|
||||
if ($null -ne $k -and -not $byKey.ContainsKey($k)) { $byKey[$k] = $p }
|
||||
}
|
||||
$ordered = New-Object System.Collections.ArrayList
|
||||
foreach ($n in $names) {
|
||||
if ($byKey.ContainsKey($n)) { [void]$ordered.Add($byKey[$n]); $byKey.Remove($n) }
|
||||
}
|
||||
foreach ($p in $props) { if ($ordered -notcontains $p) { [void]$ordered.Add($p) } }
|
||||
$others = @($node.Children | Where-Object { $_.Tag -ne "property" })
|
||||
$node.Children.Clear()
|
||||
foreach ($p in $ordered) { [void]$node.Children.Add($p) }
|
||||
foreach ($o in $others) { [void]$node.Children.Add($o) }
|
||||
}
|
||||
|
||||
function Set-AttrValue($node, [string]$name, [string]$value) {
|
||||
foreach ($a in $node.Attrs) { if ($a.Name -eq $name) { $a.Value = $value; return } }
|
||||
Add-Attr $node $name $value
|
||||
}
|
||||
|
||||
# --- element / attribute -> property ---
|
||||
|
||||
function Build-Property([System.Xml.XmlElement]$el, [bool]$isAttribute) {
|
||||
$p = New-Node "property"
|
||||
|
||||
$xsdName = XA $el "name"
|
||||
$mirrorName = MA $el "name"
|
||||
if ($null -ne $mirrorName) {
|
||||
Add-Attr $p "name" $mirrorName
|
||||
$localName = $xsdName
|
||||
} else {
|
||||
Add-Attr $p "name" $xsdName
|
||||
$localName = $null
|
||||
}
|
||||
|
||||
$refQ = Split-QName $el (XA $el "ref")
|
||||
if ($refQ) { Add-QAttr $p "ref" $refQ.Ns $refQ.Local }
|
||||
|
||||
$typeQ = Split-QName $el (XA $el "type")
|
||||
if ($typeQ) { Add-QAttr $p "type" $typeQ.Ns $typeQ.Local }
|
||||
|
||||
if ($isAttribute) {
|
||||
Add-Attr $p "lowerBound" (MA $el "lowerBound")
|
||||
Add-Attr $p "upperBound" (MA $el "upperBound")
|
||||
Add-Attr $p "nillable" (MA $el "nillable")
|
||||
} else {
|
||||
Add-Attr $p "lowerBound" (XA $el "minOccurs")
|
||||
$maxOcc = XA $el "maxOccurs"
|
||||
if ($null -ne $maxOcc) { Add-Attr $p "upperBound" $(if ($maxOcc -eq "unbounded") { "-1" } else { $maxOcc }) }
|
||||
Add-Attr $p "nillable" (XA $el "nillable")
|
||||
}
|
||||
|
||||
# XSD-шный fixed="V" несёт значение, в модели это fixed="true" + default="V".
|
||||
# Прощающий ввод: модельная форма через зеркало xdto:fixed тоже принимается.
|
||||
$mFixed = MA $el "fixed"
|
||||
if ($null -ne $mFixed) {
|
||||
Add-Attr $p "fixed" $mFixed
|
||||
Add-Attr $p "default" (XA $el "default")
|
||||
if ($mFixed -ceq "true" -and $null -eq (XA $el "default")) {
|
||||
Warn "Свойство `"$(XA $el 'name')`": xdto:fixed=`"true`" без default — платформа отвергнет пакет («Отсутствует фиксированное значение»). Значение задаётся атрибутом default, либо пишите XSD-форму fixed=`"значение`""
|
||||
}
|
||||
} elseif ($null -ne (XA $el "fixed")) {
|
||||
Add-Attr $p "fixed" "true"
|
||||
Add-Attr $p "default" (XA $el "fixed")
|
||||
} else {
|
||||
Add-Attr $p "default" (XA $el "default")
|
||||
}
|
||||
|
||||
if ($isAttribute) {
|
||||
Add-Attr $p "form" "Attribute"
|
||||
} else {
|
||||
$mf = MA $el "form"
|
||||
if ($null -ne $mf) { Add-Attr $p "form" $mf }
|
||||
}
|
||||
Add-Attr $p "localName" $localName
|
||||
Add-Attr $p "qualified" (MA $el "qualified")
|
||||
$p.Prefix = MA $el "prefix"
|
||||
|
||||
# Anonymous inline type
|
||||
$anonSimple = XFirst $el "simpleType"
|
||||
$anonComplex = XFirst $el "complexType"
|
||||
if ($anonSimple) {
|
||||
$td = New-Node "typeDef"
|
||||
Add-Attr $td "xsi:type" "ValueType"
|
||||
Fill-SimpleType $td $anonSimple
|
||||
Add-Child $p $td
|
||||
} elseif ($anonComplex) {
|
||||
$td = New-Node "typeDef"
|
||||
Add-Attr $td "xsi:type" "ObjectType"
|
||||
Fill-ComplexType $td $anonComplex
|
||||
Add-Child $p $td
|
||||
}
|
||||
return $p
|
||||
}
|
||||
|
||||
# --- complexType -> objectType / typeDef(ObjectType) ---
|
||||
|
||||
# Разрешение xs:group / xs:attributeGroup по ссылке
|
||||
$script:GROUPS = @{}
|
||||
$script:ATTR_GROUPS = @{}
|
||||
function Resolve-Group([System.Xml.XmlElement]$el, [string]$kind) {
|
||||
$ref = XA $el "ref"
|
||||
if (-not $ref) { return $null }
|
||||
$q = Split-QName $el $ref
|
||||
if (-not $q) { return $null }
|
||||
$map = if ($kind -eq "group") { $script:GROUPS } else { $script:ATTR_GROUPS }
|
||||
if ($map.ContainsKey($q.Local)) { return $map[$q.Local] }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Модель XDTO знает только плоский список свойств: вложенные частицы уплощаются.
|
||||
# Каждое уплощение — предупреждение, потому что меняется смысл схемы.
|
||||
function Collect-Particle([System.Xml.XmlElement]$particle, $elemList, [ref]$isOpen, [string]$typeName, [int]$depth, [bool]$optionalize = $false) {
|
||||
if ($depth -gt 20) { return }
|
||||
foreach ($c in $particle.ChildNodes) {
|
||||
if ($c.NodeType -ne [System.Xml.XmlNodeType]::Element -or $c.NamespaceURI -ne $XS_NS) { continue }
|
||||
switch ($c.get_LocalName()) {
|
||||
"element" {
|
||||
$prop = Build-Property $c $false
|
||||
# Ветка уплощённого xs:choice обязана стать необязательной: иначе
|
||||
# «одно из двух» превращается в «оба сразу», и тип нельзя заполнить
|
||||
if ($optionalize) { Set-AttrValue $prop "lowerBound" "0" }
|
||||
[void]$elemList.Add($prop)
|
||||
}
|
||||
"any" { $isOpen.Value = $true }
|
||||
"sequence" {
|
||||
Warn "$typeName : вложенная xs:sequence уплощена — модель XDTO хранит плоский список свойств"
|
||||
Collect-Particle $c $elemList $isOpen $typeName ($depth + 1) $optionalize
|
||||
}
|
||||
"choice" {
|
||||
$branches = @()
|
||||
foreach ($b in $c.ChildNodes) {
|
||||
if ($b.NodeType -eq [System.Xml.XmlNodeType]::Element -and $b.NamespaceURI -eq $XS_NS -and $b.HasAttribute("name")) {
|
||||
$branches += $b.GetAttribute("name")
|
||||
}
|
||||
}
|
||||
$list = if ($branches.Count -gt 0) { " (" + ($branches -join ", ") + ")" } else { "" }
|
||||
Warn ("$typeName : вложенная xs:choice уплощена — ветки$list сделаны необязательными. " +
|
||||
"Выбор одного из вариантов не сохранён: модель не запретит заполнить сразу несколько или ни одного")
|
||||
Collect-Particle $c $elemList $isOpen $typeName ($depth + 1) $true
|
||||
}
|
||||
"all" {
|
||||
Warn "$typeName : xs:all трактуется как последовательность"
|
||||
Collect-Particle $c $elemList $isOpen $typeName ($depth + 1) $optionalize
|
||||
}
|
||||
"group" {
|
||||
$g = Resolve-Group $c "group"
|
||||
if ($g) {
|
||||
foreach ($gc in $g.ChildNodes) {
|
||||
if ($gc.NodeType -eq [System.Xml.XmlNodeType]::Element -and $gc.NamespaceURI -eq $XS_NS -and
|
||||
@("sequence", "choice", "all") -contains $gc.get_LocalName()) {
|
||||
Collect-Particle $gc $elemList $isOpen $typeName ($depth + 1) $optionalize
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Warn "$typeName : не найдена группа $(XA $c 'ref') — её свойства в пакет не попали"
|
||||
}
|
||||
}
|
||||
}
|
||||
# Кратность на самой частице модель выразить не может
|
||||
if (@("sequence", "choice", "all", "group") -contains $c.get_LocalName()) {
|
||||
if ((XA $c "maxOccurs") -or (XA $c "minOccurs")) {
|
||||
Warn "$typeName : кратность на вложенной частице (<xs:$($c.get_LocalName()) minOccurs/maxOccurs>) не выражается в модели XDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# open / ordered / sequenced / abstract / mixed: выводим где выводимо,
|
||||
# остальное приходит зеркалом xdto:
|
||||
function Set-TypeFlags($node, [System.Xml.XmlElement]$ct, $isOpen, $choice) {
|
||||
$mOpen = MA $ct "open"
|
||||
if ($null -ne $mOpen) { Add-Attr $node "open" $mOpen }
|
||||
elseif ($isOpen) { Add-Attr $node "open" "true" }
|
||||
|
||||
$mOrdered = MA $ct "ordered"
|
||||
if ($null -ne $mOrdered) { Add-Attr $node "ordered" $mOrdered }
|
||||
elseif ($choice) { Add-Attr $node "ordered" "false" }
|
||||
|
||||
$mSeq = MA $ct "sequenced"
|
||||
if ($null -ne $mSeq) { Add-Attr $node "sequenced" $mSeq }
|
||||
|
||||
$mAbstract = MA $ct "abstract"
|
||||
if ($null -ne $mAbstract) { Add-Attr $node "abstract" $mAbstract }
|
||||
elseif ((XA $ct "abstract") -eq "true") { Add-Attr $node "abstract" "true" }
|
||||
|
||||
$mMixed = MA $ct "mixed"
|
||||
if ($null -ne $mMixed) { Add-Attr $node "mixed" $mMixed }
|
||||
elseif ((XA $ct "mixed") -eq "true") { Add-Attr $node "mixed" "true" }
|
||||
}
|
||||
|
||||
function Fill-ComplexType($node, [System.Xml.XmlElement]$ct) {
|
||||
# xs:complexContent/xs:extension carries the base type
|
||||
$content = XFirst $ct "complexContent"
|
||||
$body = $ct
|
||||
if ($content) {
|
||||
$ext = XFirst $content "extension"
|
||||
if ($ext) {
|
||||
$b = Split-QName $ext (XA $ext "base")
|
||||
if ($b) { Add-QAttr $node "base" $b.Ns $b.Local }
|
||||
$body = $ext
|
||||
}
|
||||
}
|
||||
|
||||
# xs:simpleContent -> a "Text" property holding the element's own value
|
||||
$simple = XFirst $ct "simpleContent"
|
||||
if ($simple) {
|
||||
$ext = XFirst $simple "extension"
|
||||
if ($ext) {
|
||||
foreach ($a in (XChildren $ext "attribute")) { Add-Child $node (Build-Property $a $true) }
|
||||
$tp = New-Node "property"
|
||||
$tName = MA $ext "textName"
|
||||
Add-Attr $tp "name" $(if ($null -ne $tName) { $tName } else { "__content" })
|
||||
$b = Split-QName $ext (XA $ext "base")
|
||||
if ($b) { Add-QAttr $tp "type" $b.Ns $b.Local }
|
||||
Add-Attr $tp "lowerBound" (MA $ext "textlowerBound")
|
||||
Add-Attr $tp "upperBound" (MA $ext "textupperBound")
|
||||
Add-Attr $tp "nillable" (MA $ext "textnillable")
|
||||
Add-Attr $tp "form" "Text"
|
||||
Add-Child $node $tp
|
||||
# xs:simpleContent не отменяет флаги самого xs:complexType
|
||||
Set-TypeFlags $node $ct $false $null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# Particle: xs:sequence (ordered) or xs:choice (ordered="false")
|
||||
$seq = XFirst $body "sequence"
|
||||
$cho = XFirst $body "choice"
|
||||
$all = XFirst $body "all"
|
||||
$grp = XFirst $body "group"
|
||||
$particle = if ($seq) { $seq } elseif ($cho) { $cho } elseif ($all) { $all } else { $grp }
|
||||
$isOpen = $false
|
||||
|
||||
# Порядок в XDTO: сначала form="Attribute", потом остальные (верно для 96.5%
|
||||
# типов корпуса). Отклонения приходят зеркалом xdto:order.
|
||||
$elemProps = New-Object System.Collections.ArrayList
|
||||
$typeName = if ($ct.HasAttribute("name")) { $ct.GetAttribute("name") } else { "(анонимный тип)" }
|
||||
if ($particle) {
|
||||
$openRef = [ref]$isOpen
|
||||
if ($all) {
|
||||
Warn "$typeName : xs:all трактуется как последовательность"
|
||||
}
|
||||
if ($grp -and -not $seq -and -not $cho -and -not $all) {
|
||||
# Корневая частица задана ссылкой на группу — раскрываем её содержимое
|
||||
$g = Resolve-Group $grp "group"
|
||||
if ($g) {
|
||||
foreach ($gc in $g.ChildNodes) {
|
||||
if ($gc.NodeType -eq [System.Xml.XmlNodeType]::Element -and $gc.NamespaceURI -eq $XS_NS -and
|
||||
@("sequence", "choice", "all") -contains $gc.get_LocalName()) {
|
||||
Collect-Particle $gc $elemProps $openRef $typeName 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Warn "$typeName : не найдена группа $(XA $grp 'ref') — её свойства в пакет не попали"
|
||||
}
|
||||
} else {
|
||||
Collect-Particle $particle $elemProps $openRef $typeName 0
|
||||
}
|
||||
$isOpen = $openRef.Value
|
||||
}
|
||||
foreach ($a in (XChildren $body "attribute")) { Add-Child $node (Build-Property $a $true) }
|
||||
# xs:attributeGroup раскрываем по ссылке
|
||||
foreach ($ag in (XChildren $body "attributeGroup")) {
|
||||
$g = Resolve-Group $ag "attributeGroup"
|
||||
if ($g) {
|
||||
foreach ($a in (XChildren $g "attribute")) { Add-Child $node (Build-Property $a $true) }
|
||||
} else {
|
||||
Warn "Не найдена группа атрибутов $(XA $ag 'ref') — её атрибуты в пакет не попали"
|
||||
}
|
||||
}
|
||||
foreach ($e in $elemProps) { Add-Child $node $e }
|
||||
if ((XChildren $body "anyAttribute").Count -gt 0) { $isOpen = $true }
|
||||
|
||||
$mOrder = MA $ct "order"
|
||||
if ($null -ne $mOrder) { Reorder-Properties $node ($mOrder -split "\|") }
|
||||
|
||||
Set-TypeFlags $node $ct $isOpen $cho
|
||||
}
|
||||
|
||||
# --- Build the package tree ---
|
||||
|
||||
$pkgNode = New-Node "package"
|
||||
Add-Attr $pkgNode "targetNamespace" $targetNs
|
||||
|
||||
$efqMirror = MA $schema "elementFormQualified"
|
||||
$afqMirror = MA $schema "attributeFormQualified"
|
||||
$efd = XA $schema "elementFormDefault"
|
||||
$afd = XA $schema "attributeFormDefault"
|
||||
if ($null -ne $efqMirror) { Add-Attr $pkgNode "elementFormQualified" $efqMirror }
|
||||
elseif ($null -ne $efd) { Add-Attr $pkgNode "elementFormQualified" $(if ($efd -eq "qualified") { "true" } else { "false" }) }
|
||||
if ($null -ne $afqMirror) { Add-Attr $pkgNode "attributeFormQualified" $afqMirror }
|
||||
elseif ($null -ne $afd) { Add-Attr $pkgNode "attributeFormQualified" $(if ($afd -eq "qualified") { "true" } else { "false" }) }
|
||||
|
||||
# Metadata properties from xs:annotation/xs:appinfo
|
||||
$metaName = $null; $metaComment = $null; $metaSynonym = @()
|
||||
$ann = XFirst $schema "annotation"
|
||||
if ($ann) {
|
||||
$appinfo = XFirst $ann "appinfo"
|
||||
if ($appinfo) {
|
||||
foreach ($pk in $appinfo.ChildNodes) {
|
||||
if ($pk.NodeType -ne [System.Xml.XmlNodeType]::Element -or $pk.NamespaceURI -ne $XDTO_NS) { continue }
|
||||
foreach ($f in $pk.ChildNodes) {
|
||||
if ($f.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
switch ($f.get_LocalName()) {
|
||||
"name" { $metaName = $f.InnerText }
|
||||
"comment" { $metaComment = $f.InnerText }
|
||||
"synonym" { $metaSynonym += @{ Lang = $f.GetAttribute("lang"); Content = $f.InnerText } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Реестр глобальных групп — нужен до обхода, чтобы раскрывать ссылки
|
||||
foreach ($node in $schema.ChildNodes) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element -or $node.NamespaceURI -ne $XS_NS) { continue }
|
||||
$nm = XA $node "name"
|
||||
if ($node.get_LocalName() -eq "group" -and $nm) { $script:GROUPS[$nm] = $node }
|
||||
if ($node.get_LocalName() -eq "attributeGroup" -and $nm) { $script:ATTR_GROUPS[$nm] = $node }
|
||||
}
|
||||
|
||||
# Конструкции XSD, которым в модели XDTO нет соответствия
|
||||
foreach ($sg in $schema.SelectNodes("//*[local-name()='element'][@substitutionGroup]")) {
|
||||
Warn "Подстановочные группы (substitutionGroup) не поддерживаются моделью XDTO — объявление $($sg.GetAttribute('name')) сохранено как обычное"
|
||||
}
|
||||
foreach ($idc in @("key", "keyref", "unique")) {
|
||||
if ($schema.SelectNodes("//*[local-name()='$idc']").Count -gt 0) {
|
||||
Warn "Ограничения целостности (xs:$idc) в модели XDTO не хранятся — отброшены"
|
||||
}
|
||||
}
|
||||
if ($schema.SelectNodes("//*[local-name()='redefine']").Count -gt 0) {
|
||||
Warn "xs:redefine не поддерживается — переопределения проигнорированы"
|
||||
}
|
||||
if ($schema.SelectNodes("//*[local-name()='include']").Count -gt 0) {
|
||||
Warn "xs:include проигнорирован: модель XDTO разрешает зависимости только по namespace. Соберите включаемую схему отдельным пакетом и добавьте <xs:import>"
|
||||
}
|
||||
|
||||
foreach ($node in $schema.ChildNodes) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element -or $node.NamespaceURI -ne $XS_NS) { continue }
|
||||
switch ($node.get_LocalName()) {
|
||||
"annotation" { }
|
||||
"group" { }
|
||||
"attributeGroup" { }
|
||||
"notation" { }
|
||||
"import" {
|
||||
$n = New-Node "import"
|
||||
Add-Attr $n "namespace" (XA $node "namespace")
|
||||
Add-Child $pkgNode $n
|
||||
}
|
||||
"include" { }
|
||||
"element" { Add-Child $pkgNode (Build-Property $node $false) }
|
||||
"attribute" { Add-Child $pkgNode (Build-Property $node $true) }
|
||||
"simpleType" {
|
||||
$n = New-Node "valueType"
|
||||
Add-Attr $n "name" (XA $node "name")
|
||||
Fill-SimpleType $n $node
|
||||
Add-Child $pkgNode $n
|
||||
}
|
||||
"complexType" {
|
||||
$n = New-Node "objectType"
|
||||
Add-Attr $n "name" (XA $node "name")
|
||||
Fill-ComplexType $n $node
|
||||
Add-Child $pkgNode $n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Serialize Package.bin ---
|
||||
|
||||
# Модель XDTO требует строгой последовательности элементов верхнего уровня:
|
||||
# import → property → valueType → objectType. Порядок объявлений в XSD произвольный,
|
||||
# поэтому пересортировываем — иначе платформа отвергает пакет с «Ошибка преобразования
|
||||
# данных XDTO». Все 760 пакетов корпуса этому порядку удовлетворяют, так что
|
||||
# round-trip не затрагивается.
|
||||
$TOP_ORDER = @("import", "property", "valueType", "objectType")
|
||||
$sortedChildren = New-Object System.Collections.ArrayList
|
||||
foreach ($t in $TOP_ORDER) {
|
||||
foreach ($c in $pkgNode.Children) { if ($c.Tag -eq $t) { [void]$sortedChildren.Add($c) } }
|
||||
}
|
||||
foreach ($c in $pkgNode.Children) { if ($TOP_ORDER -notcontains $c.Tag) { [void]$sortedChildren.Add($c) } }
|
||||
$pkgNode.Children.Clear()
|
||||
foreach ($c in $sortedChildren) { [void]$pkgNode.Children.Add($c) }
|
||||
|
||||
$attrsRoot = Sort-Attrs $pkgNode
|
||||
$rootAttrText = ""
|
||||
foreach ($a in $attrsRoot) { $rootAttrText += " $($a.Name)=`"$(Esc $a.Value)`"" }
|
||||
[void]$out.Append("<package xmlns=`"$XDTO_NS`" xmlns:xs=`"$XS_NS`" xmlns:xsi=`"$XSI_NS`"$rootAttrText>`r`n")
|
||||
foreach ($c in $pkgNode.Children) { Serialize-Node $c 2 @{} }
|
||||
[void]$out.Append("</package>")
|
||||
|
||||
$binText = $out.ToString()
|
||||
|
||||
# --- Resolve the package name ---
|
||||
|
||||
if (-not $Name) {
|
||||
if ($metaName) { $Name = $metaName } else { $Name = $defaultName }
|
||||
}
|
||||
# Санация под идентификатор 1С
|
||||
$Name = ($Name -replace '[^\wЀ-ӿ]', '_')
|
||||
if ($Name -match '^\d') { $Name = "_$Name" }
|
||||
|
||||
Assert-EditAllowed $OutputDir
|
||||
|
||||
$script:formatVersion = Detect-FormatVersion $OutputDir
|
||||
|
||||
$pkgRoot = Join-Path $OutputDir "XDTOPackages"
|
||||
$pkgDir = Join-Path $pkgRoot $Name
|
||||
$extDir = Join-Path $pkgDir "Ext"
|
||||
$mdFile = Join-Path $pkgRoot "$Name.xml"
|
||||
$binFile = Join-Path $extDir "Package.bin"
|
||||
|
||||
if ((Test-Path $binFile) -and -not $Force) {
|
||||
throw "Пакет уже существует: $binFile. Используйте -Force для перезаписи."
|
||||
}
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($binFile, $binText, $encBom)
|
||||
|
||||
# --- Metadata object file ---
|
||||
|
||||
if (-not $Synonym -and $metaSynonym.Count -gt 0) {
|
||||
$synItems = $metaSynonym
|
||||
} elseif ($Synonym -is [System.Collections.IDictionary]) {
|
||||
$synItems = @()
|
||||
foreach ($k in $Synonym.Keys) { $synItems += @{ Lang = [string]$k; Content = [string]$Synonym[$k] } }
|
||||
} elseif ($Synonym) {
|
||||
$synItems = @(@{ Lang = "ru"; Content = [string]$Synonym })
|
||||
} else {
|
||||
$synItems = @(@{ Lang = "ru"; Content = $Name })
|
||||
}
|
||||
if (-not $Comment -and $metaComment) { $Comment = $metaComment }
|
||||
|
||||
$uuid = [guid]::NewGuid().ToString()
|
||||
$md = New-Object System.Text.StringBuilder
|
||||
function M([string]$s) { [void]$md.Append($s); [void]$md.Append("`r`n") }
|
||||
M '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
M ("<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`">")
|
||||
M "`t<XDTOPackage uuid=`"$uuid`">"
|
||||
M "`t`t<Properties>"
|
||||
M "`t`t`t<Name>$(EscText $Name)</Name>"
|
||||
M "`t`t`t<Synonym>"
|
||||
foreach ($s in $synItems) {
|
||||
M "`t`t`t`t<v8:item>"
|
||||
M "`t`t`t`t`t<v8:lang>$(EscText $s.Lang)</v8:lang>"
|
||||
M "`t`t`t`t`t<v8:content>$(EscText $s.Content)</v8:content>"
|
||||
M "`t`t`t`t</v8:item>"
|
||||
}
|
||||
M "`t`t`t</Synonym>"
|
||||
if ($Comment) { M "`t`t`t<Comment>$(EscText $Comment)</Comment>" } else { M "`t`t`t<Comment/>" }
|
||||
M "`t`t`t<Namespace>$(EscText $targetNs)</Namespace>"
|
||||
M "`t`t</Properties>"
|
||||
M "`t</XDTOPackage>"
|
||||
[void]$md.Append("</MetaDataObject>")
|
||||
|
||||
[System.IO.File]::WriteAllText($mdFile, $md.ToString(), $encBom)
|
||||
|
||||
# --- Register in Configuration.xml ---
|
||||
|
||||
# Ранняя диагностика: отказ платформы при db-update дешевле поймать на сборке
|
||||
$xdtoRootDir = Join-Path $OutputDir "XDTOPackages"
|
||||
$declaredImports = @()
|
||||
foreach ($c in $pkgNode.Children) { if ($c.Tag -eq "import") { foreach ($a in $c.Attrs) { if ($a.Name -eq "namespace") { $declaredImports += $a.Value } } } }
|
||||
if ($declaredImports.Count -gt 0 -and (Test-Path $xdtoRootDir)) {
|
||||
$knownNs = @{}
|
||||
foreach ($other in (Get-ChildItem $xdtoRootDir -Directory -ErrorAction SilentlyContinue)) {
|
||||
$ob = Join-Path (Join-Path $other.FullName "Ext") "Package.bin"
|
||||
if (-not (Test-Path $ob)) { continue }
|
||||
try {
|
||||
$od = New-Object System.Xml.XmlDocument
|
||||
$od.Load($ob)
|
||||
$knownNs[$od.DocumentElement.GetAttribute("targetNamespace")] = $true
|
||||
} catch {}
|
||||
}
|
||||
foreach ($imp in $declaredImports) {
|
||||
if (-not $knownNs.ContainsKey($imp) -and $PLATFORM_NS -notcontains $imp) {
|
||||
Warn "Импорт `"$imp`" не разрешается: пакета с таким namespace в конфигурации нет. Платформа отвергнет пакет при обновлении — соберите зависимость первой"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$configXmlPath = Join-Path $OutputDir "Configuration.xml"
|
||||
$regResult = "no-config"
|
||||
if (Test-Path $configXmlPath) {
|
||||
$configDoc = New-Object System.Xml.XmlDocument
|
||||
$configDoc.PreserveWhitespace = $true
|
||||
$configDoc.Load($configXmlPath)
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($configDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", $MD_NS)
|
||||
$childObjects = $configDoc.SelectSingleNode("//md:Configuration/md:ChildObjects", $nsMgr)
|
||||
if ($childObjects) {
|
||||
$existing = $childObjects.SelectNodes("md:XDTOPackage", $nsMgr)
|
||||
$already = $false
|
||||
foreach ($e in $existing) { if ($e.InnerText -eq $Name) { $already = $true; break } }
|
||||
if ($already) {
|
||||
$regResult = "already"
|
||||
} else {
|
||||
$newElem = $configDoc.CreateElement("XDTOPackage", $MD_NS)
|
||||
$newElem.InnerText = $Name
|
||||
if ($existing.Count -gt 0) {
|
||||
$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 {
|
||||
$lastChild = $childObjects.LastChild
|
||||
if ($lastChild -and $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
|
||||
}
|
||||
}
|
||||
$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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Report ---
|
||||
|
||||
$typeCount = 0
|
||||
foreach ($c in $pkgNode.Children) { if ($c.Tag -eq "objectType" -or $c.Tag -eq "valueType") { $typeCount++ } }
|
||||
|
||||
Write-Host "✓ Пакет XDTO собран: $Name"
|
||||
Write-Host " Namespace: $targetNs"
|
||||
Write-Host " Типов: $typeCount"
|
||||
Write-Host " Файлы: XDTOPackages/$Name.xml, XDTOPackages/$Name/Ext/Package.bin"
|
||||
if ($script:warnings.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Host "Предупреждения ($($script:warnings.Count)) — конструкции XSD без точного соответствия в модели XDTO:"
|
||||
foreach ($w in $script:warnings) { Write-Host " ! $w" }
|
||||
Write-Host ""
|
||||
}
|
||||
switch ($regResult) {
|
||||
"added" { Write-Host " Configuration.xml: <XDTOPackage>$Name</XDTOPackage> добавлен в ChildObjects" }
|
||||
"already" { Write-Host " Configuration.xml: <XDTOPackage>$Name</XDTOPackage> уже зарегистрирован" }
|
||||
"no-config" { Write-Host " Configuration.xml не найден — регистрация пропущена" }
|
||||
}
|
||||
@@ -1,986 +0,0 @@
|
||||
# xdto-compile v1.2 — Build a 1C XDTO package from an XML Schema (XSD) (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Эти пространства имён предоставляет сама платформа — пакетов в конфигурации
|
||||
# для них нет и быть не должно (выведено по корпусу)
|
||||
PLATFORM_NS = {
|
||||
"http://v8.1c.ru/8.1/data/core",
|
||||
"http://v8.1c.ru/8.1/data/enterprise",
|
||||
"http://v8.1c.ru/8.1/data/enterprise/current-config",
|
||||
"http://v8.1c.ru/8.1/data-composition-system/settings",
|
||||
"http://v8.1c.ru/8.3/data/ext",
|
||||
"http://www.w3.org/2001/XMLSchema",
|
||||
}
|
||||
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-XsdPath", "-Path", default="")
|
||||
parser.add_argument("-Xsd", default="")
|
||||
parser.add_argument("-OutputDir", required=True)
|
||||
parser.add_argument("-Name", default="")
|
||||
parser.add_argument("-Synonym", default="")
|
||||
parser.add_argument("-Comment", default="")
|
||||
parser.add_argument("-Force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def _parse_xml(source, from_string=False):
|
||||
"""Разбор с узким отступлением для не-URI пространств имён.
|
||||
|
||||
Платформа допускает в targetNamespace произвольную строку (в выгрузке БП есть
|
||||
пакет с кириллическим «ДопФайлУниверсальный»), .NET такое принимает, а libxml2
|
||||
отвергает. Откатываемся на восстанавливающий разбор ТОЛЬКО на этой ошибке,
|
||||
иначе по-настоящему битый XML перестал бы отличаться от корректного.
|
||||
"""
|
||||
try:
|
||||
return (etree.fromstring(source) if from_string else etree.parse(source))
|
||||
except etree.XMLSyntaxError as e:
|
||||
if "is not a valid URI" not in str(e):
|
||||
raise
|
||||
p = etree.XMLParser(recover=True)
|
||||
return (etree.fromstring(source, p) if from_string else etree.parse(source, p))
|
||||
|
||||
# ── support guard (Ext/ParentConfigurations.bin) ─────────────
|
||||
# См. docs/1c-support-state-spec.md. Блокирует правку объектов поставщика
|
||||
# «на замке». Триггер — наличие bin; реакция из .v8-project.json
|
||||
# editingAllowedCheck (deny|warn|off, по умолчанию deny).
|
||||
|
||||
|
||||
def find_v8_project(start_dir):
|
||||
d = os.path.abspath(start_dir)
|
||||
for _ in range(20):
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.exists(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = find_v8_project(cfg_dir)
|
||||
if pj:
|
||||
with open(pj, encoding="utf-8-sig") as f:
|
||||
cfg = json.load(f)
|
||||
return str(cfg.get("editingAllowedCheck") or "deny")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return "deny"
|
||||
|
||||
|
||||
def is_external_object_root(xml_path):
|
||||
try:
|
||||
root = _parse_xml(xml_path).getroot()
|
||||
for el in root:
|
||||
if isinstance(el.tag, str):
|
||||
return etree.QName(el).localname in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
"""Версия формата выгрузки — из Configuration.xml проекта (климб вверх от каталога исходников).
|
||||
|
||||
Её задаёт платформа выгрузки: 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.20 пакет расходился с выгрузкой.
|
||||
Тело — точная копия из остальных навыков (разрешение пути делает вызывающая сторона).
|
||||
"""
|
||||
while d:
|
||||
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 assert_edit_allowed(target_path):
|
||||
d = os.path.abspath(target_path)
|
||||
for _ in range(20):
|
||||
# Автономный объект (внешняя обработка/отчёт) — граница климба
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
if f.endswith(".xml") and is_external_object_root(os.path.join(d, f)):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
cfg_xml = os.path.join(d, "Configuration.xml")
|
||||
support_bin = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cfg_xml):
|
||||
if os.path.exists(support_bin):
|
||||
mode = get_edit_mode(d)
|
||||
if mode == "off":
|
||||
return
|
||||
msg = ("Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). "
|
||||
"Правка может быть запрещена.")
|
||||
if mode == "warn":
|
||||
print(f"WARNING: {msg}", file=sys.stderr)
|
||||
return
|
||||
print(f"{msg} Снимите с поддержки (/support-edit) или задайте "
|
||||
"editingAllowedCheck в .v8-project.json.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
|
||||
|
||||
# ── load the schema ──────────────────────────────────────────
|
||||
|
||||
if args.Xsd:
|
||||
xsd_bytes = args.Xsd.encode("utf-8")
|
||||
default_name = "Package"
|
||||
elif args.XsdPath:
|
||||
if not os.path.isfile(args.XsdPath):
|
||||
print(f"Файл XSD не найден: {args.XsdPath}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.XsdPath, "rb") as f:
|
||||
xsd_bytes = f.read()
|
||||
default_name = os.path.splitext(os.path.basename(args.XsdPath))[0]
|
||||
else:
|
||||
print("Укажите -XsdPath или -Xsd", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
schema = _parse_xml(xsd_bytes, from_string=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"Не удалось разобрать XSD: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def local(el):
|
||||
return etree.QName(el).localname
|
||||
|
||||
|
||||
if local(schema) != "schema" or etree.QName(schema).namespace != XS_NS:
|
||||
print(f"Ожидался корневой <xs:schema> в пространстве имён {XS_NS}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
target_ns = schema.get("targetNamespace") or ""
|
||||
|
||||
# ── emit-tree primitives ─────────────────────────────────────
|
||||
|
||||
|
||||
class Node:
|
||||
__slots__ = ("tag", "attrs", "children", "text", "prefix", "declare_ns")
|
||||
|
||||
def __init__(self, tag):
|
||||
self.tag = tag
|
||||
self.attrs = [] # список dict: name, value | (ns, local) | list
|
||||
self.children = []
|
||||
self.text = None
|
||||
self.prefix = None
|
||||
self.declare_ns = None
|
||||
|
||||
|
||||
def add_attr(node, name, value):
|
||||
if value is None:
|
||||
return
|
||||
node.attrs.append({"name": name, "value": str(value)})
|
||||
|
||||
|
||||
def add_qattr(node, name, ns, loc):
|
||||
if loc is None:
|
||||
return
|
||||
node.attrs.append({"name": name, "ns": ns, "local": loc})
|
||||
|
||||
|
||||
def add_qlist_attr(node, name, pairs, clark):
|
||||
if not pairs:
|
||||
return
|
||||
node.attrs.append({"name": name, "list": pairs, "clark": clark})
|
||||
|
||||
|
||||
# Канонический порядок атрибутов — топологическая сортировка по корпусу 8.3.24
|
||||
# (acc + erp, 760 пакетов), см. docs/1c-xdto-spec.md.
|
||||
ATTR_ORDER = {
|
||||
"package": ["targetNamespace", "elementFormQualified", "attributeFormQualified"],
|
||||
"import": ["namespace"],
|
||||
"objectType": ["name", "base", "open", "abstract", "mixed", "ordered", "sequenced"],
|
||||
"property": ["name", "ref", "type", "lowerBound", "upperBound", "nillable",
|
||||
"fixed", "default", "form", "localName", "qualified"],
|
||||
"valueType": ["name", "base", "variety", "itemType", "length", "memberTypes",
|
||||
"minExclusive", "maxExclusive", "minInclusive", "maxInclusive",
|
||||
"minLength", "maxLength", "totalDigits", "fractionDigits", "whiteSpace"],
|
||||
"typeDef": ["xsi:type", "base", "mixed", "open", "ordered", "sequenced", "variety",
|
||||
"itemType", "length", "memberTypes", "minExclusive", "maxExclusive",
|
||||
"minInclusive", "maxInclusive", "minLength", "maxLength",
|
||||
"totalDigits", "fractionDigits", "whiteSpace"],
|
||||
"enumeration": ["xsi:type"],
|
||||
}
|
||||
|
||||
|
||||
def sort_attrs(node):
|
||||
order = ATTR_ORDER.get(node.tag)
|
||||
if not order:
|
||||
return node.attrs
|
||||
res = []
|
||||
for n in order:
|
||||
res.extend(a for a in node.attrs if a["name"] == n)
|
||||
res.extend(a for a in node.attrs if a["name"] not in order)
|
||||
return res
|
||||
|
||||
|
||||
def esc(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def esc_text(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# ── serializer with the dNpM prefix scheme ───────────────────
|
||||
|
||||
out = []
|
||||
|
||||
|
||||
def serialize_node(node, depth, inherited):
|
||||
indent = "\t" * (depth - 1)
|
||||
attrs_sorted = sort_attrs(node)
|
||||
|
||||
# Объявляем здесь только те ns, которых ещё нет в области видимости:
|
||||
# сериализатор платформы объявляет префикс на первом нуждающемся узле,
|
||||
# а потомки переиспользуют — отсюда d2p1 у property внутри objectType.
|
||||
local_ns = []
|
||||
|
||||
def need_prefix(ns):
|
||||
if not ns or ns in (XS_NS, XSI_NS):
|
||||
return
|
||||
if ns in inherited:
|
||||
return
|
||||
if ns not in local_ns:
|
||||
local_ns.append(ns)
|
||||
|
||||
for a in attrs_sorted:
|
||||
if "list" in a:
|
||||
# Нотация Кларка несёт ns в значении и префикса не требует
|
||||
if not a["clark"]:
|
||||
for p in a["list"]:
|
||||
need_prefix(p[0])
|
||||
elif a.get("ns"):
|
||||
need_prefix(a["ns"])
|
||||
|
||||
has_qualified = any(a["name"] == "qualified" for a in attrs_sorted)
|
||||
if has_qualified:
|
||||
need_prefix(XDTO_NS)
|
||||
if node.declare_ns:
|
||||
need_prefix(node.declare_ns)
|
||||
|
||||
prefix_of = dict(inherited)
|
||||
ns_decls = ""
|
||||
for i, ns in enumerate(local_ns):
|
||||
# Осмысленный префикс из исходника (зеркало xdto:prefix) имеет приоритет
|
||||
px = node.prefix if (i == 0 and node.prefix) else f"d{depth}p{i + 1}"
|
||||
prefix_of[ns] = px
|
||||
ns_decls += f' xmlns:{px}="{esc(ns)}"'
|
||||
|
||||
def qval(ns, loc):
|
||||
if not ns:
|
||||
return loc
|
||||
if ns == XS_NS:
|
||||
return f"xs:{loc}"
|
||||
if ns == XSI_NS:
|
||||
return f"xsi:{loc}"
|
||||
return f"{prefix_of[ns]}:{loc}"
|
||||
|
||||
attr_text = ""
|
||||
for a in attrs_sorted:
|
||||
if "list" in a:
|
||||
vals = []
|
||||
for ns, loc in a["list"]:
|
||||
vals.append((f"{{{ns}}}{loc}" if ns else loc) if a["clark"] else qval(ns, loc))
|
||||
attr_text += f' {a["name"]}="{esc(" ".join(vals))}"'
|
||||
elif a.get("ns") or a.get("local"):
|
||||
attr_text += f' {a["name"]}="{esc(qval(a.get("ns"), a["local"]))}"'
|
||||
elif a["name"] == "qualified":
|
||||
attr_text += f' {prefix_of[XDTO_NS]}:qualified="{esc(a["value"])}"'
|
||||
else:
|
||||
attr_text += f' {a["name"]}="{esc(a["value"])}"'
|
||||
|
||||
tag_name = f"{prefix_of[XDTO_NS]}:{node.tag}" if has_qualified else node.tag
|
||||
|
||||
has_children = bool(node.children)
|
||||
# Пустое значение пишется самозакрывающимся тегом: <enumeration/>
|
||||
has_text = node.text is not None and node.text != ""
|
||||
|
||||
if not has_children and not has_text:
|
||||
out.append(f"{indent}<{tag_name}{ns_decls}{attr_text}/>\r\n")
|
||||
return
|
||||
if has_text and not has_children:
|
||||
out.append(f"{indent}<{tag_name}{ns_decls}{attr_text}>{esc_text(node.text)}</{tag_name}>\r\n")
|
||||
return
|
||||
out.append(f"{indent}<{tag_name}{ns_decls}{attr_text}>\r\n")
|
||||
for c in node.children:
|
||||
serialize_node(c, depth + 1, prefix_of)
|
||||
out.append(f"{indent}</{tag_name}>\r\n")
|
||||
|
||||
|
||||
# ── XSD reading helpers ──────────────────────────────────────
|
||||
|
||||
# Предупреждения о том, что XSD выражает, а модель XDTO — нет. Молча ронять
|
||||
# такие конструкции нельзя: пакет соберётся, а половина свойств исчезнет.
|
||||
warnings_list = []
|
||||
|
||||
|
||||
def warn(msg):
|
||||
if msg not in warnings_list:
|
||||
warnings_list.append(msg)
|
||||
|
||||
|
||||
GROUPS = {}
|
||||
ATTR_GROUPS = {}
|
||||
|
||||
|
||||
def MA(el, name):
|
||||
# xdto: mirror attribute — литеральное значение для Package.bin.
|
||||
# Ищем по namespace, а не по строке префикса.
|
||||
return el.get(f"{{{XDTO_NS}}}{name}")
|
||||
|
||||
|
||||
def xchildren(el, name):
|
||||
return [c for c in el if isinstance(c.tag, str)
|
||||
and etree.QName(c).namespace == XS_NS and local(c) == name]
|
||||
|
||||
|
||||
def xfirst(el, name):
|
||||
r = xchildren(el, name)
|
||||
return r[0] if r else None
|
||||
|
||||
|
||||
def split_qname(el, qname):
|
||||
if not qname:
|
||||
return None
|
||||
parts = qname.split(":")
|
||||
if len(parts) == 2:
|
||||
ns = el.nsmap.get(parts[0])
|
||||
loc = parts[1]
|
||||
else:
|
||||
# Прощающий ввод: голое имя типа — тип целевого пространства имён
|
||||
ns = el.nsmap.get(None) or target_ns
|
||||
loc = parts[0]
|
||||
return (ns, loc)
|
||||
|
||||
|
||||
def split_qname_list(el, lst):
|
||||
if not lst:
|
||||
return []
|
||||
return [split_qname(el, q) for q in lst.split() if q]
|
||||
|
||||
|
||||
FACETS = ["length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive", "whiteSpace"]
|
||||
|
||||
|
||||
# ── simpleType -> valueType / typeDef(ValueType) ─────────────
|
||||
|
||||
def fill_simple_type(node, st):
|
||||
restriction = xfirst(st, "restriction")
|
||||
lst = xfirst(st, "list")
|
||||
union = xfirst(st, "union")
|
||||
|
||||
if lst is not None:
|
||||
it = split_qname(lst, lst.get("itemType"))
|
||||
mv = MA(lst, "variety")
|
||||
add_attr(node, "variety", mv if mv is not None else "List")
|
||||
if it:
|
||||
add_qattr(node, "itemType", it[0], it[1])
|
||||
return
|
||||
if union is not None:
|
||||
mv = MA(union, "variety")
|
||||
set_attr_value(node, "variety", mv if mv is not None else "Union")
|
||||
members = split_qname_list(union, union.get("memberTypes"))
|
||||
# По умолчанию нотация Кларка — так записано 125 из 135 memberTypes корпуса
|
||||
use_clark = MA(union, "memberTypesForm") != "prefixed"
|
||||
if members:
|
||||
add_qlist_attr(node, "memberTypes", members, use_clark)
|
||||
node.declare_ns = MA(union, "declareNs")
|
||||
for anon in xchildren(union, "simpleType"):
|
||||
# typeDef в контексте простого типа xsi:type не несёт (40 узлов корпуса)
|
||||
td = Node("typeDef")
|
||||
fill_simple_type(td, anon)
|
||||
node.children.append(td)
|
||||
return
|
||||
if restriction is not None:
|
||||
b = split_qname(restriction, restriction.get("base"))
|
||||
if b:
|
||||
add_qattr(node, "base", b[0], b[1])
|
||||
mv = MA(restriction, "variety")
|
||||
if mv is not None:
|
||||
add_attr(node, "variety", mv)
|
||||
# Анонимный базовый тип внутри xs:restriction — typeDef без xsi:type
|
||||
anon_base = xfirst(restriction, "simpleType")
|
||||
if anon_base is not None:
|
||||
td = Node("typeDef")
|
||||
fill_simple_type(td, anon_base)
|
||||
node.children.append(td)
|
||||
for f in FACETS:
|
||||
for fe in xchildren(restriction, f):
|
||||
add_attr(node, f, fe.get("value"))
|
||||
for pe in xchildren(restriction, "pattern"):
|
||||
pn = Node("pattern")
|
||||
pn.text = pe.get("value")
|
||||
node.children.append(pn)
|
||||
for en in xchildren(restriction, "enumeration"):
|
||||
enode = Node("enumeration")
|
||||
mt = MA(en, "type")
|
||||
if mt is not None:
|
||||
q = split_qname(en, mt)
|
||||
add_qattr(enode, "xsi:type", q[0], q[1])
|
||||
enode.text = en.get("value")
|
||||
node.children.append(enode)
|
||||
|
||||
|
||||
def set_attr_value(node, name, value):
|
||||
for a in node.attrs:
|
||||
if a["name"] == name:
|
||||
a["value"] = value
|
||||
return
|
||||
add_attr(node, name, value)
|
||||
|
||||
|
||||
def get_prop_key(p):
|
||||
for a in p.attrs:
|
||||
if a["name"] == "name":
|
||||
return a.get("value")
|
||||
for a in p.attrs:
|
||||
if a["name"] == "ref":
|
||||
return "@" + a["local"]
|
||||
return None
|
||||
|
||||
|
||||
def reorder_properties(node, names):
|
||||
props = [c for c in node.children if c.tag == "property"]
|
||||
if len(props) < 2:
|
||||
return
|
||||
by_key = {}
|
||||
for p in props:
|
||||
k = get_prop_key(p)
|
||||
if k is not None and k not in by_key:
|
||||
by_key[k] = p
|
||||
ordered = []
|
||||
for n in names:
|
||||
if n in by_key:
|
||||
ordered.append(by_key.pop(n))
|
||||
for p in props:
|
||||
if p not in ordered:
|
||||
ordered.append(p)
|
||||
others = [c for c in node.children if c.tag != "property"]
|
||||
node.children = ordered + others
|
||||
|
||||
|
||||
# ── element / attribute -> property ──────────────────────────
|
||||
|
||||
def build_property(el, is_attribute):
|
||||
p = Node("property")
|
||||
|
||||
xsd_name = el.get("name")
|
||||
mirror_name = MA(el, "name")
|
||||
if mirror_name is not None:
|
||||
add_attr(p, "name", mirror_name)
|
||||
local_name = xsd_name
|
||||
else:
|
||||
add_attr(p, "name", xsd_name)
|
||||
local_name = None
|
||||
|
||||
ref_q = split_qname(el, el.get("ref"))
|
||||
if ref_q:
|
||||
add_qattr(p, "ref", ref_q[0], ref_q[1])
|
||||
|
||||
type_q = split_qname(el, el.get("type"))
|
||||
if type_q:
|
||||
add_qattr(p, "type", type_q[0], type_q[1])
|
||||
|
||||
if is_attribute:
|
||||
add_attr(p, "lowerBound", MA(el, "lowerBound"))
|
||||
add_attr(p, "upperBound", MA(el, "upperBound"))
|
||||
add_attr(p, "nillable", MA(el, "nillable"))
|
||||
else:
|
||||
add_attr(p, "lowerBound", el.get("minOccurs"))
|
||||
max_occ = el.get("maxOccurs")
|
||||
if max_occ is not None:
|
||||
add_attr(p, "upperBound", "-1" if max_occ == "unbounded" else max_occ)
|
||||
add_attr(p, "nillable", el.get("nillable"))
|
||||
|
||||
# XSD-шный fixed="V" несёт значение, в модели это fixed="true" + default="V".
|
||||
# Прощающий ввод: модельная форма через зеркало xdto:fixed тоже принимается.
|
||||
m_fixed = MA(el, "fixed")
|
||||
if m_fixed is not None:
|
||||
add_attr(p, "fixed", m_fixed)
|
||||
add_attr(p, "default", el.get("default"))
|
||||
if m_fixed == "true" and el.get("default") is None:
|
||||
warn('Свойство "' + str(el.get("name")) + '": xdto:fixed="true" без default — '
|
||||
"платформа отвергнет пакет («Отсутствует фиксированное значение»). "
|
||||
'Значение задаётся атрибутом default, либо пишите XSD-форму fixed="значение"')
|
||||
elif el.get("fixed") is not None:
|
||||
add_attr(p, "fixed", "true")
|
||||
add_attr(p, "default", el.get("fixed"))
|
||||
else:
|
||||
add_attr(p, "default", el.get("default"))
|
||||
|
||||
if is_attribute:
|
||||
add_attr(p, "form", "Attribute")
|
||||
else:
|
||||
mf = MA(el, "form")
|
||||
if mf is not None:
|
||||
add_attr(p, "form", mf)
|
||||
add_attr(p, "localName", local_name)
|
||||
add_attr(p, "qualified", MA(el, "qualified"))
|
||||
p.prefix = MA(el, "prefix")
|
||||
|
||||
anon_simple = xfirst(el, "simpleType")
|
||||
anon_complex = xfirst(el, "complexType")
|
||||
if anon_simple is not None:
|
||||
td = Node("typeDef")
|
||||
add_attr(td, "xsi:type", "ValueType")
|
||||
fill_simple_type(td, anon_simple)
|
||||
p.children.append(td)
|
||||
elif anon_complex is not None:
|
||||
td = Node("typeDef")
|
||||
add_attr(td, "xsi:type", "ObjectType")
|
||||
fill_complex_type(td, anon_complex)
|
||||
p.children.append(td)
|
||||
return p
|
||||
|
||||
|
||||
# ── complexType -> objectType / typeDef(ObjectType) ──────────
|
||||
|
||||
def resolve_group(el, kind):
|
||||
ref = el.get("ref")
|
||||
if not ref:
|
||||
return None
|
||||
q = split_qname(el, ref)
|
||||
if not q:
|
||||
return None
|
||||
m = GROUPS if kind == "group" else ATTR_GROUPS
|
||||
return m.get(q[1])
|
||||
|
||||
|
||||
# Модель XDTO знает только плоский список свойств: вложенные частицы уплощаются.
|
||||
# Каждое уплощение — предупреждение, потому что меняется смысл схемы.
|
||||
def collect_particle(particle, elem_list, open_flag, type_name, depth, optionalize=False):
|
||||
if depth > 20:
|
||||
return
|
||||
for c in particle:
|
||||
if not isinstance(c.tag, str) or etree.QName(c).namespace != XS_NS:
|
||||
continue
|
||||
ln = local(c)
|
||||
if ln == "element":
|
||||
prop = build_property(c, False)
|
||||
# Ветка уплощённого xs:choice обязана стать необязательной: иначе
|
||||
# «одно из двух» превращается в «оба сразу», и тип нельзя заполнить
|
||||
if optionalize:
|
||||
set_attr_value(prop, "lowerBound", "0")
|
||||
elem_list.append(prop)
|
||||
elif ln == "any":
|
||||
open_flag[0] = True
|
||||
elif ln == "sequence":
|
||||
warn(type_name + " : вложенная xs:sequence уплощена — модель XDTO хранит плоский список свойств")
|
||||
collect_particle(c, elem_list, open_flag, type_name, depth + 1, optionalize)
|
||||
elif ln == "choice":
|
||||
branches = [b.get("name") for b in c
|
||||
if isinstance(b.tag, str) and etree.QName(b).namespace == XS_NS and b.get("name")]
|
||||
lst = (" (" + ", ".join(branches) + ")") if branches else ""
|
||||
warn(type_name + " : вложенная xs:choice уплощена — ветки" + lst + " сделаны необязательными. "
|
||||
"Выбор одного из вариантов не сохранён: модель не запретит заполнить "
|
||||
"сразу несколько или ни одного")
|
||||
collect_particle(c, elem_list, open_flag, type_name, depth + 1, True)
|
||||
elif ln == "all":
|
||||
warn(type_name + " : xs:all трактуется как последовательность")
|
||||
collect_particle(c, elem_list, open_flag, type_name, depth + 1, optionalize)
|
||||
elif ln == "group":
|
||||
g = resolve_group(c, "group")
|
||||
if g is not None:
|
||||
for gc in g:
|
||||
if isinstance(gc.tag, str) and etree.QName(gc).namespace == XS_NS \
|
||||
and local(gc) in ("sequence", "choice", "all"):
|
||||
collect_particle(gc, elem_list, open_flag, type_name, depth + 1, optionalize)
|
||||
else:
|
||||
warn(type_name + " : не найдена группа " + str(c.get("ref")) + " — её свойства в пакет не попали")
|
||||
if ln in ("sequence", "choice", "all", "group"):
|
||||
if c.get("maxOccurs") is not None or c.get("minOccurs") is not None:
|
||||
warn(type_name + " : кратность на вложенной частице (<xs:" + ln +
|
||||
" minOccurs/maxOccurs>) не выражается в модели XDTO")
|
||||
|
||||
|
||||
def set_type_flags(node, ct, is_open, choice):
|
||||
m_open = MA(ct, "open")
|
||||
if m_open is not None:
|
||||
add_attr(node, "open", m_open)
|
||||
elif is_open:
|
||||
add_attr(node, "open", "true")
|
||||
|
||||
m_ordered = MA(ct, "ordered")
|
||||
if m_ordered is not None:
|
||||
add_attr(node, "ordered", m_ordered)
|
||||
elif choice is not None:
|
||||
add_attr(node, "ordered", "false")
|
||||
|
||||
m_seq = MA(ct, "sequenced")
|
||||
if m_seq is not None:
|
||||
add_attr(node, "sequenced", m_seq)
|
||||
|
||||
m_abstract = MA(ct, "abstract")
|
||||
if m_abstract is not None:
|
||||
add_attr(node, "abstract", m_abstract)
|
||||
elif ct.get("abstract") == "true":
|
||||
add_attr(node, "abstract", "true")
|
||||
|
||||
m_mixed = MA(ct, "mixed")
|
||||
if m_mixed is not None:
|
||||
add_attr(node, "mixed", m_mixed)
|
||||
elif ct.get("mixed") == "true":
|
||||
add_attr(node, "mixed", "true")
|
||||
|
||||
|
||||
def fill_complex_type(node, ct):
|
||||
body = ct
|
||||
content = xfirst(ct, "complexContent")
|
||||
if content is not None:
|
||||
ext = xfirst(content, "extension")
|
||||
if ext is not None:
|
||||
b = split_qname(ext, ext.get("base"))
|
||||
if b:
|
||||
add_qattr(node, "base", b[0], b[1])
|
||||
body = ext
|
||||
|
||||
# xs:simpleContent -> свойство "Text", хранящее значение самого элемента
|
||||
simple = xfirst(ct, "simpleContent")
|
||||
if simple is not None:
|
||||
ext = xfirst(simple, "extension")
|
||||
if ext is not None:
|
||||
for a in xchildren(ext, "attribute"):
|
||||
node.children.append(build_property(a, True))
|
||||
tp = Node("property")
|
||||
t_name = MA(ext, "textName")
|
||||
add_attr(tp, "name", t_name if t_name is not None else "__content")
|
||||
b = split_qname(ext, ext.get("base"))
|
||||
if b:
|
||||
add_qattr(tp, "type", b[0], b[1])
|
||||
add_attr(tp, "lowerBound", MA(ext, "textlowerBound"))
|
||||
add_attr(tp, "upperBound", MA(ext, "textupperBound"))
|
||||
add_attr(tp, "nillable", MA(ext, "textnillable"))
|
||||
add_attr(tp, "form", "Text")
|
||||
node.children.append(tp)
|
||||
# xs:simpleContent не отменяет флаги самого xs:complexType
|
||||
set_type_flags(node, ct, False, None)
|
||||
return
|
||||
|
||||
seq = xfirst(body, "sequence")
|
||||
cho = xfirst(body, "choice")
|
||||
all_ = xfirst(body, "all")
|
||||
grp = xfirst(body, "group")
|
||||
particle = seq if seq is not None else (cho if cho is not None else (all_ if all_ is not None else grp))
|
||||
open_flag = [False]
|
||||
|
||||
# Порядок в XDTO: сначала form="Attribute", потом остальные (96.5% типов корпуса)
|
||||
elem_props = []
|
||||
type_name = ct.get("name") or "(анонимный тип)"
|
||||
if particle is not None:
|
||||
if all_ is not None:
|
||||
warn(type_name + " : xs:all трактуется как последовательность")
|
||||
if grp is not None and seq is None and cho is None and all_ is None:
|
||||
# Корневая частица задана ссылкой на группу — раскрываем её содержимое
|
||||
g = resolve_group(grp, "group")
|
||||
if g is not None:
|
||||
for gc in g:
|
||||
if isinstance(gc.tag, str) and etree.QName(gc).namespace == XS_NS \
|
||||
and local(gc) in ("sequence", "choice", "all"):
|
||||
collect_particle(gc, elem_props, open_flag, type_name, 1)
|
||||
else:
|
||||
warn(type_name + " : не найдена группа " + str(grp.get("ref")) + " — её свойства в пакет не попали")
|
||||
else:
|
||||
collect_particle(particle, elem_props, open_flag, type_name, 0)
|
||||
is_open = open_flag[0]
|
||||
for a in xchildren(body, "attribute"):
|
||||
node.children.append(build_property(a, True))
|
||||
# xs:attributeGroup раскрываем по ссылке
|
||||
for ag in xchildren(body, "attributeGroup"):
|
||||
g = resolve_group(ag, "attributeGroup")
|
||||
if g is not None:
|
||||
for a in xchildren(g, "attribute"):
|
||||
node.children.append(build_property(a, True))
|
||||
else:
|
||||
warn("Не найдена группа атрибутов " + str(ag.get("ref")) + " — её атрибуты в пакет не попали")
|
||||
node.children.extend(elem_props)
|
||||
if xchildren(body, "anyAttribute"):
|
||||
is_open = True
|
||||
|
||||
m_order = MA(ct, "order")
|
||||
if m_order is not None:
|
||||
reorder_properties(node, m_order.split("|"))
|
||||
|
||||
set_type_flags(node, ct, is_open, cho)
|
||||
|
||||
|
||||
# ── build the package tree ───────────────────────────────────
|
||||
|
||||
pkg_node = Node("package")
|
||||
add_attr(pkg_node, "targetNamespace", target_ns)
|
||||
|
||||
efq_mirror = MA(schema, "elementFormQualified")
|
||||
afq_mirror = MA(schema, "attributeFormQualified")
|
||||
efd = schema.get("elementFormDefault")
|
||||
afd = schema.get("attributeFormDefault")
|
||||
if efq_mirror is not None:
|
||||
add_attr(pkg_node, "elementFormQualified", efq_mirror)
|
||||
elif efd is not None:
|
||||
add_attr(pkg_node, "elementFormQualified", "true" if efd == "qualified" else "false")
|
||||
if afq_mirror is not None:
|
||||
add_attr(pkg_node, "attributeFormQualified", afq_mirror)
|
||||
elif afd is not None:
|
||||
add_attr(pkg_node, "attributeFormQualified", "true" if afd == "qualified" else "false")
|
||||
|
||||
meta_name = meta_comment = None
|
||||
meta_synonym = []
|
||||
ann = xfirst(schema, "annotation")
|
||||
if ann is not None:
|
||||
appinfo = xfirst(ann, "appinfo")
|
||||
if appinfo is not None:
|
||||
for pk in appinfo:
|
||||
if not isinstance(pk.tag, str) or etree.QName(pk).namespace != XDTO_NS:
|
||||
continue
|
||||
for f in pk:
|
||||
if not isinstance(f.tag, str):
|
||||
continue
|
||||
ln = local(f)
|
||||
if ln == "name":
|
||||
meta_name = f.text or ""
|
||||
elif ln == "comment":
|
||||
meta_comment = f.text or ""
|
||||
elif ln == "synonym":
|
||||
meta_synonym.append({"Lang": f.get("lang") or "", "Content": f.text or ""})
|
||||
|
||||
# Реестр глобальных групп — нужен до обхода, чтобы раскрывать ссылки
|
||||
for node in schema:
|
||||
if not isinstance(node.tag, str) or etree.QName(node).namespace != XS_NS:
|
||||
continue
|
||||
nm = node.get("name")
|
||||
if local(node) == "group" and nm:
|
||||
GROUPS[nm] = node
|
||||
if local(node) == "attributeGroup" and nm:
|
||||
ATTR_GROUPS[nm] = node
|
||||
|
||||
# Конструкции XSD, которым в модели XDTO нет соответствия
|
||||
for sg in schema.iter():
|
||||
if isinstance(sg.tag, str) and local(sg) == "element" and sg.get("substitutionGroup"):
|
||||
warn("Подстановочные группы (substitutionGroup) не поддерживаются моделью XDTO — объявление "
|
||||
+ str(sg.get("name")) + " сохранено как обычное")
|
||||
for idc in ("key", "keyref", "unique"):
|
||||
if any(isinstance(e.tag, str) and local(e) == idc for e in schema.iter()):
|
||||
warn("Ограничения целостности (xs:" + idc + ") в модели XDTO не хранятся — отброшены")
|
||||
if any(isinstance(e.tag, str) and local(e) == "redefine" for e in schema.iter()):
|
||||
warn("xs:redefine не поддерживается — переопределения проигнорированы")
|
||||
if any(isinstance(e.tag, str) and local(e) == "include" for e in schema.iter()):
|
||||
warn("xs:include проигнорирован: модель XDTO разрешает зависимости только по namespace. "
|
||||
"Соберите включаемую схему отдельным пакетом и добавьте <xs:import>")
|
||||
|
||||
for node in schema:
|
||||
if not isinstance(node.tag, str) or etree.QName(node).namespace != XS_NS:
|
||||
continue
|
||||
ln = local(node)
|
||||
if ln == "import":
|
||||
n = Node("import")
|
||||
add_attr(n, "namespace", node.get("namespace"))
|
||||
pkg_node.children.append(n)
|
||||
elif ln in ("annotation", "include", "group", "attributeGroup", "notation"):
|
||||
continue
|
||||
elif ln == "element":
|
||||
pkg_node.children.append(build_property(node, False))
|
||||
elif ln == "attribute":
|
||||
pkg_node.children.append(build_property(node, True))
|
||||
elif ln == "simpleType":
|
||||
n = Node("valueType")
|
||||
add_attr(n, "name", node.get("name"))
|
||||
fill_simple_type(n, node)
|
||||
pkg_node.children.append(n)
|
||||
elif ln == "complexType":
|
||||
n = Node("objectType")
|
||||
add_attr(n, "name", node.get("name"))
|
||||
fill_complex_type(n, node)
|
||||
pkg_node.children.append(n)
|
||||
|
||||
# ── serialize Package.bin ────────────────────────────────────
|
||||
|
||||
# Модель XDTO требует строгой последовательности элементов верхнего уровня:
|
||||
# import → property → valueType → objectType. Порядок объявлений в XSD произвольный,
|
||||
# поэтому пересортировываем — иначе платформа отвергает пакет с «Ошибка преобразования
|
||||
# данных XDTO». Все 760 пакетов корпуса этому порядку удовлетворяют.
|
||||
TOP_ORDER = ["import", "property", "valueType", "objectType"]
|
||||
pkg_node.children = (
|
||||
[c for t in TOP_ORDER for c in pkg_node.children if c.tag == t]
|
||||
+ [c for c in pkg_node.children if c.tag not in TOP_ORDER]
|
||||
)
|
||||
|
||||
root_attr_text = "".join(f' {a["name"]}="{esc(a["value"])}"' for a in sort_attrs(pkg_node))
|
||||
out.append(f'<package xmlns="{XDTO_NS}" xmlns:xs="{XS_NS}" xmlns:xsi="{XSI_NS}"{root_attr_text}>\r\n')
|
||||
for c in pkg_node.children:
|
||||
serialize_node(c, 2, {})
|
||||
out.append("</package>")
|
||||
|
||||
bin_text = "".join(out)
|
||||
|
||||
# ── resolve the package name ─────────────────────────────────
|
||||
|
||||
name = args.Name or meta_name or default_name
|
||||
name = re.sub(r"[^\wЀ-ӿ]", "_", name, flags=re.UNICODE)
|
||||
if re.match(r"^\d", name):
|
||||
name = "_" + name
|
||||
|
||||
assert_edit_allowed(args.OutputDir)
|
||||
|
||||
format_version = detect_format_version(os.path.abspath(args.OutputDir))
|
||||
|
||||
pkg_root = os.path.join(args.OutputDir, "XDTOPackages")
|
||||
pkg_dir = os.path.join(pkg_root, name)
|
||||
ext_dir = os.path.join(pkg_dir, "Ext")
|
||||
md_file = os.path.join(pkg_root, name + ".xml")
|
||||
bin_file = os.path.join(ext_dir, "Package.bin")
|
||||
|
||||
if os.path.exists(bin_file) and not args.Force:
|
||||
print(f"Пакет уже существует: {bin_file}. Используйте -Force для перезаписи.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
with open(bin_file, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbf" + bin_text.encode("utf-8"))
|
||||
|
||||
# ── metadata object file ─────────────────────────────────────
|
||||
|
||||
if not args.Synonym and meta_synonym:
|
||||
syn_items = meta_synonym
|
||||
elif args.Synonym:
|
||||
syn_items = [{"Lang": "ru", "Content": args.Synonym}]
|
||||
else:
|
||||
syn_items = [{"Lang": "ru", "Content": name}]
|
||||
comment = args.Comment or meta_comment or ""
|
||||
|
||||
md_lines = [
|
||||
'<?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" '
|
||||
f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">',
|
||||
f'\t<XDTOPackage uuid="{uuid.uuid4()}">',
|
||||
"\t\t<Properties>",
|
||||
f"\t\t\t<Name>{esc_text(name)}</Name>",
|
||||
"\t\t\t<Synonym>",
|
||||
]
|
||||
for s in syn_items:
|
||||
md_lines += [
|
||||
"\t\t\t\t<v8:item>",
|
||||
f'\t\t\t\t\t<v8:lang>{esc_text(s["Lang"])}</v8:lang>',
|
||||
f'\t\t\t\t\t<v8:content>{esc_text(s["Content"])}</v8:content>',
|
||||
"\t\t\t\t</v8:item>",
|
||||
]
|
||||
md_lines.append("\t\t\t</Synonym>")
|
||||
md_lines.append(f"\t\t\t<Comment>{esc_text(comment)}</Comment>" if comment else "\t\t\t<Comment/>")
|
||||
md_lines.append(f"\t\t\t<Namespace>{esc_text(target_ns)}</Namespace>")
|
||||
md_lines += ["\t\t</Properties>", "\t</XDTOPackage>", "</MetaDataObject>"]
|
||||
|
||||
with open(md_file, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbf" + "\r\n".join(md_lines).encode("utf-8"))
|
||||
|
||||
# ── register in Configuration.xml ────────────────────────────
|
||||
|
||||
# Ранняя диагностика: отказ платформы при db-update дешевле поймать на сборке
|
||||
xdto_root_dir = os.path.join(args.OutputDir, "XDTOPackages")
|
||||
declared_imports = [a["value"] for c in pkg_node.children if c.tag == "import"
|
||||
for a in c.attrs if a["name"] == "namespace"]
|
||||
if declared_imports and os.path.isdir(xdto_root_dir):
|
||||
known_ns = set()
|
||||
for other in sorted(os.listdir(xdto_root_dir)):
|
||||
ob = os.path.join(xdto_root_dir, other, "Ext", "Package.bin")
|
||||
if not os.path.exists(ob):
|
||||
continue
|
||||
try:
|
||||
known_ns.add(_parse_xml(ob).getroot().get("targetNamespace"))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
for imp in declared_imports:
|
||||
if imp not in known_ns and imp not in PLATFORM_NS:
|
||||
warn(f'Импорт "{imp}" не разрешается: пакета с таким namespace в конфигурации нет. '
|
||||
"Платформа отвергнет пакет при обновлении — соберите зависимость первой")
|
||||
|
||||
config_xml = os.path.join(args.OutputDir, "Configuration.xml")
|
||||
reg_result = "no-config"
|
||||
if os.path.exists(config_xml):
|
||||
with open(config_xml, "rb") as f:
|
||||
raw = f.read()
|
||||
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
cfg_doc = _parse_xml(config_xml)
|
||||
child_objects = cfg_doc.find(f".//{{{MD_NS}}}Configuration/{{{MD_NS}}}ChildObjects")
|
||||
if child_objects is not None:
|
||||
existing = child_objects.findall(f"{{{MD_NS}}}XDTOPackage")
|
||||
if any((e.text or "") == name for e in existing):
|
||||
reg_result = "already"
|
||||
else:
|
||||
new_elem = etree.SubElement(child_objects, f"{{{MD_NS}}}XDTOPackage")
|
||||
new_elem.text = name
|
||||
if existing:
|
||||
last = existing[-1]
|
||||
new_elem.tail = last.tail
|
||||
child_objects.remove(new_elem)
|
||||
last.addnext(new_elem)
|
||||
else:
|
||||
new_elem.tail = child_objects.text
|
||||
data = etree.tostring(cfg_doc, xml_declaration=True, encoding="UTF-8")
|
||||
if had_bom:
|
||||
data = b"\xef\xbb\xbf" + data
|
||||
with open(config_xml, "wb") as f:
|
||||
f.write(data)
|
||||
reg_result = "added"
|
||||
|
||||
type_count = sum(1 for c in pkg_node.children if c.tag in ("objectType", "valueType"))
|
||||
print(f"✓ Пакет XDTO собран: {name}")
|
||||
print(f" Namespace: {target_ns}")
|
||||
print(f" Типов: {type_count}")
|
||||
print(f" Файлы: XDTOPackages/{name}.xml, XDTOPackages/{name}/Ext/Package.bin")
|
||||
if warnings_list:
|
||||
print("")
|
||||
print("Предупреждения (" + str(len(warnings_list)) +
|
||||
") — конструкции XSD без точного соответствия в модели XDTO:")
|
||||
for w in warnings_list:
|
||||
print(" ! " + w)
|
||||
print("")
|
||||
if reg_result == "added":
|
||||
print(f" Configuration.xml: <XDTOPackage>{name}</XDTOPackage> добавлен в ChildObjects")
|
||||
elif reg_result == "already":
|
||||
print(f" Configuration.xml: <XDTOPackage>{name}</XDTOPackage> уже зарегистрирован")
|
||||
else:
|
||||
print(" Configuration.xml не найден — регистрация пропущена")
|
||||
@@ -1,88 +0,0 @@
|
||||
# XSD ↔ XDTO — справочник
|
||||
|
||||
## Соответствия
|
||||
|
||||
| XML Schema | Модель XDTO |
|
||||
|---|---|
|
||||
| `xs:schema/@targetNamespace` | пространство имён пакета |
|
||||
| `elementFormDefault` / `attributeFormDefault` | `elementFormQualified` / `attributeFormQualified` |
|
||||
| `xs:import/@namespace` | зависимость от другого пакета (разрешается по namespace) |
|
||||
| `xs:complexType` | объектный тип |
|
||||
| `xs:simpleType` | тип значения |
|
||||
| `xs:element` / `xs:attribute` на верхнем уровне | глобальное свойство пакета |
|
||||
| `xs:element` / `xs:attribute` внутри типа | свойство типа |
|
||||
| `@minOccurs` / `@maxOccurs="unbounded"` | `lowerBound` / `upperBound="-1"` |
|
||||
| `@nillable`, `@default`, `@fixed`, `@ref` | те же по смыслу |
|
||||
| анонимный `xs:simpleType`/`xs:complexType` в объявлении | встроенный тип свойства |
|
||||
| `xs:complexContent/xs:extension/@base` | наследование типа |
|
||||
| `@abstract`, `@mixed` | те же |
|
||||
| `xs:choice` | тип-выбор одного из вариантов |
|
||||
| `xs:any` + `xs:anyAttribute` | открытый тип |
|
||||
| `xs:simpleContent/xs:extension/@base` | свойство собственного значения элемента |
|
||||
| `xs:restriction` + фасеты | базовый тип + ограничения |
|
||||
| `xs:pattern`, `xs:enumeration` | те же |
|
||||
| `xs:list/@itemType` | список |
|
||||
| `xs:union/@memberTypes` | объединение |
|
||||
|
||||
Порядок объявлений верхнего уровня в XSD произвольный — навык сам расставит их
|
||||
в порядке, который требует модель.
|
||||
|
||||
## Аннотации `xdto:`
|
||||
|
||||
Пространство имён — `http://v8.1c.ru/8.1/xdto`. Нужны только там, где XML Schema
|
||||
не может выразить то, что умеет модель. В большинстве схем не нужны вовсе.
|
||||
|
||||
Правило: **чего XSD сказать не может — пиши атрибутом `xdto:` с тем же именем,
|
||||
что и в модели**.
|
||||
|
||||
| Аннотация | Где | Назначение |
|
||||
|---|---|---|
|
||||
| `xdto:nillable` | `xs:attribute` | `nillable` у свойства-атрибута (XSD допускает только у элементов) |
|
||||
| `xdto:lowerBound`, `xdto:upperBound` | `xs:attribute` | кратность свойства-атрибута |
|
||||
| `xdto:qualified` | объявление | переопределение `*FormQualified` для одного свойства |
|
||||
| `xdto:name` | объявление | имя свойства, если XML-имя не годится как идентификатор 1С; XML-имя уйдёт в `localName` |
|
||||
| `xdto:form` | `xs:element` | записать `form` явно |
|
||||
| `xdto:variety` | `xs:restriction`, `xs:list`, `xs:union` | записать разновидность типа явно |
|
||||
| `xdto:open`, `xdto:abstract`, `xdto:mixed`, `xdto:ordered`, `xdto:sequenced` | `xs:complexType` | флаги типа, не выводимые из модели содержимого |
|
||||
| `xdto:order` | `xs:complexType` | исходный порядок свойств, если он не «атрибуты первыми»; имена через `\|` |
|
||||
| `xdto:textName`, `xdto:textlowerBound`, `xdto:textupperBound`, `xdto:textnillable` | `xs:extension` в `xs:simpleContent` | параметры свойства собственного значения |
|
||||
| `xdto:type` | `xs:enumeration` | тип литерала перечисления |
|
||||
| `xdto:prefix` | объявление | осмысленный префикс пространства имён вместо генерируемого |
|
||||
| `xdto:memberTypesForm="prefixed"` | `xs:union` | записать состав объединения префиксами, а не `{ns}имя` |
|
||||
| `xdto:declareNs` | `xs:union` | объявить префикс пространства имён на узле |
|
||||
| `xdto:elementFormQualified`, `xdto:attributeFormQualified` | `xs:schema` | записать флаги явно |
|
||||
|
||||
Пример:
|
||||
|
||||
```xml
|
||||
<xs:complexType name="КонтактнаяИнформация" xdto:sequenced="true">
|
||||
<xs:sequence>
|
||||
<xs:element name="Комментарий" type="xs:string" minOccurs="0"/>
|
||||
<xs:element name="Адрес по документу" type="xs:string"
|
||||
xdto:name="Адрес_по_документу" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Представление" type="xs:string"
|
||||
xdto:nillable="true" xdto:lowerBound="0"/>
|
||||
</xs:complexType>
|
||||
```
|
||||
|
||||
Аннотации, которые проставляет `/xdto-decompile` при выгрузке существующего пакета,
|
||||
писать вручную не нужно — они нужны, чтобы обратная сборка вернула ровно тот же файл.
|
||||
|
||||
## Свойства объекта метаданных
|
||||
|
||||
```xml
|
||||
<xs:annotation>
|
||||
<xs:appinfo>
|
||||
<xdto:package xmlns:xdto="http://v8.1c.ru/8.1/xdto">
|
||||
<xdto:name>ОбменСБанком</xdto:name>
|
||||
<xdto:synonym lang="ru">Обмен с банком</xdto:synonym>
|
||||
<xdto:synonym lang="en">Bank exchange</xdto:synonym>
|
||||
<xdto:comment>Формат 1С:Предприятие — Клиент банка</xdto:comment>
|
||||
</xdto:package>
|
||||
</xs:appinfo>
|
||||
</xs:annotation>
|
||||
```
|
||||
|
||||
Пространство имён пакета берётся из `targetNamespace` и здесь не дублируется.
|
||||
Параметры `-Name`, `-Synonym`, `-Comment` имеют приоритет над этим блоком.
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
name: xdto-decompile
|
||||
description: Выгрузка пакета XDTO 1С в XML-схему (XSD). Используй когда нужно получить схему существующего пакета — чтобы переработать её целиком, отдать контрагенту или перенести пакет в другую конфигурацию
|
||||
argument-hint: <PackagePath> [-OutFile <файл.xsd>]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /xdto-decompile — Выгрузка пакета XDTO в XML-схему
|
||||
|
||||
Превращает пакет XDTO в обычную XML-схему — читаемую и редактируемую.
|
||||
Заменяет чтение `Ext/Package.bin` напрямую.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `PackagePath` | да | Каталог пакета, путь к `Ext/Package.bin` или к `<Имя>.xml` объекта метаданных. Псевдоним — `-Path` |
|
||||
| `OutFile` | нет | Записать схему в файл (UTF-8 с BOM). Без него — вывод в stdout |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/xdto-decompile/scripts/xdto-decompile.py" -PackagePath "<путь>"
|
||||
```
|
||||
|
||||
Примеры:
|
||||
```powershell
|
||||
... -PackagePath src/XDTOPackages/ОбменСБанком
|
||||
... -PackagePath src/XDTOPackages/ОбменСБанком -OutFile bank.xsd
|
||||
```
|
||||
|
||||
## Переработка схемы целиком
|
||||
|
||||
`/xdto-decompile` → правка XSD → `/xdto-compile -Force` возвращает пакет без потерь,
|
||||
включая имя, синоним и комментарий объекта метаданных — они выгружаются
|
||||
в `xs:annotation/xs:appinfo`.
|
||||
|
||||
Этот же путь даёт версионную копию пакета: смени в схеме `targetNamespace` и собери
|
||||
её с `-Name` нового пакета — имя и синоним задаются флагами `/xdto-compile`, внутри
|
||||
схемы их править не нужно. Меняя пространство имён, поправь **и объявление `xmlns`
|
||||
с тем же URI**: внутренние ссылки пользуются им как префиксом. Заменять все вхождения
|
||||
строки нельзя — пострадает импорт пространства имён, для которого старый URI является
|
||||
префиксом (`urn:пример:обмен` и `urn:пример:обмен:legacy`).
|
||||
|
||||
Этот путь нужен, когда схему меняют широко или сначала надо разобраться, как она
|
||||
устроена. Чтобы поправить одно свойство, схему целиком читать не нужно — `/xdto-edit`.
|
||||
Если нужна не схема, а сводка «что присвоить и что обязательно», — `/xdto-info`.
|
||||
|
||||
В схеме могут встретиться атрибуты с префиксом `xdto:` — так записано то, что
|
||||
XML Schema выразить не может (например `nillable` у атрибута). Схема при этом остаётся
|
||||
валидной, валидаторы такие атрибуты игнорируют. Трогать их обычно не нужно; смысл
|
||||
каждого описан в `xsd-reference.md` навыка `/xdto-compile`.
|
||||
|
||||
## Передача схемы наружу
|
||||
|
||||
Полученную XSD можно отдавать контрагенту как есть — она валидна и не теряет
|
||||
данных пакета. Штатная команда «Экспорт XML-схемы» в Конфигураторе для этого
|
||||
хуже: она теряет `nillable` у свойств-атрибутов.
|
||||
@@ -1,612 +0,0 @@
|
||||
# xdto-decompile v1.0 — Convert 1C XDTO package to XML Schema (XSD)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Alias('Path')]
|
||||
[string]$PackagePath,
|
||||
[string]$OutFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
$XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
$XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
$MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
$V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
# --- Resolve paths: accept package dir, Package.bin, or metadata .xml ---
|
||||
|
||||
function Resolve-PackagePaths([string]$p) {
|
||||
$binPath = $null
|
||||
$mdPath = $null
|
||||
|
||||
if (Test-Path $p -PathType Leaf) {
|
||||
if ([System.IO.Path]::GetFileName($p) -eq "Package.bin") {
|
||||
$binPath = $p
|
||||
# <Name>/Ext/Package.bin -> <Name>.xml
|
||||
$extDir = [System.IO.Path]::GetDirectoryName($p)
|
||||
$pkgDir = [System.IO.Path]::GetDirectoryName($extDir)
|
||||
$cand = "$pkgDir.xml"
|
||||
if (Test-Path $cand) { $mdPath = $cand }
|
||||
} elseif ($p.EndsWith(".xml")) {
|
||||
$mdPath = $p
|
||||
$pkgDir = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($p), [System.IO.Path]::GetFileNameWithoutExtension($p))
|
||||
$cand = Join-Path (Join-Path $pkgDir "Ext") "Package.bin"
|
||||
if (Test-Path $cand) { $binPath = $cand }
|
||||
}
|
||||
} elseif (Test-Path $p -PathType Container) {
|
||||
$cand = Join-Path (Join-Path $p "Ext") "Package.bin"
|
||||
if (Test-Path $cand) {
|
||||
$binPath = $cand
|
||||
$mdCand = "$($p.TrimEnd('\','/')).xml"
|
||||
if (Test-Path $mdCand) { $mdPath = $mdCand }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $binPath) { throw "Не найден Ext/Package.bin для пути: $p" }
|
||||
return @{ Bin = $binPath; Md = $mdPath }
|
||||
}
|
||||
|
||||
$paths = Resolve-PackagePaths $PackagePath
|
||||
|
||||
# --- Load package model ---
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
$doc.Load($paths.Bin)
|
||||
$pkg = $doc.DocumentElement
|
||||
if ($pkg.get_LocalName() -ne "package") { throw "Ожидался корневой <package>, получен <$($pkg.get_LocalName())>" }
|
||||
|
||||
$targetNs = $pkg.GetAttribute("targetNamespace")
|
||||
|
||||
# --- Namespace -> prefix map for the emitted schema ---
|
||||
|
||||
$nsPrefix = @{}
|
||||
$nsPrefix[$XS_NS] = "xs"
|
||||
if ($targetNs) { $nsPrefix[$targetNs] = "tns" }
|
||||
|
||||
$imports = @()
|
||||
foreach ($imp in $pkg.ChildNodes) {
|
||||
if ($imp.NodeType -ne [System.Xml.XmlNodeType]::Element -or $imp.get_LocalName() -ne "import") { continue }
|
||||
$ns = $imp.GetAttribute("namespace")
|
||||
$imports += $ns
|
||||
if (-not $nsPrefix.ContainsKey($ns)) { $nsPrefix[$ns] = "ns" + ($nsPrefix.Count) }
|
||||
}
|
||||
|
||||
# Any foreign namespace referenced but not imported still needs a prefix
|
||||
function Register-Ns([string]$ns) {
|
||||
if (-not $ns) { return }
|
||||
if (-not $nsPrefix.ContainsKey($ns)) { $nsPrefix[$ns] = "ns" + ($nsPrefix.Count) }
|
||||
}
|
||||
|
||||
# --- Output buffer ---
|
||||
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
function X([string]$line) { [void]$sb.Append($line); [void]$sb.Append("`r`n") }
|
||||
function Esc([string]$s) {
|
||||
if ($null -eq $s) { return "" }
|
||||
return $s.Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace('"', """)
|
||||
}
|
||||
function EscText([string]$s) {
|
||||
if ($null -eq $s) { return "" }
|
||||
return $s.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
}
|
||||
|
||||
# --- QName conversion: bin prefix -> schema prefix ---
|
||||
|
||||
function Convert-QName([System.Xml.XmlElement]$el, [string]$qname) {
|
||||
if (-not $qname) { return $null }
|
||||
# Нотация Кларка {ns}local — так записаны почти все memberTypes
|
||||
if ($qname.StartsWith("{")) {
|
||||
$close = $qname.IndexOf("}")
|
||||
if ($close -gt 0) {
|
||||
$ns = $qname.Substring(1, $close - 1)
|
||||
$local = $qname.Substring($close + 1)
|
||||
if (-not $ns) { return $local }
|
||||
Register-Ns $ns
|
||||
return "$($nsPrefix[$ns]):$local"
|
||||
}
|
||||
}
|
||||
$parts = $qname.Split(":")
|
||||
if ($parts.Count -eq 2) {
|
||||
$ns = $el.GetNamespaceOfPrefix($parts[0])
|
||||
$local = $parts[1]
|
||||
} else {
|
||||
$ns = $el.GetNamespaceOfPrefix("")
|
||||
$local = $parts[0]
|
||||
}
|
||||
if (-not $ns) { return $qname }
|
||||
Register-Ns $ns
|
||||
return "$($nsPrefix[$ns]):$local"
|
||||
}
|
||||
|
||||
function Convert-QNameList([System.Xml.XmlElement]$el, [string]$list) {
|
||||
if (-not $list) { return $null }
|
||||
$out = @()
|
||||
foreach ($q in ($list -split "\s+")) {
|
||||
if ($q) { $out += (Convert-QName $el $q) }
|
||||
}
|
||||
return ($out -join " ")
|
||||
}
|
||||
|
||||
# --- Attribute helpers ---
|
||||
|
||||
function A([System.Xml.XmlElement]$el, [string]$name) {
|
||||
if ($el.HasAttribute($name)) { return $el.GetAttribute($name) }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Emits `key="value"` pairs, skipping nulls
|
||||
function Attrs([object[]]$pairs) {
|
||||
$out = ""
|
||||
for ($i = 0; $i -lt $pairs.Count; $i += 2) {
|
||||
$v = $pairs[$i + 1]
|
||||
if ($null -ne $v) { $out += " $($pairs[$i])=`"$(Esc ([string]$v))`"" }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
# --- xdto: mirror attributes ---
|
||||
# Everything XSD cannot express literally rides as xdto:<same name as in Package.bin>.
|
||||
# Mirrors are emitted only when the literal bin form is not recoverable from the XSD.
|
||||
|
||||
$usesXdtoNs = $false
|
||||
function Mirror([string]$name, $value) {
|
||||
# $value НЕ типизируем: [string]$null коэрсится в "" и зеркало ложно появляется
|
||||
if ($null -eq $value) { return "" }
|
||||
$script:usesXdtoNs = $true
|
||||
return " xdto:$name=`"$(Esc ([string]$value))`""
|
||||
}
|
||||
|
||||
# Обычно префиксы генерируются схемой dNpM, но изредка узел несёт осмысленный
|
||||
# префикс (например dcsset) — его надо сохранить, иначе round-trip не сойдётся.
|
||||
function Mirror-Prefix([System.Xml.XmlElement]$el) {
|
||||
foreach ($a in $el.Attributes) {
|
||||
if ($a.Prefix -ne "xmlns") { continue }
|
||||
if ($a.get_LocalName() -match '^d\d+p\d+$') { continue }
|
||||
return (Mirror "prefix" $a.get_LocalName())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Facet emission (simple types) ---
|
||||
|
||||
$FACET_ATTRS = @("length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive", "whiteSpace")
|
||||
|
||||
function Emit-Facets([System.Xml.XmlElement]$el, [string]$indent) {
|
||||
foreach ($f in $FACET_ATTRS) {
|
||||
$v = A $el $f
|
||||
if ($null -ne $v) { X "$indent<xs:$f value=`"$(Esc $v)`"/>" }
|
||||
}
|
||||
foreach ($child in $el.ChildNodes) {
|
||||
if ($child.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
if ($child.get_LocalName() -eq "pattern") {
|
||||
X "$indent<xs:pattern value=`"$(Esc $child.InnerText)`"/>"
|
||||
} elseif ($child.get_LocalName() -eq "enumeration") {
|
||||
# xsi:type on enumeration has no XSD counterpart — mirror it
|
||||
$xsiType = $child.GetAttribute("type", $XSI_NS)
|
||||
$m = ""
|
||||
if ($xsiType) { $m = Mirror "type" (Convert-QName $child $xsiType) }
|
||||
X "$indent<xs:enumeration value=`"$(Esc $child.InnerText)`"$m/>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Has-SimpleContent([System.Xml.XmlElement]$el) {
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "pattern") { return $true }
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "enumeration") { return $true }
|
||||
}
|
||||
foreach ($f in $FACET_ATTRS) { if ($null -ne (A $el $f)) { return $true } }
|
||||
return $false
|
||||
}
|
||||
|
||||
# --- Simple type body (valueType / typeDef xsi:type=ValueType) ---
|
||||
|
||||
function Emit-SimpleTypeBody([System.Xml.XmlElement]$el, [string]$indent) {
|
||||
$variety = A $el "variety"
|
||||
$base = Convert-QName $el (A $el "base")
|
||||
$itemType = Convert-QName $el (A $el "itemType")
|
||||
$memberTypes= Convert-QNameList $el (A $el "memberTypes")
|
||||
|
||||
# variety is mirrored: "Atomic" is written explicitly for only part of the corpus
|
||||
$mv = Mirror "variety" $variety
|
||||
|
||||
# memberTypes почти всегда записаны нотацией Кларка; редкую префиксную форму зеркалим
|
||||
$rawMembers = A $el "memberTypes"
|
||||
if ($null -ne $rawMembers -and -not $rawMembers.StartsWith("{")) {
|
||||
$mv += Mirror "memberTypesForm" "prefixed"
|
||||
}
|
||||
# При нотации Кларка объявление xmlns:dNpM иногда присутствует, иногда нет —
|
||||
# из значения это не выводится (зависит от состояния сериализатора), зеркалим факт
|
||||
if ($null -ne $rawMembers -and $rawMembers.StartsWith("{")) {
|
||||
foreach ($a in $el.Attributes) {
|
||||
if ($a.Prefix -eq "xmlns" -and $a.get_LocalName() -match '^d\d+p\d+$') {
|
||||
$mv += Mirror "declareNs" $a.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($variety -eq "List" -or $itemType) {
|
||||
X "$indent<xs:list$(Attrs @('itemType', $itemType))$mv/>"
|
||||
return
|
||||
}
|
||||
if ($variety -eq "Union" -or $memberTypes) {
|
||||
$anon = @()
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") { $anon += $c }
|
||||
}
|
||||
if ($anon.Count -eq 0) {
|
||||
X "$indent<xs:union$(Attrs @('memberTypes', $memberTypes))$mv/>"
|
||||
} else {
|
||||
X "$indent<xs:union$(Attrs @('memberTypes', $memberTypes))$mv>"
|
||||
foreach ($c in $anon) {
|
||||
X "$indent`t<xs:simpleType>"
|
||||
Emit-SimpleTypeBody $c "$indent`t`t"
|
||||
X "$indent`t</xs:simpleType>"
|
||||
}
|
||||
X "$indent</xs:union>"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Базовый тип может быть задан не атрибутом base, а вложенным анонимным typeDef
|
||||
$anonBase = $null
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") { $anonBase = $c; break }
|
||||
}
|
||||
|
||||
if ((Has-SimpleContent $el) -or $anonBase) {
|
||||
X "$indent<xs:restriction$(Attrs @('base', $base))$mv>"
|
||||
if ($anonBase) {
|
||||
X "$indent`t<xs:simpleType>"
|
||||
Emit-SimpleTypeBody $anonBase "$indent`t`t"
|
||||
X "$indent`t</xs:simpleType>"
|
||||
}
|
||||
Emit-Facets $el "$indent`t"
|
||||
X "$indent</xs:restriction>"
|
||||
} else {
|
||||
X "$indent<xs:restriction$(Attrs @('base', $base))$mv/>"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Property classification ---
|
||||
|
||||
function Get-PropForm([System.Xml.XmlElement]$p) {
|
||||
$f = A $p "form"
|
||||
if ($null -eq $f) { return "Element" }
|
||||
return $f
|
||||
}
|
||||
|
||||
function Get-AnonTypeDef([System.Xml.XmlElement]$p) {
|
||||
foreach ($c in $p.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") { return $c }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Property emission ---
|
||||
|
||||
function Emit-Property([System.Xml.XmlElement]$p, [string]$indent, [bool]$isGlobal) {
|
||||
$form = Get-PropForm $p
|
||||
$name = A $p "name"
|
||||
$type = Convert-QName $p (A $p "type")
|
||||
$ref = Convert-QName $p (A $p "ref")
|
||||
$local = A $p "localName"
|
||||
$lower = A $p "lowerBound"
|
||||
$upper = A $p "upperBound"
|
||||
$nill = A $p "nillable"
|
||||
$def = A $p "default"
|
||||
$fix = A $p "fixed"
|
||||
# В модели fixed — булев флаг, значение лежит в default; в XSD наоборот:
|
||||
# fixed="V" несёт само значение. Переводим, а не копируем.
|
||||
$defOut = $def
|
||||
$fixOut = $null
|
||||
$fixMirror = ""
|
||||
if ($fix -eq "true" -and $null -ne $def) { $fixOut = $def; $defOut = $null }
|
||||
elseif ($null -ne $fix) { $fixMirror = Mirror "fixed" $fix }
|
||||
$anon = Get-AnonTypeDef $p
|
||||
# qualified записан как атрибут в пространстве имён XDTO
|
||||
$qual = $p.GetAttribute("qualified", $XDTO_NS)
|
||||
if ($qual -eq "") { $qual = $null }
|
||||
|
||||
# lowerBound/upperBound map 1:1 onto minOccurs/maxOccurs, including "written explicitly"
|
||||
$minOccurs = $lower
|
||||
$maxOccurs = $null
|
||||
if ($null -ne $upper) { $maxOccurs = if ($upper -eq "-1") { "unbounded" } else { $upper } }
|
||||
|
||||
# localName carries the original XML name when it is not a valid 1C identifier
|
||||
$xmlName = if ($null -ne $local) { $local } else { $name }
|
||||
$mirrorName = if ($null -ne $local) { Mirror "name" $name } else { "" }
|
||||
|
||||
$m = ""
|
||||
$isAttr = ($form -eq "Attribute")
|
||||
|
||||
if ($isAttr) {
|
||||
# XSD forbids nillable on attributes, and has no minOccurs/maxOccurs
|
||||
if ($null -ne $qual) { $m += Mirror "qualified" $qual }
|
||||
if ($null -ne $nill) { $m += Mirror "nillable" $nill }
|
||||
if ($null -ne $lower) { $m += Mirror "lowerBound" $lower }
|
||||
if ($null -ne $upper) { $m += Mirror "upperBound" $upper }
|
||||
$m += $mirrorName
|
||||
$m += $fixMirror
|
||||
$body = Attrs @('name', $xmlName, 'ref', $ref, 'type', $type, 'default', $defOut, 'fixed', $fixOut)
|
||||
if ($anon) {
|
||||
X "$indent<xs:attribute$body$m>"
|
||||
X "$indent`t<xs:simpleType>"
|
||||
Emit-SimpleTypeBody $anon "$indent`t`t"
|
||||
X "$indent`t</xs:simpleType>"
|
||||
X "$indent</xs:attribute>"
|
||||
} else {
|
||||
X "$indent<xs:attribute$body$m/>"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ($form -eq "Text") {
|
||||
# handled by the owning complexType (xs:simpleContent)
|
||||
return
|
||||
}
|
||||
|
||||
# form="Element" written explicitly is indistinguishable in XSD from the default
|
||||
if ($null -ne (A $p "form")) { $m += Mirror "form" $form }
|
||||
if ($null -ne $qual) { $m += Mirror "qualified" $qual }
|
||||
$m += $mirrorName
|
||||
$m += (Mirror-Prefix $p)
|
||||
$m += $fixMirror
|
||||
|
||||
$body = Attrs @('name', $xmlName, 'ref', $ref, 'type', $type,
|
||||
'minOccurs', $minOccurs, 'maxOccurs', $maxOccurs,
|
||||
'nillable', $nill, 'default', $defOut, 'fixed', $fixOut)
|
||||
|
||||
if ($anon) {
|
||||
X "$indent<xs:element$body$m>"
|
||||
if ($anon.GetAttribute("type", $XSI_NS) -eq "ObjectType") {
|
||||
$anonBase = Convert-QName $anon (A $anon "base")
|
||||
if ($anonBase) {
|
||||
X "$indent`t<xs:complexType$(ComplexTypeAttrs $anon)>"
|
||||
X "$indent`t`t<xs:complexContent>"
|
||||
X "$indent`t`t`t<xs:extension$(Attrs @('base', $anonBase))>"
|
||||
Emit-ComplexTypeBody $anon "$indent`t`t`t`t"
|
||||
X "$indent`t`t`t</xs:extension>"
|
||||
X "$indent`t`t</xs:complexContent>"
|
||||
X "$indent`t</xs:complexType>"
|
||||
} else {
|
||||
X "$indent`t<xs:complexType$(ComplexTypeAttrs $anon)>"
|
||||
Emit-ComplexTypeBody $anon "$indent`t`t"
|
||||
X "$indent`t</xs:complexType>"
|
||||
}
|
||||
} else {
|
||||
X "$indent`t<xs:simpleType>"
|
||||
Emit-SimpleTypeBody $anon "$indent`t`t"
|
||||
X "$indent`t</xs:simpleType>"
|
||||
}
|
||||
X "$indent</xs:element>"
|
||||
} else {
|
||||
X "$indent<xs:element$body$m/>"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Complex type body (objectType / typeDef xsi:type=ObjectType) ---
|
||||
|
||||
function Emit-ComplexTypeBody([System.Xml.XmlElement]$el, [string]$indent) {
|
||||
$open = A $el "open"
|
||||
$ordered = A $el "ordered"
|
||||
$sequenced = A $el "sequenced"
|
||||
|
||||
$props = @()
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "property") { $props += $c }
|
||||
}
|
||||
|
||||
$elems = @(); $attrs = @(); $text = $null
|
||||
foreach ($p in $props) {
|
||||
switch (Get-PropForm $p) {
|
||||
"Attribute" { $attrs += $p }
|
||||
"Text" { $text = $p }
|
||||
default { $elems += $p }
|
||||
}
|
||||
}
|
||||
|
||||
# simpleContent: a "Text" property holds the element's own value
|
||||
if ($null -ne $text) {
|
||||
$tType = Convert-QName $text (A $text "type")
|
||||
$tm = ""
|
||||
$tName = A $text "name"
|
||||
if ($tName -ne "__content") { $tm += Mirror "textName" $tName }
|
||||
foreach ($extra in @("lowerBound", "upperBound", "nillable")) {
|
||||
$v = A $text $extra
|
||||
if ($null -ne $v) { $tm += Mirror "text$extra" $v }
|
||||
}
|
||||
X "$indent<xs:simpleContent>"
|
||||
X "$indent`t<xs:extension$(Attrs @('base', $tType))$tm>"
|
||||
foreach ($a in $attrs) { Emit-Property $a "$indent`t`t" $false }
|
||||
X "$indent`t</xs:extension>"
|
||||
X "$indent</xs:simpleContent>"
|
||||
return
|
||||
}
|
||||
|
||||
$particleTag = if ($ordered -eq "false") { "xs:choice" } else { "xs:sequence" }
|
||||
$needParticle = ($elems.Count -gt 0) -or ($open -eq "true")
|
||||
|
||||
if ($needParticle) {
|
||||
X "$indent<$particleTag>"
|
||||
foreach ($e in $elems) { Emit-Property $e "$indent`t" $false }
|
||||
if ($open -eq "true") {
|
||||
X "$indent`t<xs:any namespace=`"##any`" processContents=`"lax`" minOccurs=`"0`" maxOccurs=`"unbounded`"/>"
|
||||
}
|
||||
X "$indent</$particleTag>"
|
||||
}
|
||||
|
||||
foreach ($a in $attrs) { Emit-Property $a "$indent" $false }
|
||||
if ($open -eq "true") {
|
||||
X "$indent<xs:anyAttribute namespace=`"##any`" processContents=`"lax`"/>"
|
||||
}
|
||||
}
|
||||
|
||||
# Attributes of a complexType tag itself (mirrors + XSD-native abstract/mixed)
|
||||
function ComplexTypeAttrs([System.Xml.XmlElement]$el) {
|
||||
$open = A $el "open"
|
||||
$ordered = A $el "ordered"
|
||||
$sequenced = A $el "sequenced"
|
||||
$abstract = A $el "abstract"
|
||||
$mixed = A $el "mixed"
|
||||
|
||||
$out = ""
|
||||
if ($abstract -eq "true") { $out += " abstract=`"true`"" } elseif ($null -ne $abstract) { $out += Mirror "abstract" $abstract }
|
||||
if ($mixed -eq "true") { $out += " mixed=`"true`"" } elseif ($null -ne $mixed) { $out += Mirror "mixed" $mixed }
|
||||
|
||||
# XSD требует объявлять атрибуты после частицы, поэтому исходный порядок свойств
|
||||
# восстановим как «сначала form=Attribute, потом остальные» — это верно для 96.5%
|
||||
# типов корпуса. Расхождения (768 типов) зеркалим списком имён.
|
||||
$order = @(); $kinds = @()
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -ne [System.Xml.XmlNodeType]::Element -or $c.get_LocalName() -ne "property") { continue }
|
||||
$nm = A $c "name"
|
||||
if ($null -eq $nm) { $nm = "@" + ((A $c "ref") -split ":")[-1] }
|
||||
$order += $nm
|
||||
$kinds += $(if ((Get-PropForm $c) -eq "Attribute") { 0 } else { 1 })
|
||||
}
|
||||
if ($order.Count -gt 1) {
|
||||
$natural = $true
|
||||
for ($i = 1; $i -lt $kinds.Count; $i++) { if ($kinds[$i] -lt $kinds[$i - 1]) { $natural = $false; break } }
|
||||
if (-not $natural) { $out += Mirror "order" ($order -join "|") }
|
||||
}
|
||||
|
||||
# open="true" is rendered as xs:any + xs:anyAttribute; anything else is mirrored
|
||||
if ($null -ne $open -and $open -ne "true") { $out += Mirror "open" $open }
|
||||
# ordered="false" is rendered as xs:choice; "true" written explicitly is mirrored
|
||||
if ($null -ne $ordered -and $ordered -ne "false") { $out += Mirror "ordered" $ordered }
|
||||
# sequenced has no XSD counterpart at all
|
||||
if ($null -ne $sequenced) { $out += Mirror "sequenced" $sequenced }
|
||||
|
||||
return $out
|
||||
}
|
||||
|
||||
# --- Metadata properties (Name/Synonym/Comment) from the object's .xml ---
|
||||
|
||||
function Get-MetadataBlock() {
|
||||
if (-not $paths.Md -or -not (Test-Path $paths.Md)) { return $null }
|
||||
$md = New-Object System.Xml.XmlDocument
|
||||
$md.Load($paths.Md)
|
||||
$nsm = New-Object System.Xml.XmlNamespaceManager($md.NameTable)
|
||||
$nsm.AddNamespace("md", $MD_NS)
|
||||
$nsm.AddNamespace("v8", $V8_NS)
|
||||
$props = $md.SelectSingleNode("//md:XDTOPackage/md:Properties", $nsm)
|
||||
if (-not $props) { return $null }
|
||||
|
||||
$res = @{ Name = $null; Comment = $null; Synonym = @() }
|
||||
$n = $props.SelectSingleNode("md:Name", $nsm); if ($n) { $res.Name = $n.InnerText }
|
||||
$c = $props.SelectSingleNode("md:Comment", $nsm); if ($c) { $res.Comment = $c.InnerText }
|
||||
foreach ($item in $props.SelectNodes("md:Synonym/v8:item", $nsm)) {
|
||||
$lang = $item.SelectSingleNode("v8:lang", $nsm)
|
||||
$cont = $item.SelectSingleNode("v8:content", $nsm)
|
||||
$res.Synonym += @{ Lang = $(if ($lang) { $lang.InnerText } else { "" }); Content = $(if ($cont) { $cont.InnerText } else { "" }) }
|
||||
}
|
||||
return $res
|
||||
}
|
||||
|
||||
$meta = Get-MetadataBlock
|
||||
|
||||
# --- Emit ---
|
||||
# Body first: emitting it registers every namespace actually referenced, so the
|
||||
# schema element can declare a complete prefix map.
|
||||
|
||||
$bodyBuilder = New-Object System.Text.StringBuilder
|
||||
$mainBuilder = $sb
|
||||
$sb = $bodyBuilder
|
||||
|
||||
foreach ($node in $pkg.ChildNodes) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
switch ($node.get_LocalName()) {
|
||||
"import" {
|
||||
X "`t<xs:import namespace=`"$(Esc $node.GetAttribute('namespace'))`"/>"
|
||||
}
|
||||
"property" {
|
||||
Emit-Property $node "`t" $true
|
||||
}
|
||||
"valueType" {
|
||||
$name = A $node "name"
|
||||
X "`t<xs:simpleType$(Attrs @('name', $name))>"
|
||||
Emit-SimpleTypeBody $node "`t`t"
|
||||
X "`t</xs:simpleType>"
|
||||
}
|
||||
"objectType" {
|
||||
$name = A $node "name"
|
||||
$base = Convert-QName $node (A $node "base")
|
||||
$cta = ComplexTypeAttrs $node
|
||||
if ($base) {
|
||||
X "`t<xs:complexType$(Attrs @('name', $name))$cta>"
|
||||
X "`t`t<xs:complexContent>"
|
||||
X "`t`t`t<xs:extension$(Attrs @('base', $base))>"
|
||||
Emit-ComplexTypeBody $node "`t`t`t`t"
|
||||
X "`t`t`t</xs:extension>"
|
||||
X "`t`t</xs:complexContent>"
|
||||
X "`t</xs:complexType>"
|
||||
} else {
|
||||
$hasBody = $false
|
||||
foreach ($c in $node.ChildNodes) { if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) { $hasBody = $true; break } }
|
||||
if (-not $hasBody -and (A $node "open") -ne "true") {
|
||||
X "`t<xs:complexType$(Attrs @('name', $name))$cta/>"
|
||||
} else {
|
||||
X "`t<xs:complexType$(Attrs @('name', $name))$cta>"
|
||||
Emit-ComplexTypeBody $node "`t`t"
|
||||
X "`t</xs:complexType>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sb = $mainBuilder
|
||||
|
||||
# --- Schema element ---
|
||||
|
||||
$nsDecls = ""
|
||||
foreach ($kv in ($nsPrefix.GetEnumerator() | Sort-Object { $_.Value })) {
|
||||
$nsDecls += " xmlns:$($kv.Value)=`"$(Esc $kv.Key)`""
|
||||
}
|
||||
if ($usesXdtoNs) { $nsDecls += " xmlns:xdto=`"$XDTO_NS`"" }
|
||||
|
||||
$schemaAttrs = ""
|
||||
$efq = A $pkg "elementFormQualified"
|
||||
$afq = A $pkg "attributeFormQualified"
|
||||
if ($null -ne $efq) { $schemaAttrs += " elementFormDefault=`"$(if ($efq -eq 'true') { 'qualified' } else { 'unqualified' })`"" }
|
||||
if ($null -ne $afq) { $schemaAttrs += " attributeFormDefault=`"$(if ($afq -eq 'true') { 'qualified' } else { 'unqualified' })`"" }
|
||||
|
||||
X "<xs:schema$nsDecls$(Attrs @('targetNamespace', $targetNs))$schemaAttrs>"
|
||||
|
||||
if ($meta) {
|
||||
X "`t<xs:annotation>"
|
||||
X "`t`t<xs:appinfo>"
|
||||
X "`t`t`t<xdto:package xmlns:xdto=`"$XDTO_NS`">"
|
||||
if ($null -ne $meta.Name) { X "`t`t`t`t<xdto:name>$(EscText $meta.Name)</xdto:name>" }
|
||||
if ($null -ne $meta.Comment -and $meta.Comment -ne "") { X "`t`t`t`t<xdto:comment>$(EscText $meta.Comment)</xdto:comment>" }
|
||||
foreach ($s in $meta.Synonym) {
|
||||
X "`t`t`t`t<xdto:synonym lang=`"$(Esc $s.Lang)`">$(EscText $s.Content)</xdto:synonym>"
|
||||
}
|
||||
X "`t`t`t</xdto:package>"
|
||||
X "`t`t</xs:appinfo>"
|
||||
X "`t</xs:annotation>"
|
||||
}
|
||||
|
||||
[void]$sb.Append($bodyBuilder.ToString())
|
||||
X "</xs:schema>"
|
||||
|
||||
# --- Output (UTF-8 with BOM, CRLF — matches the Designer's own XSD export) ---
|
||||
|
||||
$text = $sb.ToString()
|
||||
if ($OutFile) {
|
||||
$dir = [System.IO.Path]::GetDirectoryName($OutFile)
|
||||
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($OutFile, $text, $enc)
|
||||
Write-Host "✓ XSD записана: $OutFile"
|
||||
Write-Host " targetNamespace: $targetNs"
|
||||
} else {
|
||||
[Console]::Out.Write($text)
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
# xdto-decompile v1.0 — Convert 1C XDTO package to XML Schema (XSD) (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# newline="" — иначе Windows транслирует \n и CRLF схемы удваивается в \r\r\n
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-PackagePath", "-Path", required=True)
|
||||
parser.add_argument("-OutFile", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def _parse_xml(source, from_string=False):
|
||||
"""Разбор с узким отступлением для не-URI пространств имён.
|
||||
|
||||
Платформа допускает в targetNamespace произвольную строку (в выгрузке БП есть
|
||||
пакет с кириллическим «ДопФайлУниверсальный»), .NET такое принимает, а libxml2
|
||||
отвергает. Откатываемся на восстанавливающий разбор ТОЛЬКО на этой ошибке,
|
||||
иначе по-настоящему битый XML перестал бы отличаться от корректного.
|
||||
"""
|
||||
try:
|
||||
return (etree.fromstring(source) if from_string else etree.parse(source))
|
||||
except etree.XMLSyntaxError as e:
|
||||
if "is not a valid URI" not in str(e):
|
||||
raise
|
||||
p = etree.XMLParser(recover=True)
|
||||
return (etree.fromstring(source, p) if from_string else etree.parse(source, p))
|
||||
|
||||
# ── resolve paths ────────────────────────────────────────────
|
||||
|
||||
package_path = args.PackagePath
|
||||
bin_path = None
|
||||
md_path = None
|
||||
|
||||
if os.path.isfile(package_path):
|
||||
if os.path.basename(package_path) == "Package.bin":
|
||||
bin_path = package_path
|
||||
pkg_dir = os.path.dirname(os.path.dirname(package_path))
|
||||
if os.path.exists(pkg_dir + ".xml"):
|
||||
md_path = pkg_dir + ".xml"
|
||||
elif package_path.endswith(".xml"):
|
||||
md_path = package_path
|
||||
stem = os.path.join(os.path.dirname(package_path),
|
||||
os.path.splitext(os.path.basename(package_path))[0])
|
||||
c = os.path.join(stem, "Ext", "Package.bin")
|
||||
if os.path.exists(c):
|
||||
bin_path = c
|
||||
elif os.path.isdir(package_path):
|
||||
c = os.path.join(package_path, "Ext", "Package.bin")
|
||||
if os.path.exists(c):
|
||||
bin_path = c
|
||||
m = package_path.rstrip("\\/") + ".xml"
|
||||
if os.path.exists(m):
|
||||
md_path = m
|
||||
|
||||
if not bin_path:
|
||||
print(f"Не найден Ext/Package.bin для пути: {package_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
doc = _parse_xml(bin_path)
|
||||
pkg = doc.getroot()
|
||||
|
||||
|
||||
def local(el):
|
||||
return etree.QName(el).localname
|
||||
|
||||
|
||||
if local(pkg) != "package":
|
||||
print(f"Ожидался корневой <package>, получен <{local(pkg)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
target_ns = pkg.get("targetNamespace")
|
||||
|
||||
# ── namespace -> prefix map for the emitted schema ───────────
|
||||
|
||||
ns_prefix = {XS_NS: "xs"}
|
||||
if target_ns:
|
||||
ns_prefix[target_ns] = "tns"
|
||||
|
||||
imports = []
|
||||
for imp in pkg:
|
||||
if isinstance(imp.tag, str) and local(imp) == "import":
|
||||
ns = imp.get("namespace")
|
||||
imports.append(ns)
|
||||
if ns not in ns_prefix:
|
||||
ns_prefix[ns] = "ns" + str(len(ns_prefix))
|
||||
|
||||
|
||||
def register_ns(ns):
|
||||
if ns and ns not in ns_prefix:
|
||||
ns_prefix[ns] = "ns" + str(len(ns_prefix))
|
||||
|
||||
|
||||
# ── output buffer ────────────────────────────────────────────
|
||||
|
||||
lines = []
|
||||
|
||||
|
||||
def X(line):
|
||||
lines.append(line)
|
||||
|
||||
|
||||
def esc(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def esc_text(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# ── QName conversion: bin prefix -> schema prefix ────────────
|
||||
|
||||
def convert_qname(el, qname):
|
||||
if not qname:
|
||||
return None
|
||||
# Нотация Кларка {ns}local — так записаны почти все memberTypes
|
||||
if qname.startswith("{"):
|
||||
close = qname.find("}")
|
||||
if close > 0:
|
||||
ns = qname[1:close]
|
||||
loc = qname[close + 1:]
|
||||
if not ns:
|
||||
return loc
|
||||
register_ns(ns)
|
||||
return f"{ns_prefix[ns]}:{loc}"
|
||||
parts = qname.split(":")
|
||||
if len(parts) == 2:
|
||||
ns = el.nsmap.get(parts[0])
|
||||
loc = parts[1]
|
||||
else:
|
||||
ns = el.nsmap.get(None)
|
||||
loc = parts[0]
|
||||
if not ns:
|
||||
return qname
|
||||
register_ns(ns)
|
||||
return f"{ns_prefix[ns]}:{loc}"
|
||||
|
||||
|
||||
def convert_qname_list(el, lst):
|
||||
if not lst:
|
||||
return None
|
||||
return " ".join(convert_qname(el, q) for q in lst.split() if q)
|
||||
|
||||
|
||||
def attrs(pairs):
|
||||
out = ""
|
||||
for i in range(0, len(pairs), 2):
|
||||
v = pairs[i + 1]
|
||||
if v is not None:
|
||||
out += f' {pairs[i]}="{esc(v)}"'
|
||||
return out
|
||||
|
||||
|
||||
# ── xdto: mirror attributes ──────────────────────────────────
|
||||
|
||||
state = {"uses_xdto": False}
|
||||
|
||||
|
||||
def mirror(name, value):
|
||||
if value is None:
|
||||
return ""
|
||||
state["uses_xdto"] = True
|
||||
return f' xdto:{name}="{esc(value)}"'
|
||||
|
||||
|
||||
DNPM = re.compile(r"^d\d+p\d+$")
|
||||
|
||||
|
||||
def ns_decls_of(el):
|
||||
"""Локальные объявления xmlns на самом узле, как (префикс, uri)."""
|
||||
parent_map = el.getparent().nsmap if el.getparent() is not None else {}
|
||||
for px, uri in el.nsmap.items():
|
||||
if px is None:
|
||||
continue
|
||||
if parent_map.get(px) == uri:
|
||||
continue
|
||||
yield px, uri
|
||||
|
||||
|
||||
def mirror_prefix(el):
|
||||
# Обычно префиксы генерируются схемой dNpM, но изредка узел несёт осмысленный
|
||||
# префикс (например dcsset) — его надо сохранить, иначе round-trip не сойдётся.
|
||||
for px, _uri in ns_decls_of(el):
|
||||
if DNPM.match(px):
|
||||
continue
|
||||
return mirror("prefix", px)
|
||||
return ""
|
||||
|
||||
|
||||
FACET_ATTRS = ["length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive", "whiteSpace"]
|
||||
|
||||
|
||||
def emit_facets(el, indent):
|
||||
for f in FACET_ATTRS:
|
||||
v = el.get(f)
|
||||
if v is not None:
|
||||
X(f'{indent}<xs:{f} value="{esc(v)}"/>')
|
||||
for child in el:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
ln = local(child)
|
||||
if ln == "pattern":
|
||||
X(f'{indent}<xs:pattern value="{esc(child.text or "")}"/>')
|
||||
elif ln == "enumeration":
|
||||
xsi_type = child.get(f"{{{XSI_NS}}}type")
|
||||
m = mirror("type", convert_qname(child, xsi_type)) if xsi_type else ""
|
||||
X(f'{indent}<xs:enumeration value="{esc(child.text or "")}"{m}/>')
|
||||
|
||||
|
||||
def has_simple_content(el):
|
||||
for c in el:
|
||||
if isinstance(c.tag, str) and local(c) in ("pattern", "enumeration"):
|
||||
return True
|
||||
return any(el.get(f) is not None for f in FACET_ATTRS)
|
||||
|
||||
|
||||
# ── simple type body (valueType / typeDef xsi:type=ValueType) ─
|
||||
|
||||
def emit_simple_type_body(el, indent):
|
||||
variety = el.get("variety")
|
||||
base = convert_qname(el, el.get("base"))
|
||||
item_type = convert_qname(el, el.get("itemType"))
|
||||
member_types = convert_qname_list(el, el.get("memberTypes"))
|
||||
|
||||
mv = mirror("variety", variety)
|
||||
|
||||
raw_members = el.get("memberTypes")
|
||||
if raw_members is not None and not raw_members.startswith("{"):
|
||||
mv += mirror("memberTypesForm", "prefixed")
|
||||
# При нотации Кларка объявление xmlns:dNpM иногда присутствует, иногда нет —
|
||||
# из значения это не выводится (зависит от состояния сериализатора), зеркалим факт
|
||||
if raw_members is not None and raw_members.startswith("{"):
|
||||
for px, uri in ns_decls_of(el):
|
||||
if DNPM.match(px):
|
||||
mv += mirror("declareNs", uri)
|
||||
break
|
||||
|
||||
if variety == "List" or item_type:
|
||||
X(f'{indent}<xs:list{attrs(["itemType", item_type])}{mv}/>')
|
||||
return
|
||||
if variety == "Union" or member_types:
|
||||
anon = [c for c in el if isinstance(c.tag, str) and local(c) == "typeDef"]
|
||||
if not anon:
|
||||
X(f'{indent}<xs:union{attrs(["memberTypes", member_types])}{mv}/>')
|
||||
else:
|
||||
X(f'{indent}<xs:union{attrs(["memberTypes", member_types])}{mv}>')
|
||||
for c in anon:
|
||||
X(f"{indent}\t<xs:simpleType>")
|
||||
emit_simple_type_body(c, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:simpleType>")
|
||||
X(f"{indent}</xs:union>")
|
||||
return
|
||||
|
||||
# Базовый тип может быть задан не атрибутом base, а вложенным анонимным typeDef
|
||||
anon_base = next((c for c in el if isinstance(c.tag, str) and local(c) == "typeDef"), None)
|
||||
|
||||
if has_simple_content(el) or anon_base is not None:
|
||||
X(f'{indent}<xs:restriction{attrs(["base", base])}{mv}>')
|
||||
if anon_base is not None:
|
||||
X(f"{indent}\t<xs:simpleType>")
|
||||
emit_simple_type_body(anon_base, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:simpleType>")
|
||||
emit_facets(el, indent + "\t")
|
||||
X(f"{indent}</xs:restriction>")
|
||||
else:
|
||||
X(f'{indent}<xs:restriction{attrs(["base", base])}{mv}/>')
|
||||
|
||||
|
||||
# ── property classification ──────────────────────────────────
|
||||
|
||||
def prop_form(p):
|
||||
f = p.get("form")
|
||||
return "Element" if f is None else f
|
||||
|
||||
|
||||
def anon_type_def(p):
|
||||
return next((c for c in p if isinstance(c.tag, str) and local(c) == "typeDef"), None)
|
||||
|
||||
|
||||
# ── property emission ────────────────────────────────────────
|
||||
|
||||
def emit_property(p, indent):
|
||||
form = prop_form(p)
|
||||
name = p.get("name")
|
||||
type_ = convert_qname(p, p.get("type"))
|
||||
ref = convert_qname(p, p.get("ref"))
|
||||
local_name = p.get("localName")
|
||||
lower = p.get("lowerBound")
|
||||
upper = p.get("upperBound")
|
||||
nill = p.get("nillable")
|
||||
default = p.get("default")
|
||||
fixed = p.get("fixed")
|
||||
# В модели fixed — булев флаг, значение лежит в default; в XSD наоборот:
|
||||
# fixed="V" несёт само значение. Переводим, а не копируем.
|
||||
def_out, fix_out, fix_mirror = default, None, ""
|
||||
if fixed == "true" and default is not None:
|
||||
fix_out, def_out = default, None
|
||||
elif fixed is not None:
|
||||
fix_mirror = mirror("fixed", fixed)
|
||||
anon = anon_type_def(p)
|
||||
qual = p.get(f"{{{XDTO_NS}}}qualified")
|
||||
|
||||
min_occurs = lower
|
||||
max_occurs = None
|
||||
if upper is not None:
|
||||
max_occurs = "unbounded" if upper == "-1" else upper
|
||||
|
||||
xml_name = local_name if local_name is not None else name
|
||||
mirror_name = mirror("name", name) if local_name is not None else ""
|
||||
|
||||
m = ""
|
||||
if form == "Attribute":
|
||||
if qual is not None:
|
||||
m += mirror("qualified", qual)
|
||||
if nill is not None:
|
||||
m += mirror("nillable", nill)
|
||||
if lower is not None:
|
||||
m += mirror("lowerBound", lower)
|
||||
if upper is not None:
|
||||
m += mirror("upperBound", upper)
|
||||
m += mirror_name
|
||||
m += fix_mirror
|
||||
body = attrs(["name", xml_name, "ref", ref, "type", type_, "default", def_out, "fixed", fix_out])
|
||||
if anon is not None:
|
||||
X(f"{indent}<xs:attribute{body}{m}>")
|
||||
X(f"{indent}\t<xs:simpleType>")
|
||||
emit_simple_type_body(anon, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:simpleType>")
|
||||
X(f"{indent}</xs:attribute>")
|
||||
else:
|
||||
X(f"{indent}<xs:attribute{body}{m}/>")
|
||||
return
|
||||
|
||||
if form == "Text":
|
||||
# handled by the owning complexType (xs:simpleContent)
|
||||
return
|
||||
|
||||
if p.get("form") is not None:
|
||||
m += mirror("form", form)
|
||||
if qual is not None:
|
||||
m += mirror("qualified", qual)
|
||||
m += mirror_name
|
||||
m += mirror_prefix(p)
|
||||
m += fix_mirror
|
||||
|
||||
body = attrs(["name", xml_name, "ref", ref, "type", type_,
|
||||
"minOccurs", min_occurs, "maxOccurs", max_occurs,
|
||||
"nillable", nill, "default", def_out, "fixed", fix_out])
|
||||
|
||||
if anon is not None:
|
||||
X(f"{indent}<xs:element{body}{m}>")
|
||||
if anon.get(f"{{{XSI_NS}}}type") == "ObjectType":
|
||||
anon_base = convert_qname(anon, anon.get("base"))
|
||||
if anon_base:
|
||||
X(f"{indent}\t<xs:complexType{complex_type_attrs(anon)}>")
|
||||
X(f"{indent}\t\t<xs:complexContent>")
|
||||
X(f'{indent}\t\t\t<xs:extension{attrs(["base", anon_base])}>')
|
||||
emit_complex_type_body(anon, indent + "\t\t\t\t")
|
||||
X(f"{indent}\t\t\t</xs:extension>")
|
||||
X(f"{indent}\t\t</xs:complexContent>")
|
||||
X(f"{indent}\t</xs:complexType>")
|
||||
else:
|
||||
X(f"{indent}\t<xs:complexType{complex_type_attrs(anon)}>")
|
||||
emit_complex_type_body(anon, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:complexType>")
|
||||
else:
|
||||
X(f"{indent}\t<xs:simpleType>")
|
||||
emit_simple_type_body(anon, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:simpleType>")
|
||||
X(f"{indent}</xs:element>")
|
||||
else:
|
||||
X(f"{indent}<xs:element{body}{m}/>")
|
||||
|
||||
|
||||
# ── complex type body ────────────────────────────────────────
|
||||
|
||||
def emit_complex_type_body(el, indent):
|
||||
open_ = el.get("open")
|
||||
ordered = el.get("ordered")
|
||||
|
||||
props = [c for c in el if isinstance(c.tag, str) and local(c) == "property"]
|
||||
elems, attr_props, text = [], [], None
|
||||
for p in props:
|
||||
f = prop_form(p)
|
||||
if f == "Attribute":
|
||||
attr_props.append(p)
|
||||
elif f == "Text":
|
||||
text = p
|
||||
else:
|
||||
elems.append(p)
|
||||
|
||||
if text is not None:
|
||||
t_type = convert_qname(text, text.get("type"))
|
||||
tm = ""
|
||||
t_name = text.get("name")
|
||||
if t_name != "__content":
|
||||
tm += mirror("textName", t_name)
|
||||
for extra in ("lowerBound", "upperBound", "nillable"):
|
||||
v = text.get(extra)
|
||||
if v is not None:
|
||||
tm += mirror("text" + extra, v)
|
||||
X(f"{indent}<xs:simpleContent>")
|
||||
X(f'{indent}\t<xs:extension{attrs(["base", t_type])}{tm}>')
|
||||
for a in attr_props:
|
||||
emit_property(a, indent + "\t\t")
|
||||
X(f"{indent}\t</xs:extension>")
|
||||
X(f"{indent}</xs:simpleContent>")
|
||||
return
|
||||
|
||||
particle_tag = "xs:choice" if ordered == "false" else "xs:sequence"
|
||||
if elems or open_ == "true":
|
||||
X(f"{indent}<{particle_tag}>")
|
||||
for e in elems:
|
||||
emit_property(e, indent + "\t")
|
||||
if open_ == "true":
|
||||
X(f'{indent}\t<xs:any namespace="##any" processContents="lax" minOccurs="0" maxOccurs="unbounded"/>')
|
||||
X(f"{indent}</{particle_tag}>")
|
||||
|
||||
for a in attr_props:
|
||||
emit_property(a, indent)
|
||||
if open_ == "true":
|
||||
X(f'{indent}<xs:anyAttribute namespace="##any" processContents="lax"/>')
|
||||
|
||||
|
||||
def complex_type_attrs(el):
|
||||
open_ = el.get("open")
|
||||
ordered = el.get("ordered")
|
||||
sequenced = el.get("sequenced")
|
||||
abstract = el.get("abstract")
|
||||
mixed = el.get("mixed")
|
||||
|
||||
out = ""
|
||||
if abstract == "true":
|
||||
out += ' abstract="true"'
|
||||
elif abstract is not None:
|
||||
out += mirror("abstract", abstract)
|
||||
if mixed == "true":
|
||||
out += ' mixed="true"'
|
||||
elif mixed is not None:
|
||||
out += mirror("mixed", mixed)
|
||||
|
||||
# XSD требует объявлять атрибуты после частицы, поэтому исходный порядок свойств
|
||||
# восстановим как «сначала form=Attribute, потом остальные» — верно для 96.5% типов
|
||||
order, kinds = [], []
|
||||
for c in el:
|
||||
if not isinstance(c.tag, str) or local(c) != "property":
|
||||
continue
|
||||
nm = c.get("name")
|
||||
if nm is None:
|
||||
nm = "@" + (c.get("ref") or "").split(":")[-1]
|
||||
order.append(nm)
|
||||
kinds.append(0 if prop_form(c) == "Attribute" else 1)
|
||||
if len(order) > 1:
|
||||
natural = all(kinds[i] >= kinds[i - 1] for i in range(1, len(kinds)))
|
||||
if not natural:
|
||||
out += mirror("order", "|".join(order))
|
||||
|
||||
if open_ is not None and open_ != "true":
|
||||
out += mirror("open", open_)
|
||||
if ordered is not None and ordered != "false":
|
||||
out += mirror("ordered", ordered)
|
||||
if sequenced is not None:
|
||||
out += mirror("sequenced", sequenced)
|
||||
return out
|
||||
|
||||
|
||||
# ── metadata properties ──────────────────────────────────────
|
||||
|
||||
meta = None
|
||||
if md_path and os.path.exists(md_path):
|
||||
md = _parse_xml(md_path)
|
||||
props_el = md.find(f".//{{{MD_NS}}}XDTOPackage/{{{MD_NS}}}Properties")
|
||||
if props_el is not None:
|
||||
meta = {"Name": None, "Comment": None, "Synonym": []}
|
||||
n = props_el.find(f"{{{MD_NS}}}Name")
|
||||
if n is not None:
|
||||
meta["Name"] = n.text or ""
|
||||
c = props_el.find(f"{{{MD_NS}}}Comment")
|
||||
if c is not None:
|
||||
meta["Comment"] = c.text or ""
|
||||
for item in props_el.iterfind(f"{{{MD_NS}}}Synonym/{{{V8_NS}}}item"):
|
||||
lang = item.find(f"{{{V8_NS}}}lang")
|
||||
cont = item.find(f"{{{V8_NS}}}content")
|
||||
meta["Synonym"].append({
|
||||
"Lang": (lang.text or "") if lang is not None else "",
|
||||
"Content": (cont.text or "") if cont is not None else "",
|
||||
})
|
||||
|
||||
# ── emit ─────────────────────────────────────────────────────
|
||||
# Тело первым: при его генерации регистрируются все использованные пространства
|
||||
# имён, поэтому корневой элемент может объявить полную карту префиксов.
|
||||
|
||||
for node in pkg:
|
||||
if not isinstance(node.tag, str):
|
||||
continue
|
||||
ln = local(node)
|
||||
if ln == "import":
|
||||
X(f'\t<xs:import namespace="{esc(node.get("namespace"))}"/>')
|
||||
elif ln == "property":
|
||||
emit_property(node, "\t")
|
||||
elif ln == "valueType":
|
||||
X(f'\t<xs:simpleType{attrs(["name", node.get("name")])}>')
|
||||
emit_simple_type_body(node, "\t\t")
|
||||
X("\t</xs:simpleType>")
|
||||
elif ln == "objectType":
|
||||
name = node.get("name")
|
||||
base = convert_qname(node, node.get("base"))
|
||||
cta = complex_type_attrs(node)
|
||||
if base:
|
||||
X(f'\t<xs:complexType{attrs(["name", name])}{cta}>')
|
||||
X("\t\t<xs:complexContent>")
|
||||
X(f'\t\t\t<xs:extension{attrs(["base", base])}>')
|
||||
emit_complex_type_body(node, "\t\t\t\t")
|
||||
X("\t\t\t</xs:extension>")
|
||||
X("\t\t</xs:complexContent>")
|
||||
X("\t</xs:complexType>")
|
||||
else:
|
||||
has_body = any(isinstance(c.tag, str) for c in node)
|
||||
if not has_body and node.get("open") != "true":
|
||||
X(f'\t<xs:complexType{attrs(["name", name])}{cta}/>')
|
||||
else:
|
||||
X(f'\t<xs:complexType{attrs(["name", name])}{cta}>')
|
||||
emit_complex_type_body(node, "\t\t")
|
||||
X("\t</xs:complexType>")
|
||||
|
||||
body_lines = lines
|
||||
lines = []
|
||||
|
||||
# ── schema element ───────────────────────────────────────────
|
||||
|
||||
ns_decls = ""
|
||||
for uri, px in sorted(ns_prefix.items(), key=lambda kv: kv[1]):
|
||||
ns_decls += f' xmlns:{px}="{esc(uri)}"'
|
||||
if state["uses_xdto"]:
|
||||
ns_decls += f' xmlns:xdto="{XDTO_NS}"'
|
||||
|
||||
schema_attrs = ""
|
||||
efq = pkg.get("elementFormQualified")
|
||||
afq = pkg.get("attributeFormQualified")
|
||||
if efq is not None:
|
||||
schema_attrs += f' elementFormDefault="{"qualified" if efq == "true" else "unqualified"}"'
|
||||
if afq is not None:
|
||||
schema_attrs += f' attributeFormDefault="{"qualified" if afq == "true" else "unqualified"}"'
|
||||
|
||||
X(f'<xs:schema{ns_decls}{attrs(["targetNamespace", target_ns])}{schema_attrs}>')
|
||||
|
||||
if meta:
|
||||
X("\t<xs:annotation>")
|
||||
X("\t\t<xs:appinfo>")
|
||||
X(f'\t\t\t<xdto:package xmlns:xdto="{XDTO_NS}">')
|
||||
if meta["Name"] is not None:
|
||||
X(f'\t\t\t\t<xdto:name>{esc_text(meta["Name"])}</xdto:name>')
|
||||
if meta["Comment"]:
|
||||
X(f'\t\t\t\t<xdto:comment>{esc_text(meta["Comment"])}</xdto:comment>')
|
||||
for s in meta["Synonym"]:
|
||||
X(f'\t\t\t\t<xdto:synonym lang="{esc(s["Lang"])}">{esc_text(s["Content"])}</xdto:synonym>')
|
||||
X("\t\t\t</xdto:package>")
|
||||
X("\t\t</xs:appinfo>")
|
||||
X("\t</xs:annotation>")
|
||||
|
||||
lines.extend(body_lines)
|
||||
X("</xs:schema>")
|
||||
|
||||
text = "\r\n".join(lines) + "\r\n"
|
||||
|
||||
if args.OutFile:
|
||||
d = os.path.dirname(args.OutFile)
|
||||
if d and not os.path.isdir(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(args.OutFile, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbf" + text.encode("utf-8"))
|
||||
print(f"✓ XSD записана: {args.OutFile}")
|
||||
print(f" targetNamespace: {target_ns}")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
name: xdto-edit
|
||||
description: Точечное редактирование пакета XDTO 1С. Используй когда нужно добавить, изменить или удалить тип или свойство в существующем пакете, переименовать пакет, сменить пространство имён
|
||||
argument-hint: <PackagePath> -Operation <операция> [-Target <путь>] [-Value <значение>] [-NoValidate]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /xdto-edit — Точечное редактирование пакета XDTO
|
||||
|
||||
Меняет один элемент пакета, не требуя читать и переписывать всю схему — для больших
|
||||
пакетов (`EnterpriseData` — около мегабайта) это единственный практичный путь.
|
||||
|
||||
Если нужно переработать схему целиком или сперва разобраться, как она устроена, —
|
||||
`/xdto-decompile` → правка XSD → `/xdto-compile -Force`.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `PackagePath` | да | Каталог пакета, `Ext/Package.bin` или `<Имя>.xml`. Псевдоним — `-Path` |
|
||||
| `Operation` | да | Операция из таблицы ниже |
|
||||
| `Target` | зависит | Адрес: имя типа или путь `Тип.Свойство` |
|
||||
| `Value` | зависит | Фрагмент XSD, литерал, URI или текст. `@путь` — взять содержимое из файла |
|
||||
| `NoValidate` | нет | Не запускать `xdto-validate` после правки |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/xdto-edit/scripts/xdto-edit.py" -PackagePath "<путь>" -Operation <op> -Target "<адрес>" -Value "<значение>"
|
||||
```
|
||||
|
||||
## Операции
|
||||
|
||||
| Операция | `-Target` | `-Value` |
|
||||
|---|---|---|
|
||||
| `add-property` | имя типа | `<xs:element>` или `<xs:attribute>` |
|
||||
| `replace-property` | `Тип.Свойство` | новое объявление целиком |
|
||||
| `remove-property` | `Тип.Свойство` | — |
|
||||
| `add-type` | — | `<xs:complexType>` или `<xs:simpleType>` |
|
||||
| `remove-type` | имя типа | — |
|
||||
| `add-enum` | имя типа значения | литерал |
|
||||
| `add-import` | — | URI пространства имён |
|
||||
| `rename` | — | новое имя объекта метаданных |
|
||||
| `set-synonym` | — | синоним |
|
||||
| `set-comment` | — | комментарий |
|
||||
| `set-namespace` | — | новый URI пространства имён |
|
||||
|
||||
Батч через `;;` там, где перечисление осмысленно: `remove-property`, `remove-type`,
|
||||
`add-enum`, `add-import`.
|
||||
|
||||
```powershell
|
||||
... -Operation add-property -Target "Платёж" -Value '<xs:element name="Комментарий" type="xs:string" minOccurs="0"/>'
|
||||
... -Operation remove-property -Target "Платёж.Комментарий ;; Платёж.Черновик"
|
||||
... -Operation add-enum -Target "ВидДокумента" -Value "Инкассо ;; Аккредитив"
|
||||
... -Operation rename -Value ОбменСБанком
|
||||
```
|
||||
|
||||
Содержимое всегда описывается фрагментом XML-схемы — тем же языком, что и в
|
||||
`/xdto-compile`. Отдельных параметров вида `-MinOccurs` нет: чтобы поменять свойство,
|
||||
дай его новое объявление целиком через `replace-property`.
|
||||
|
||||
Ограничение длины и прочие фасеты задаются вложенным типом:
|
||||
|
||||
```xml
|
||||
<xs:element name="Комментарий" minOccurs="0">
|
||||
<xs:simpleType>
|
||||
<xs:restriction base="xs:string"><xs:maxLength value="200"/></xs:restriction>
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
```
|
||||
|
||||
Многострочный фрагмент передавай файлом: `-Value "@frag.xsd"`. Инлайн через оболочку
|
||||
надёжен только для однострочных фрагментов без вложенных кавычек.
|
||||
|
||||
## Адресация
|
||||
|
||||
Путь `Тип.Свойство`. Точка безопасна: имена типов и свойств — идентификаторы 1С.
|
||||
Путь продолжается внутрь встроенных типов: `ПлатежныйДокумент.ДатаСписано.ИдПлатежа`.
|
||||
|
||||
Посмотреть, что есть в пакете и как называется нужный тип, — `/xdto-info`.
|
||||
Перед правкой типа полезно `/xdto-info -Mode used-by -Name <Тип>`: покажет,
|
||||
кого затронет изменение.
|
||||
|
||||
## Что тянет за собой переименование и смена namespace
|
||||
|
||||
`rename` меняет имя в объекте метаданных, переименовывает файл `<Имя>.xml` и каталог
|
||||
`<Имя>/`, правит регистрацию в `Configuration.xml`. Новое имя проверяется на
|
||||
допустимость как идентификатор 1С и на занятость.
|
||||
|
||||
`set-namespace` меняет `targetNamespace`, все внутренние ссылки на собственные типы
|
||||
и `<Namespace>` объекта метаданных. Пакеты, импортирующие старое пространство имён,
|
||||
**не изменяются** — при версионировании они и должны продолжать смотреть на прежнее.
|
||||
Навык их перечислит; если правка не версионная, поправь их импорты сам.
|
||||
|
||||
После правки автоматически запускается `/xdto-validate` — отключается через `-NoValidate`.
|
||||
|
||||
## Типичный workflow
|
||||
|
||||
1. `/xdto-info <пакет>` — найти нужный тип
|
||||
2. `/xdto-info <пакет> -Mode used-by -Name <Тип>` — если меняешь существующее
|
||||
3. `/xdto-edit <пакет> -Operation <op> …`
|
||||
4. `/db-load-xml` + `/db-update`
|
||||
@@ -1,562 +0,0 @@
|
||||
# xdto-edit v1.0 — Point edits of a 1C XDTO package
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Alias('Path')]
|
||||
[string]$PackagePath,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ValidateSet("add-property", "replace-property", "remove-property",
|
||||
"add-type", "remove-type", "add-enum", "add-import",
|
||||
"rename", "set-synonym", "set-comment", "set-namespace")]
|
||||
[string]$Operation,
|
||||
[string]$Target,
|
||||
[string]$Value,
|
||||
[switch]$NoValidate
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
$XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
$MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
$V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function 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.get_LocalName() }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
$mode = "deny"
|
||||
try {
|
||||
$pj = Find-V8Project $cfgDir
|
||||
if ($pj) {
|
||||
$cfg = Get-Content -Path $pj -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($cfg.PSObject.Properties.Name -contains 'editingAllowedCheck' -and $cfg.editingAllowedCheck) {
|
||||
$mode = [string]$cfg.editingAllowedCheck
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return $mode
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath) {
|
||||
try {
|
||||
$d = $targetPath
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
foreach ($x in @(Get-ChildItem -Path $d -Filter "*.xml" -File -ErrorAction SilentlyContinue)) {
|
||||
if (Test-ExternalObjectRoot $x.FullName) { return }
|
||||
}
|
||||
$cfgXml = Join-Path $d "Configuration.xml"
|
||||
$supportBin = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if (Test-Path $cfgXml) {
|
||||
if (Test-Path $supportBin) {
|
||||
$mode = Get-EditMode $d
|
||||
if ($mode -eq "off") { return }
|
||||
$msg = "Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). Правка может быть запрещена."
|
||||
if ($mode -eq "warn") { Write-Warning $msg; return }
|
||||
throw "$msg Снимите с поддержки (/support-edit) или задайте editingAllowedCheck в .v8-project.json."
|
||||
}
|
||||
return
|
||||
}
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
} catch [System.Management.Automation.RuntimeException] {
|
||||
throw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# --- Resolve package ------------------------------------------------------------
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($PackagePath)) {
|
||||
$PackagePath = Join-Path (Get-Location).Path $PackagePath
|
||||
}
|
||||
|
||||
$pkgDir = $null
|
||||
if (Test-Path $PackagePath -PathType Container) {
|
||||
if (Test-Path (Join-Path (Join-Path $PackagePath "Ext") "Package.bin")) { $pkgDir = $PackagePath }
|
||||
} elseif ((Test-Path $PackagePath -PathType Leaf) -and ([System.IO.Path]::GetFileName($PackagePath) -eq "Package.bin")) {
|
||||
$pkgDir = Split-Path (Split-Path $PackagePath -Parent) -Parent
|
||||
} elseif ($PackagePath.EndsWith(".xml")) {
|
||||
$stem = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($PackagePath),
|
||||
[System.IO.Path]::GetFileNameWithoutExtension($PackagePath))
|
||||
if (Test-Path (Join-Path (Join-Path $stem "Ext") "Package.bin")) { $pkgDir = $stem }
|
||||
}
|
||||
if (-not $pkgDir) { throw "Не найден пакет XDTO по пути: $PackagePath" }
|
||||
|
||||
$pkgName = [System.IO.Path]::GetFileName($pkgDir)
|
||||
$xdtoRoot = Split-Path $pkgDir -Parent
|
||||
$configRoot = Split-Path $xdtoRoot -Parent
|
||||
$binFile = Join-Path (Join-Path $pkgDir "Ext") "Package.bin"
|
||||
$mdFile = Join-Path $xdtoRoot "$pkgName.xml"
|
||||
$configXml = Join-Path $configRoot "Configuration.xml"
|
||||
|
||||
# -Value "@путь" — содержимое берётся из файла. Передавать XSD-фрагмент инлайном
|
||||
# через powershell.exe -File ненадёжно: вложенные кавычки схлопываются на границе
|
||||
# процессов, и вместо понятной ошибки получается сырой сбой разбора XML.
|
||||
if ($Value -and $Value.StartsWith("@")) {
|
||||
$valueFile = $Value.Substring(1)
|
||||
if (-not [System.IO.Path]::IsPathRooted($valueFile)) {
|
||||
$valueFile = Join-Path (Get-Location).Path $valueFile
|
||||
}
|
||||
if (-not (Test-Path $valueFile -PathType Leaf)) { throw "Файл значения не найден: $valueFile" }
|
||||
$Value = [System.IO.File]::ReadAllText($valueFile).Trim()
|
||||
}
|
||||
|
||||
Assert-EditAllowed $pkgDir
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# --- Sibling skills -------------------------------------------------------------
|
||||
# Правка идёт через уже проверенный round-trip: пакет выгружается в XSD, операция
|
||||
# применяется к схеме, пакет собирается обратно. Второго эмиттера не заводим —
|
||||
# байт-точность для всего нетронутого достаётся от компилятора.
|
||||
|
||||
$decompileScript = Join-Path (Join-Path $PSScriptRoot "..\..\xdto-decompile") "scripts\xdto-decompile.ps1"
|
||||
$compileScript = Join-Path (Join-Path $PSScriptRoot "..\..\xdto-compile") "scripts\xdto-compile.ps1"
|
||||
$validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\xdto-validate") "scripts\xdto-validate.ps1"
|
||||
|
||||
# Исключение из автономности навыков, сделанное осознанно: конвертер XSD ↔ модель
|
||||
# нельзя скопировать буквально (xdto-compile — скрипт со сквозным потоком, не библиотека),
|
||||
# а вторая его реализация разошлась бы с первой. Обещание «правка не меняет ни байта
|
||||
# в нетронутом» держится именно на том, что код тот же самый.
|
||||
# Проверяем комплектность заранее, чтобы не падать на середине правки.
|
||||
function Assert-SiblingsPresent([string]$operation) {
|
||||
$needed = @{}
|
||||
if (@("rename", "set-synonym", "set-comment") -notcontains $operation) {
|
||||
$needed["xdto-decompile"] = $decompileScript
|
||||
$needed["xdto-compile"] = $compileScript
|
||||
}
|
||||
$missing = @()
|
||||
foreach ($k in $needed.Keys) { if (-not (Test-Path $needed[$k])) { $missing += $k } }
|
||||
if ($missing.Count -gt 0) {
|
||||
$skillsRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
|
||||
throw ("Навык неработоспособен: рядом нет " + ($missing -join ", ") + ".`n" +
|
||||
"Операция `"$operation`" выполняется через " +
|
||||
$(if ($missing.Count -gt 1) { "них" } else { "него" }) + ".`n" +
|
||||
"Ожидаются в каталоге навыков: $skillsRoot")
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Sibling([string]$script, [string[]]$argList, [string]$what) {
|
||||
if (-not (Test-Path $script)) { throw "Не найден навык $what по пути: $script" }
|
||||
$out = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script @argList 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "$what завершился с ошибкой:`n$($out -join "`n")" }
|
||||
return $out
|
||||
}
|
||||
|
||||
# --- Metadata object edits ------------------------------------------------------
|
||||
|
||||
function Edit-Metadata([string]$field, [string]$newValue) {
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $true
|
||||
$doc.Load($mdFile)
|
||||
$nsm = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
|
||||
$nsm.AddNamespace("md", $MD_NS)
|
||||
$nsm.AddNamespace("v8", $V8_NS)
|
||||
$props = $doc.SelectSingleNode("//md:XDTOPackage/md:Properties", $nsm)
|
||||
if (-not $props) { throw "В объекте метаданных не найден блок <Properties>" }
|
||||
|
||||
switch ($field) {
|
||||
"Name" {
|
||||
$n = $props.SelectSingleNode("md:Name", $nsm)
|
||||
if (-not $n) { throw "В объекте метаданных нет <Name>" }
|
||||
$n.InnerText = $newValue
|
||||
}
|
||||
"Comment" {
|
||||
$c = $props.SelectSingleNode("md:Comment", $nsm)
|
||||
if (-not $c) {
|
||||
$c = $doc.CreateElement("Comment", $MD_NS)
|
||||
$props.AppendChild($c) | Out-Null
|
||||
}
|
||||
$c.InnerText = $newValue
|
||||
}
|
||||
"Namespace" {
|
||||
$ns = $props.SelectSingleNode("md:Namespace", $nsm)
|
||||
if (-not $ns) { throw "В объекте метаданных нет <Namespace>" }
|
||||
$ns.InnerText = $newValue
|
||||
}
|
||||
"Synonym" {
|
||||
$syn = $props.SelectSingleNode("md:Synonym", $nsm)
|
||||
if (-not $syn) {
|
||||
$syn = $doc.CreateElement("Synonym", $MD_NS)
|
||||
$props.AppendChild($syn) | Out-Null
|
||||
}
|
||||
$item = $syn.SelectSingleNode("v8:item[v8:lang='ru']", $nsm)
|
||||
if (-not $item) {
|
||||
$item = $doc.CreateElement("v8", "item", $V8_NS)
|
||||
$lang = $doc.CreateElement("v8", "lang", $V8_NS); $lang.InnerText = "ru"
|
||||
$cont = $doc.CreateElement("v8", "content", $V8_NS)
|
||||
$item.AppendChild($lang) | Out-Null
|
||||
$item.AppendChild($cont) | Out-Null
|
||||
$syn.AppendChild($item) | Out-Null
|
||||
}
|
||||
$item.SelectSingleNode("v8:content", $nsm).InnerText = $newValue
|
||||
}
|
||||
}
|
||||
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$stream = New-Object System.IO.FileStream($mdFile, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
$doc.Save($writer)
|
||||
$writer.Close(); $stream.Close()
|
||||
}
|
||||
|
||||
function Rename-Package([string]$newName) {
|
||||
if ($newName -notmatch '^[\wЀ-ӿ]+$' -or $newName -match '^\d') {
|
||||
throw "`"$newName`" не является допустимым идентификатором 1С"
|
||||
}
|
||||
$newMd = Join-Path $xdtoRoot "$newName.xml"
|
||||
$newDir = Join-Path $xdtoRoot $newName
|
||||
if ((Test-Path $newMd) -or (Test-Path $newDir)) { throw "Имя `"$newName`" уже занято другим пакетом" }
|
||||
|
||||
Edit-Metadata "Name" $newName
|
||||
Move-Item $mdFile $newMd
|
||||
Move-Item $pkgDir $newDir
|
||||
|
||||
if (Test-Path $configXml) {
|
||||
$cfg = New-Object System.Xml.XmlDocument
|
||||
$cfg.PreserveWhitespace = $true
|
||||
$cfg.Load($configXml)
|
||||
$nsm = New-Object System.Xml.XmlNamespaceManager($cfg.NameTable)
|
||||
$nsm.AddNamespace("md", $MD_NS)
|
||||
$found = $false
|
||||
foreach ($e in $cfg.SelectNodes("//md:Configuration/md:ChildObjects/md:XDTOPackage", $nsm)) {
|
||||
if ($e.InnerText -eq $pkgName) { $e.InnerText = $newName; $found = $true; break }
|
||||
}
|
||||
if ($found) {
|
||||
$s = New-Object System.Xml.XmlWriterSettings
|
||||
$s.Encoding = $encBom; $s.Indent = $false
|
||||
$st = New-Object System.IO.FileStream($configXml, [System.IO.FileMode]::Create)
|
||||
$w = [System.Xml.XmlWriter]::Create($st, $s)
|
||||
$cfg.Save($w); $w.Close(); $st.Close()
|
||||
Write-Host " Configuration.xml: <XDTOPackage> переименован в $newName"
|
||||
} else {
|
||||
Write-Warning "В Configuration.xml не найдена запись <XDTOPackage>$pkgName</XDTOPackage> — зарегистрируйте пакет вручную"
|
||||
}
|
||||
}
|
||||
Write-Host "✓ Пакет переименован: $pkgName → $newName"
|
||||
Write-Host " Перемещены: $newName.xml, $newName/"
|
||||
}
|
||||
|
||||
# --- Model edits through the XSD round-trip -------------------------------------
|
||||
|
||||
$XSD_DECL = @{ "add-property" = "element"; "replace-property" = "element"; "remove-property" = "element" }
|
||||
|
||||
function Get-SchemaChildren([System.Xml.XmlElement]$el, [string]$local) {
|
||||
$res = New-Object System.Collections.ArrayList
|
||||
foreach ($c in $el.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.NamespaceURI -eq $XS_NS -and $c.get_LocalName() -eq $local) { [void]$res.Add($c) }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
function Get-SchemaFirst([System.Xml.XmlElement]$el, [string]$local) {
|
||||
$r = Get-SchemaChildren $el $local
|
||||
if ($r.Count -gt 0) { return $r[0] }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Find-TypeElement($schema, [string]$typeName) {
|
||||
foreach ($kind in @("complexType", "simpleType")) {
|
||||
foreach ($t in (Get-SchemaChildren $schema $kind)) {
|
||||
if ($t.GetAttribute("name") -eq $typeName) { return $t }
|
||||
}
|
||||
}
|
||||
throw "В пакете нет типа `"$typeName`""
|
||||
}
|
||||
|
||||
# Тело типа: внутрь xs:complexContent/xs:extension, если тип наследуется
|
||||
function Get-TypeBody([System.Xml.XmlElement]$ct) {
|
||||
$content = Get-SchemaFirst $ct "complexContent"
|
||||
if ($content) {
|
||||
$ext = Get-SchemaFirst $content "extension"
|
||||
if ($ext) { return $ext }
|
||||
}
|
||||
return $ct
|
||||
}
|
||||
|
||||
function Find-Declaration([System.Xml.XmlElement]$body, [string]$propName) {
|
||||
foreach ($node in $body.SelectNodes(".//*")) {
|
||||
if ($node.NamespaceURI -ne $XS_NS) { continue }
|
||||
if (@("element", "attribute") -notcontains $node.get_LocalName()) { continue }
|
||||
if ($node.GetAttribute("name") -eq $propName) { return $node }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Путь Тип.Свойство[.Вложенное...] — точка безопасна: имена в модели XDTO
|
||||
# являются идентификаторами 1С и точку содержать не могут
|
||||
function Resolve-Path($schema, [string]$path) {
|
||||
$segments = $path.Split(".")
|
||||
$typeEl = Find-TypeElement $schema $segments[0]
|
||||
if ($segments.Count -eq 1) { return [pscustomobject]@{ Type = $typeEl; Decl = $null } }
|
||||
|
||||
$body = Get-TypeBody $typeEl
|
||||
$decl = $null
|
||||
for ($i = 1; $i -lt $segments.Count; $i++) {
|
||||
$decl = Find-Declaration $body $segments[$i]
|
||||
if (-not $decl) { throw "По пути `"$path`" не найдено свойство `"$($segments[$i])`"" }
|
||||
if ($i -lt $segments.Count - 1) {
|
||||
$inner = Get-SchemaFirst $decl "complexType"
|
||||
if (-not $inner) { throw "Свойство `"$($segments[$i])`" не содержит вложенного типа — путь дальше не идёт" }
|
||||
$body = Get-TypeBody $inner
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{ Type = $typeEl; Decl = $decl }
|
||||
}
|
||||
|
||||
function Import-Fragment($schema, [string]$xml) {
|
||||
$tmp = New-Object System.Xml.XmlDocument
|
||||
$nsAttrs = " xmlns:xs=`"$XS_NS`" xmlns:xdto=`"$XDTO_NS`""
|
||||
$tns = $schema.GetAttribute("targetNamespace")
|
||||
if ($tns) { $nsAttrs += " xmlns:tns=`"$tns`"" }
|
||||
foreach ($a in $schema.Attributes) {
|
||||
if ($a.Prefix -eq "xmlns" -and $a.get_LocalName() -notin @("xs", "xdto", "tns")) {
|
||||
$nsAttrs += " xmlns:$($a.get_LocalName())=`"$($a.Value)`""
|
||||
}
|
||||
}
|
||||
try { $tmp.LoadXml("<wrap$nsAttrs>$xml</wrap>") }
|
||||
catch {
|
||||
throw ("Не удалось разобрать -Value как фрагмент XML-схемы: " + $_.Exception.InnerException.Message + "`n" +
|
||||
"Получено: " + $xml + "`n" +
|
||||
"Если фрагмент передан инлайном, кавычки могли схлопнуться на границе процессов — " +
|
||||
"положите его в файл и укажите -Value `"@путь`".")
|
||||
}
|
||||
$res = New-Object System.Collections.ArrayList
|
||||
foreach ($c in $tmp.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||
[void]$res.Add($schema.OwnerDocument.ImportNode($c, $true))
|
||||
}
|
||||
}
|
||||
if ($res.Count -eq 0) { throw "Во фрагменте нет ни одного элемента: $xml" }
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Apply-ModelOperation($schema) {
|
||||
switch ($Operation) {
|
||||
|
||||
"add-property" {
|
||||
if (-not $Target) { throw "add-property требует -Target <Тип>" }
|
||||
$loc = Resolve-Path $schema $Target
|
||||
$body = Get-TypeBody $(if ($loc.Decl) { Get-SchemaFirst $loc.Decl "complexType" } else { $loc.Type })
|
||||
foreach ($frag in (Import-Fragment $schema $Value)) {
|
||||
$kind = $frag.get_LocalName()
|
||||
if ($kind -eq "attribute") {
|
||||
$body.AppendChild($frag) | Out-Null
|
||||
} elseif ($kind -eq "element") {
|
||||
$particle = Get-SchemaFirst $body "sequence"
|
||||
if (-not $particle) { $particle = Get-SchemaFirst $body "choice" }
|
||||
if (-not $particle) { $particle = Get-SchemaFirst $body "all" }
|
||||
if (-not $particle) {
|
||||
$particle = $schema.OwnerDocument.CreateElement("xs", "sequence", $XS_NS)
|
||||
$firstAttr = Get-SchemaFirst $body "attribute"
|
||||
if ($firstAttr) { $body.InsertBefore($particle, $firstAttr) | Out-Null }
|
||||
else { $body.AppendChild($particle) | Out-Null }
|
||||
}
|
||||
$particle.AppendChild($frag) | Out-Null
|
||||
} else {
|
||||
throw "add-property ожидает <xs:element> или <xs:attribute>, получен <xs:$kind>"
|
||||
}
|
||||
Write-Host " + $($frag.GetAttribute('name')) в тип $Target"
|
||||
}
|
||||
}
|
||||
|
||||
"replace-property" {
|
||||
if (-not $Target) { throw "replace-property требует -Target `"Тип.Свойство`"" }
|
||||
$loc = Resolve-Path $schema $Target
|
||||
if (-not $loc.Decl) { throw "replace-property требует путь вида `"Тип.Свойство`"" }
|
||||
$frags = Import-Fragment $schema $Value
|
||||
if ($frags.Count -ne 1) { throw "replace-property ожидает ровно одно объявление" }
|
||||
$loc.Decl.ParentNode.ReplaceChild($frags[0], $loc.Decl) | Out-Null
|
||||
Write-Host " ~ $Target заменено"
|
||||
}
|
||||
|
||||
"remove-property" {
|
||||
if (-not $Target) { throw "remove-property требует путь `"Тип.Свойство`"" }
|
||||
foreach ($one in ($Target -split "\s*;;\s*")) {
|
||||
if (-not $one) { continue }
|
||||
$loc = Resolve-Path $schema $one
|
||||
if (-not $loc.Decl) { throw "remove-property требует путь вида `"Тип.Свойство`", получено `"$one`"" }
|
||||
$loc.Decl.ParentNode.RemoveChild($loc.Decl) | Out-Null
|
||||
Write-Host " − $one удалено"
|
||||
}
|
||||
}
|
||||
|
||||
"add-type" {
|
||||
foreach ($frag in (Import-Fragment $schema $Value)) {
|
||||
if (@("complexType", "simpleType") -notcontains $frag.get_LocalName()) {
|
||||
throw "add-type ожидает <xs:complexType> или <xs:simpleType>, получен <xs:$($frag.get_LocalName())>"
|
||||
}
|
||||
$schema.AppendChild($frag) | Out-Null
|
||||
Write-Host " + тип $($frag.GetAttribute('name'))"
|
||||
}
|
||||
}
|
||||
|
||||
"remove-type" {
|
||||
if (-not $Target) { throw "remove-type требует -Target <Тип>" }
|
||||
foreach ($one in ($Target -split "\s*;;\s*")) {
|
||||
if (-not $one) { continue }
|
||||
$t = Find-TypeElement $schema $one
|
||||
$t.ParentNode.RemoveChild($t) | Out-Null
|
||||
Write-Host " − тип $one удалён"
|
||||
}
|
||||
}
|
||||
|
||||
"add-enum" {
|
||||
if (-not $Target) { throw "add-enum требует -Target <ТипЗначения>" }
|
||||
$t = Find-TypeElement $schema $Target
|
||||
$restriction = Get-SchemaFirst $t "restriction"
|
||||
if (-not $restriction) { throw "Тип `"$Target`" не является ограничением простого типа" }
|
||||
foreach ($lit in ($Value -split "\s*;;\s*")) {
|
||||
if (-not $lit) { continue }
|
||||
$e = $schema.OwnerDocument.CreateElement("xs", "enumeration", $XS_NS)
|
||||
$e.SetAttribute("value", $lit)
|
||||
$restriction.AppendChild($e) | Out-Null
|
||||
Write-Host " + значение `"$lit`" в тип $Target"
|
||||
}
|
||||
}
|
||||
|
||||
"add-import" {
|
||||
foreach ($uri in ($Value -split "\s*;;\s*")) {
|
||||
if (-not $uri) { continue }
|
||||
$exists = $false
|
||||
foreach ($i in (Get-SchemaChildren $schema "import")) {
|
||||
if ($i.GetAttribute("namespace") -eq $uri) { $exists = $true; break }
|
||||
}
|
||||
if ($exists) { Write-Host " = импорт $uri уже объявлен"; continue }
|
||||
$imp = $schema.OwnerDocument.CreateElement("xs", "import", $XS_NS)
|
||||
$imp.SetAttribute("namespace", $uri)
|
||||
$firstOther = $null
|
||||
foreach ($c in $schema.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -notin @("annotation", "import")) { $firstOther = $c; break }
|
||||
}
|
||||
if ($firstOther) { $schema.InsertBefore($imp, $firstOther) | Out-Null } else { $schema.AppendChild($imp) | Out-Null }
|
||||
Write-Host " + импорт $uri"
|
||||
}
|
||||
}
|
||||
|
||||
"set-namespace" {
|
||||
if (-not $Value) { throw "set-namespace требует -Value <URI>" }
|
||||
$old = $schema.GetAttribute("targetNamespace")
|
||||
# Установка того же значения не отбрасывается: пакет пересобирается вхолостую,
|
||||
# и это заодно проба точности пути «выгрузить → собрать» на любом пакете
|
||||
if ($old -eq $Value) { Write-Host " = namespace уже $Value, пакет пересобран без изменений" }
|
||||
# Меняем и targetNamespace, и объявление префикса, который на него указывал:
|
||||
# иначе ссылки на собственные типы станут ссылками в чужое пространство имён
|
||||
$schema.SetAttribute("targetNamespace", $Value)
|
||||
foreach ($a in @($schema.Attributes)) {
|
||||
if ($a.Prefix -eq "xmlns" -and $a.Value -eq $old) {
|
||||
$schema.SetAttribute("xmlns:$($a.get_LocalName())", $Value)
|
||||
}
|
||||
}
|
||||
Write-Host " ~ namespace: $old → $Value"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Dispatch -------------------------------------------------------------------
|
||||
|
||||
$metaOps = @("rename", "set-synonym", "set-comment")
|
||||
$touchesModel = ($metaOps -notcontains $Operation)
|
||||
|
||||
Assert-SiblingsPresent $Operation
|
||||
|
||||
Write-Host "Пакет: $pkgName"
|
||||
|
||||
if ($Operation -eq "rename") {
|
||||
if (-not $Value) { throw "rename требует -Value <НовоеИмя>" }
|
||||
Rename-Package $Value
|
||||
$pkgName = $Value
|
||||
$pkgDir = Join-Path $xdtoRoot $Value
|
||||
} elseif ($Operation -eq "set-synonym") {
|
||||
if (-not $Value) { throw "set-synonym требует -Value <текст>" }
|
||||
Edit-Metadata "Synonym" $Value
|
||||
Write-Host "✓ Синоним: $Value"
|
||||
} elseif ($Operation -eq "set-comment") {
|
||||
Edit-Metadata "Comment" $Value
|
||||
Write-Host "✓ Комментарий обновлён"
|
||||
} else {
|
||||
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) ("xdto-edit_" + [guid]::NewGuid().ToString("N").Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
|
||||
try {
|
||||
$xsdPath = Join-Path $tmpDir "schema.xsd"
|
||||
Invoke-Sibling $decompileScript @("-PackagePath", $binFile, "-OutFile", $xsdPath) "xdto-decompile" | Out-Null
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
$doc.Load($xsdPath)
|
||||
$schema = $doc.DocumentElement
|
||||
|
||||
$oldNamespace = $schema.GetAttribute("targetNamespace")
|
||||
Apply-ModelOperation $schema
|
||||
|
||||
$doc.Save($xsdPath)
|
||||
Invoke-Sibling $compileScript @("-XsdPath", $xsdPath, "-OutputDir", $configRoot, "-Name", $pkgName, "-Force") "xdto-compile" | Out-Null
|
||||
|
||||
if ($Operation -eq "set-namespace") {
|
||||
Edit-Metadata "Namespace" $Value
|
||||
# Зависящие пакеты не трогаем: при версионировании они обязаны продолжать
|
||||
# смотреть на прежний namespace. Но молчать о них нельзя.
|
||||
$dependents = @()
|
||||
foreach ($d in (Get-ChildItem $xdtoRoot -Directory -ErrorAction SilentlyContinue)) {
|
||||
if ($d.Name -eq $pkgName) { continue }
|
||||
$ob = Join-Path (Join-Path $d.FullName "Ext") "Package.bin"
|
||||
if (-not (Test-Path $ob)) { continue }
|
||||
try {
|
||||
$od = New-Object System.Xml.XmlDocument
|
||||
$od.Load($ob)
|
||||
foreach ($imp in $od.DocumentElement.ChildNodes) {
|
||||
if ($imp.NodeType -eq [System.Xml.XmlNodeType]::Element -and $imp.get_LocalName() -eq "import" -and
|
||||
$imp.GetAttribute("namespace") -eq $oldNamespace) { $dependents += $d.Name; break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if ($dependents.Count -gt 0) {
|
||||
Write-Host ""
|
||||
Write-Warning ("Старый namespace импортируют пакеты ($($dependents.Count)): " + ($dependents -join ", ") +
|
||||
". Они не изменены — при версионировании это верно; если нет, поправьте их импорты.")
|
||||
}
|
||||
}
|
||||
Write-Host "✓ Пакет пересобран: XDTOPackages/$pkgName/Ext/Package.bin"
|
||||
} finally {
|
||||
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate -------------------------------------------------------------------
|
||||
|
||||
if (-not $NoValidate) {
|
||||
if (Test-Path $validateScript) {
|
||||
Write-Host ""
|
||||
Write-Host "--- xdto-validate ---"
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $validateScript -PackagePath (Join-Path $xdtoRoot $pkgName)
|
||||
} else {
|
||||
Write-Host "[SKIP] xdto-validate не найден: $validateScript"
|
||||
}
|
||||
}
|
||||
exit 0
|
||||
@@ -1,541 +0,0 @@
|
||||
# xdto-edit v1.0 — Point edits of a 1C XDTO package (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
XDTO_NS = "http://v8.1c.ru/8.1/xdto"
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
OPS = ["add-property", "replace-property", "remove-property", "add-type", "remove-type",
|
||||
"add-enum", "add-import", "rename", "set-synonym", "set-comment", "set-namespace"]
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-PackagePath", "-Path", required=True)
|
||||
parser.add_argument("-Operation", required=True, choices=OPS)
|
||||
parser.add_argument("-Target", default="")
|
||||
parser.add_argument("-Value", default="")
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(msg, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def local(el):
|
||||
return etree.QName(el).localname
|
||||
|
||||
|
||||
def _parse_xml(source, from_string=False):
|
||||
"""Разбор с узким отступлением для не-URI пространств имён.
|
||||
|
||||
Платформа допускает в targetNamespace произвольную строку, .NET такое принимает,
|
||||
а libxml2 отвергает. Откатываемся на восстанавливающий разбор ТОЛЬКО на этой ошибке.
|
||||
"""
|
||||
try:
|
||||
return (etree.fromstring(source) if from_string else etree.parse(source))
|
||||
except etree.XMLSyntaxError as e:
|
||||
if "is not a valid URI" not in str(e):
|
||||
raise
|
||||
p = etree.XMLParser(recover=True)
|
||||
return (etree.fromstring(source, p) if from_string else etree.parse(source, p))
|
||||
|
||||
|
||||
# ── support guard ────────────────────────────────────────────
|
||||
# См. docs/1c-support-state-spec.md.
|
||||
|
||||
def find_v8_project(start_dir):
|
||||
d = os.path.abspath(start_dir)
|
||||
for _ in range(20):
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.exists(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = find_v8_project(cfg_dir)
|
||||
if pj:
|
||||
with open(pj, encoding="utf-8-sig") as f:
|
||||
return str(json.load(f).get("editingAllowedCheck") or "deny")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return "deny"
|
||||
|
||||
|
||||
def is_external_object_root(xml_path):
|
||||
try:
|
||||
for el in _parse_xml(xml_path).getroot():
|
||||
if isinstance(el.tag, str):
|
||||
return local(el) in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path):
|
||||
d = os.path.abspath(target_path)
|
||||
for _ in range(20):
|
||||
try:
|
||||
for f in os.listdir(d):
|
||||
if f.endswith(".xml") and is_external_object_root(os.path.join(d, f)):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
if os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
if os.path.exists(os.path.join(d, "Ext", "ParentConfigurations.bin")):
|
||||
mode = get_edit_mode(d)
|
||||
if mode == "off":
|
||||
return
|
||||
msg = ("Конфигурация находится на поддержке (Ext/ParentConfigurations.bin). "
|
||||
"Правка может быть запрещена.")
|
||||
if mode == "warn":
|
||||
print("WARNING: " + msg, file=sys.stderr)
|
||||
return
|
||||
die(msg + " Снимите с поддержки (/support-edit) или задайте "
|
||||
"editingAllowedCheck в .v8-project.json.")
|
||||
return
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
|
||||
|
||||
# ── resolve package ──────────────────────────────────────────
|
||||
|
||||
package_path = os.path.abspath(args.PackagePath)
|
||||
pkg_dir = None
|
||||
if os.path.isdir(package_path):
|
||||
if os.path.exists(os.path.join(package_path, "Ext", "Package.bin")):
|
||||
pkg_dir = package_path
|
||||
elif os.path.isfile(package_path) and os.path.basename(package_path) == "Package.bin":
|
||||
pkg_dir = os.path.dirname(os.path.dirname(package_path))
|
||||
elif package_path.endswith(".xml"):
|
||||
stem = os.path.join(os.path.dirname(package_path),
|
||||
os.path.splitext(os.path.basename(package_path))[0])
|
||||
if os.path.exists(os.path.join(stem, "Ext", "Package.bin")):
|
||||
pkg_dir = stem
|
||||
if not pkg_dir:
|
||||
die(f"Не найден пакет XDTO по пути: {package_path}")
|
||||
|
||||
pkg_name = os.path.basename(pkg_dir.rstrip("\\/"))
|
||||
xdto_root = os.path.dirname(pkg_dir)
|
||||
config_root = os.path.dirname(xdto_root)
|
||||
bin_file = os.path.join(pkg_dir, "Ext", "Package.bin")
|
||||
md_file = os.path.join(xdto_root, pkg_name + ".xml")
|
||||
config_xml = os.path.join(config_root, "Configuration.xml")
|
||||
|
||||
# -Value "@путь" — содержимое берётся из файла. Передавать XSD-фрагмент инлайном
|
||||
# ненадёжно: вложенные кавычки схлопываются на границе процессов, и вместо понятной
|
||||
# ошибки получается сырой сбой разбора XML.
|
||||
if args.Value.startswith("@"):
|
||||
value_file = args.Value[1:]
|
||||
if not os.path.isabs(value_file):
|
||||
value_file = os.path.join(os.getcwd(), value_file)
|
||||
if not os.path.isfile(value_file):
|
||||
die("Файл значения не найден: " + value_file)
|
||||
with open(value_file, encoding="utf-8-sig") as f:
|
||||
args.Value = f.read().strip()
|
||||
|
||||
assert_edit_allowed(pkg_dir)
|
||||
|
||||
SKILLS = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
DECOMPILE = os.path.join(SKILLS, "xdto-decompile", "scripts", "xdto-decompile.py")
|
||||
COMPILE = os.path.join(SKILLS, "xdto-compile", "scripts", "xdto-compile.py")
|
||||
VALIDATE = os.path.join(SKILLS, "xdto-validate", "scripts", "xdto-validate.py")
|
||||
|
||||
|
||||
# Исключение из автономности навыков, сделанное осознанно: конвертер XSD <-> модель
|
||||
# нельзя скопировать буквально (xdto-compile — скрипт со сквозным потоком, не библиотека),
|
||||
# а вторая его реализация разошлась бы с первой. Обещание «правка не меняет ни байта
|
||||
# в нетронутом» держится именно на том, что код тот же самый.
|
||||
# Проверяем комплектность заранее, чтобы не падать на середине правки.
|
||||
def assert_siblings_present(operation):
|
||||
needed = {}
|
||||
if operation not in ("rename", "set-synonym", "set-comment"):
|
||||
needed["xdto-decompile"] = DECOMPILE
|
||||
needed["xdto-compile"] = COMPILE
|
||||
missing = [k for k, v in needed.items() if not os.path.exists(v)]
|
||||
if missing:
|
||||
die("Навык неработоспособен: рядом нет " + ", ".join(missing) + ".\n"
|
||||
+ f'Операция "{operation}" выполняется через '
|
||||
+ ("них" if len(missing) > 1 else "него") + ".\n"
|
||||
+ "Ожидаются в каталоге навыков: " + SKILLS)
|
||||
|
||||
|
||||
def invoke_sibling(script, argv, what):
|
||||
if not os.path.exists(script):
|
||||
die(f"Не найден навык {what} по пути: {script}")
|
||||
r = subprocess.run([sys.executable, script, *argv], capture_output=True, text=True, encoding="utf-8")
|
||||
if r.returncode != 0:
|
||||
die(f"{what} завершился с ошибкой:\n{(r.stderr or '') + (r.stdout or '')}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def save_xml(doc, path):
|
||||
raw = etree.tostring(doc, xml_declaration=True, encoding="UTF-8")
|
||||
with open(path, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbf" + raw)
|
||||
|
||||
|
||||
# ── metadata object edits ────────────────────────────────────
|
||||
|
||||
def edit_metadata(field, new_value):
|
||||
doc = _parse_xml(md_file)
|
||||
props = doc.find(f".//{{{MD_NS}}}XDTOPackage/{{{MD_NS}}}Properties")
|
||||
if props is None:
|
||||
die("В объекте метаданных не найден блок <Properties>")
|
||||
|
||||
if field in ("Name", "Namespace"):
|
||||
el = props.find(f"{{{MD_NS}}}{field}")
|
||||
if el is None:
|
||||
die(f"В объекте метаданных нет <{field}>")
|
||||
el.text = new_value
|
||||
elif field == "Comment":
|
||||
el = props.find(f"{{{MD_NS}}}Comment")
|
||||
if el is None:
|
||||
el = etree.SubElement(props, f"{{{MD_NS}}}Comment")
|
||||
el.text = new_value
|
||||
elif field == "Synonym":
|
||||
syn = props.find(f"{{{MD_NS}}}Synonym")
|
||||
if syn is None:
|
||||
syn = etree.SubElement(props, f"{{{MD_NS}}}Synonym")
|
||||
item = None
|
||||
for it in syn.iterfind(f"{{{V8_NS}}}item"):
|
||||
lg = it.find(f"{{{V8_NS}}}lang")
|
||||
if lg is not None and (lg.text or "") == "ru":
|
||||
item = it
|
||||
break
|
||||
if item is None:
|
||||
item = etree.SubElement(syn, f"{{{V8_NS}}}item")
|
||||
etree.SubElement(item, f"{{{V8_NS}}}lang").text = "ru"
|
||||
etree.SubElement(item, f"{{{V8_NS}}}content")
|
||||
item.find(f"{{{V8_NS}}}content").text = new_value
|
||||
save_xml(doc, md_file)
|
||||
|
||||
|
||||
def rename_package(new_name):
|
||||
global pkg_name, pkg_dir
|
||||
# \w с re.UNICODE уже покрывает кириллицу; явные диапазоны только плодят ошибки
|
||||
if not re.match(r"^\w+$", new_name, re.UNICODE) or re.match(r"^\d", new_name):
|
||||
die(f'"{new_name}" не является допустимым идентификатором 1С')
|
||||
new_md = os.path.join(xdto_root, new_name + ".xml")
|
||||
new_dir = os.path.join(xdto_root, new_name)
|
||||
if os.path.exists(new_md) or os.path.exists(new_dir):
|
||||
die(f'Имя "{new_name}" уже занято другим пакетом')
|
||||
|
||||
edit_metadata("Name", new_name)
|
||||
shutil.move(md_file, new_md)
|
||||
shutil.move(pkg_dir, new_dir)
|
||||
|
||||
if os.path.exists(config_xml):
|
||||
cfg = _parse_xml(config_xml)
|
||||
found = False
|
||||
for e in cfg.iterfind(f".//{{{MD_NS}}}Configuration/{{{MD_NS}}}ChildObjects/{{{MD_NS}}}XDTOPackage"):
|
||||
if (e.text or "") == pkg_name:
|
||||
e.text = new_name
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
save_xml(cfg, config_xml)
|
||||
print(f" Configuration.xml: <XDTOPackage> переименован в {new_name}")
|
||||
else:
|
||||
print(f"WARNING: В Configuration.xml не найдена запись <XDTOPackage>{pkg_name}</XDTOPackage> — "
|
||||
"зарегистрируйте пакет вручную", file=sys.stderr)
|
||||
print(f"✓ Пакет переименован: {pkg_name} → {new_name}")
|
||||
print(f" Перемещены: {new_name}.xml, {new_name}/")
|
||||
pkg_name = new_name
|
||||
pkg_dir = new_dir
|
||||
|
||||
|
||||
# ── model edits through the XSD round-trip ───────────────────
|
||||
|
||||
def xs_children(el, name):
|
||||
return [c for c in el if isinstance(c.tag, str)
|
||||
and etree.QName(c).namespace == XS_NS and local(c) == name]
|
||||
|
||||
|
||||
def xs_first(el, name):
|
||||
r = xs_children(el, name)
|
||||
return r[0] if r else None
|
||||
|
||||
|
||||
def find_type_element(schema, type_name):
|
||||
for kind in ("complexType", "simpleType"):
|
||||
for t in xs_children(schema, kind):
|
||||
if t.get("name") == type_name:
|
||||
return t
|
||||
die(f'В пакете нет типа "{type_name}"')
|
||||
|
||||
|
||||
def get_type_body(ct):
|
||||
content = xs_first(ct, "complexContent")
|
||||
if content is not None:
|
||||
ext = xs_first(content, "extension")
|
||||
if ext is not None:
|
||||
return ext
|
||||
return ct
|
||||
|
||||
|
||||
def find_declaration(body, prop_name):
|
||||
for node in body.iter():
|
||||
if not isinstance(node.tag, str) or etree.QName(node).namespace != XS_NS:
|
||||
continue
|
||||
if local(node) in ("element", "attribute") and node.get("name") == prop_name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def resolve_path(schema, path):
|
||||
# Точка безопасна: имена в модели XDTO — идентификаторы 1С
|
||||
segments = path.split(".")
|
||||
type_el = find_type_element(schema, segments[0])
|
||||
if len(segments) == 1:
|
||||
return (type_el, None)
|
||||
body = get_type_body(type_el)
|
||||
decl = None
|
||||
for i in range(1, len(segments)):
|
||||
decl = find_declaration(body, segments[i])
|
||||
if decl is None:
|
||||
die(f'По пути "{path}" не найдено свойство "{segments[i]}"')
|
||||
if i < len(segments) - 1:
|
||||
inner = xs_first(decl, "complexType")
|
||||
if inner is None:
|
||||
die(f'Свойство "{segments[i]}" не содержит вложенного типа — путь дальше не идёт')
|
||||
body = get_type_body(inner)
|
||||
return (type_el, decl)
|
||||
|
||||
|
||||
def import_fragment(schema, xml):
|
||||
ns = {"xs": XS_NS, "xdto": XDTO_NS}
|
||||
tns = schema.get("targetNamespace")
|
||||
if tns:
|
||||
ns["tns"] = tns
|
||||
for px, uri in schema.nsmap.items():
|
||||
if px and px not in ns:
|
||||
ns[px] = uri
|
||||
decls = " ".join(f'xmlns:{k}="{v}"' for k, v in ns.items())
|
||||
try:
|
||||
wrapped = etree.fromstring(f"<wrap {decls}>{xml}</wrap>".encode("utf-8"))
|
||||
except etree.XMLSyntaxError as e:
|
||||
die("Не удалось разобрать -Value как фрагмент XML-схемы: " + str(e) + "\n"
|
||||
+ "Получено: " + xml + "\n"
|
||||
+ "Если фрагмент передан инлайном, кавычки могли схлопнуться на границе "
|
||||
'процессов — положите его в файл и укажите -Value "@путь".')
|
||||
res = [c for c in wrapped if isinstance(c.tag, str)]
|
||||
if not res:
|
||||
die(f"Во фрагменте нет ни одного элемента: {xml}")
|
||||
return res
|
||||
|
||||
|
||||
def apply_model_operation(schema):
|
||||
op = args.Operation
|
||||
|
||||
if op == "add-property":
|
||||
if not args.Target:
|
||||
die("add-property требует -Target <Тип>")
|
||||
type_el, decl = resolve_path(schema, args.Target)
|
||||
host = xs_first(decl, "complexType") if decl is not None else type_el
|
||||
body = get_type_body(host)
|
||||
for frag in import_fragment(schema, args.Value):
|
||||
kind = local(frag)
|
||||
if kind == "attribute":
|
||||
body.append(frag)
|
||||
elif kind == "element":
|
||||
# Явные is not None: пустой <xs:sequence/> в lxml ложен,
|
||||
# и через "or" мы бы создали вторую частицу
|
||||
particle = xs_first(body, "sequence")
|
||||
if particle is None:
|
||||
particle = xs_first(body, "choice")
|
||||
if particle is None:
|
||||
particle = xs_first(body, "all")
|
||||
if particle is None:
|
||||
particle = etree.Element(f"{{{XS_NS}}}sequence")
|
||||
first_attr = xs_first(body, "attribute")
|
||||
if first_attr is not None:
|
||||
first_attr.addprevious(particle)
|
||||
else:
|
||||
body.append(particle)
|
||||
particle.append(frag)
|
||||
else:
|
||||
die(f"add-property ожидает <xs:element> или <xs:attribute>, получен <xs:{kind}>")
|
||||
print(f' + {frag.get("name")} в тип {args.Target}')
|
||||
|
||||
elif op == "replace-property":
|
||||
if not args.Target:
|
||||
die('replace-property требует -Target "Тип.Свойство"')
|
||||
_, decl = resolve_path(schema, args.Target)
|
||||
if decl is None:
|
||||
die('replace-property требует путь вида "Тип.Свойство"')
|
||||
frags = import_fragment(schema, args.Value)
|
||||
if len(frags) != 1:
|
||||
die("replace-property ожидает ровно одно объявление")
|
||||
decl.getparent().replace(decl, frags[0])
|
||||
print(f" ~ {args.Target} заменено")
|
||||
|
||||
elif op == "remove-property":
|
||||
if not args.Target:
|
||||
die('remove-property требует путь "Тип.Свойство"')
|
||||
for one in [x.strip() for x in args.Target.split(";;") if x.strip()]:
|
||||
_, decl = resolve_path(schema, one)
|
||||
if decl is None:
|
||||
die(f'remove-property требует путь вида "Тип.Свойство", получено "{one}"')
|
||||
decl.getparent().remove(decl)
|
||||
print(f" − {one} удалено")
|
||||
|
||||
elif op == "add-type":
|
||||
for frag in import_fragment(schema, args.Value):
|
||||
if local(frag) not in ("complexType", "simpleType"):
|
||||
die(f"add-type ожидает <xs:complexType> или <xs:simpleType>, получен <xs:{local(frag)}>")
|
||||
schema.append(frag)
|
||||
print(f' + тип {frag.get("name")}')
|
||||
|
||||
elif op == "remove-type":
|
||||
if not args.Target:
|
||||
die("remove-type требует -Target <Тип>")
|
||||
for one in [x.strip() for x in args.Target.split(";;") if x.strip()]:
|
||||
t = find_type_element(schema, one)
|
||||
t.getparent().remove(t)
|
||||
print(f" − тип {one} удалён")
|
||||
|
||||
elif op == "add-enum":
|
||||
if not args.Target:
|
||||
die("add-enum требует -Target <ТипЗначения>")
|
||||
t = find_type_element(schema, args.Target)
|
||||
restriction = xs_first(t, "restriction")
|
||||
if restriction is None:
|
||||
die(f'Тип "{args.Target}" не является ограничением простого типа')
|
||||
for lit in [x.strip() for x in args.Value.split(";;") if x.strip()]:
|
||||
e = etree.SubElement(restriction, f"{{{XS_NS}}}enumeration")
|
||||
e.set("value", lit)
|
||||
print(f' + значение "{lit}" в тип {args.Target}')
|
||||
|
||||
elif op == "add-import":
|
||||
for uri in [x.strip() for x in args.Value.split(";;") if x.strip()]:
|
||||
if any(i.get("namespace") == uri for i in xs_children(schema, "import")):
|
||||
print(f" = импорт {uri} уже объявлен")
|
||||
continue
|
||||
imp = etree.Element(f"{{{XS_NS}}}import")
|
||||
imp.set("namespace", uri)
|
||||
first_other = next((c for c in schema if isinstance(c.tag, str)
|
||||
and local(c) not in ("annotation", "import")), None)
|
||||
if first_other is not None:
|
||||
first_other.addprevious(imp)
|
||||
else:
|
||||
schema.append(imp)
|
||||
print(f" + импорт {uri}")
|
||||
|
||||
elif op == "set-namespace":
|
||||
if not args.Value:
|
||||
die("set-namespace требует -Value <URI>")
|
||||
old = schema.get("targetNamespace")
|
||||
if old == args.Value:
|
||||
# Установка того же значения не отбрасывается: пакет пересобирается вхолостую
|
||||
print(f" = namespace уже {args.Value}, пакет пересобран без изменений")
|
||||
else:
|
||||
print(f" ~ namespace: {old} → {args.Value}")
|
||||
# Меняем и targetNamespace, и объявление префикса, который на него указывал
|
||||
new_nsmap = {px: (args.Value if uri == old else uri) for px, uri in schema.nsmap.items()}
|
||||
rebuilt = etree.Element(schema.tag, nsmap=new_nsmap)
|
||||
for k, v in schema.attrib.items():
|
||||
rebuilt.set(k, v)
|
||||
rebuilt.set("targetNamespace", args.Value)
|
||||
rebuilt.text = schema.text
|
||||
for c in list(schema):
|
||||
rebuilt.append(c)
|
||||
return rebuilt
|
||||
return schema
|
||||
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────
|
||||
|
||||
assert_siblings_present(args.Operation)
|
||||
|
||||
print(f"Пакет: {pkg_name}")
|
||||
old_namespace = None
|
||||
|
||||
if args.Operation == "rename":
|
||||
if not args.Value:
|
||||
die("rename требует -Value <НовоеИмя>")
|
||||
rename_package(args.Value)
|
||||
elif args.Operation == "set-synonym":
|
||||
if not args.Value:
|
||||
die("set-synonym требует -Value <текст>")
|
||||
edit_metadata("Synonym", args.Value)
|
||||
print(f"✓ Синоним: {args.Value}")
|
||||
elif args.Operation == "set-comment":
|
||||
edit_metadata("Comment", args.Value)
|
||||
print("✓ Комментарий обновлён")
|
||||
else:
|
||||
tmp_dir = tempfile.mkdtemp(prefix="xdto-edit_")
|
||||
try:
|
||||
xsd_path = os.path.join(tmp_dir, "schema.xsd")
|
||||
invoke_sibling(DECOMPILE, ["-PackagePath", bin_file, "-OutFile", xsd_path], "xdto-decompile")
|
||||
|
||||
doc = _parse_xml(xsd_path)
|
||||
schema = doc.getroot()
|
||||
old_namespace = schema.get("targetNamespace")
|
||||
schema = apply_model_operation(schema)
|
||||
|
||||
with open(xsd_path, "wb") as f:
|
||||
f.write(etree.tostring(schema, xml_declaration=True, encoding="UTF-8"))
|
||||
invoke_sibling(COMPILE, ["-XsdPath", xsd_path, "-OutputDir", config_root,
|
||||
"-Name", pkg_name, "-Force"], "xdto-compile")
|
||||
|
||||
if args.Operation == "set-namespace":
|
||||
edit_metadata("Namespace", args.Value)
|
||||
# Зависящие пакеты не трогаем: при версионировании они обязаны продолжать
|
||||
# смотреть на прежний namespace. Но молчать о них нельзя.
|
||||
dependents = []
|
||||
for d in sorted(os.listdir(xdto_root)):
|
||||
if d == pkg_name or not os.path.isdir(os.path.join(xdto_root, d)):
|
||||
continue
|
||||
ob = os.path.join(xdto_root, d, "Ext", "Package.bin")
|
||||
if not os.path.exists(ob):
|
||||
continue
|
||||
try:
|
||||
for imp in _parse_xml(ob).getroot():
|
||||
if isinstance(imp.tag, str) and local(imp) == "import" \
|
||||
and imp.get("namespace") == old_namespace:
|
||||
dependents.append(d)
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if dependents:
|
||||
print("")
|
||||
print(f"WARNING: Старый namespace импортируют пакеты ({len(dependents)}): "
|
||||
+ ", ".join(dependents)
|
||||
+ ". Они не изменены — при версионировании это верно; "
|
||||
"если нет, поправьте их импорты.", file=sys.stderr)
|
||||
print(f"✓ Пакет пересобран: XDTOPackages/{pkg_name}/Ext/Package.bin")
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
if not args.NoValidate:
|
||||
if os.path.exists(VALIDATE):
|
||||
print("")
|
||||
print("--- xdto-validate ---")
|
||||
subprocess.run([sys.executable, VALIDATE, "-PackagePath", os.path.join(xdto_root, pkg_name)])
|
||||
else:
|
||||
print(f"[SKIP] xdto-validate не найден: {VALIDATE}")
|
||||
sys.exit(0)
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
name: xdto-info
|
||||
description: Анализ структуры пакета XDTO 1С — типы, свойства, точки входа. Используй как подготовительный шаг при написании кода, создающего и заполняющего объект XDTO, при разборе входящего XML, а также чтобы узнать, какие пакеты есть в конфигурации
|
||||
argument-hint: <PackagePath> [-Namespace <URI>|-Package <имя>] [-Name <Тип>] [-Depth N] [-RequiredOnly] [-Mode used-by] [-Limit N] [-Offset N] [-OutFile <файл>]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /xdto-info — Анализ структуры пакета XDTO
|
||||
|
||||
Показывает структуру типа в терминах 1С: какой тип значения присваивать, что обязательно,
|
||||
где нужен вложенный объект, какие значения допустимы. Заменяет чтение `Package.bin`
|
||||
или XSD с ручным переводом `xs:decimal` → `Число` и `lowerBound="0"` → необязательный.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `PackagePath` | да | Каталог пакета либо корень исходников конфигурации. Псевдоним — `-Path` |
|
||||
| `Namespace` | нет | Выбрать пакет по пространству имён (когда путь — корень исходников) |
|
||||
| `Package` | нет | Выбрать пакет по имени объекта метаданных |
|
||||
| `Name` | нет | Имя типа. Без выбранного пакета ищется по всей конфигурации |
|
||||
| `Depth` | нет | Глубина разузлования вложенных объектов. По умолчанию 1 |
|
||||
| `RequiredOnly` | нет | Оставить только обязательные свойства — скелет для «заполни обязательное». Необязательный объект уходит вместе со своим содержимым |
|
||||
| `Mode` | нет | `used-by` — показать, кто ссылается на тип |
|
||||
| `Limit` / `Offset` | нет | Пагинация. По умолчанию 150 строк |
|
||||
| `OutFile` | нет | Записать результат в файл (UTF-8 BOM) |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/xdto-info/scripts/xdto-info.py" -PackagePath "<путь>"
|
||||
```
|
||||
|
||||
## Что показывает
|
||||
|
||||
Точка входа определяется по пути: корень исходников — список пакетов, каталог пакета —
|
||||
его состав.
|
||||
|
||||
| Вызов | Результат |
|
||||
|---|---|
|
||||
| `-PackagePath src` | все пакеты конфигурации: имя, число типов, namespace |
|
||||
| `-PackagePath src/XDTOPackages/ОбменСБанком` | импорты, точки входа, списки типов |
|
||||
| `... -Name ПлатежныйДокумент` | структура типа для заполнения |
|
||||
| `... -Name ПлатежныйДокумент -Depth 3` | то же с раскрытием вложенных объектов |
|
||||
| `... -Mode used-by -Name СуммаТип` | кто ссылается на тип, включая соседние пакеты |
|
||||
|
||||
## Когда известны namespace и тип, но не имя пакета
|
||||
|
||||
Так бывает чаще всего: namespace и имя типа видны в коде или в образце XML,
|
||||
а как называется пакет — нет. Вызов повторяет строку, от которой отталкиваешься:
|
||||
|
||||
```powershell
|
||||
# ФабрикаXDTO.Тип("urn:1C.ru:ClientBankExchange", "ПлатежныйДокумент")
|
||||
... -PackagePath src -Namespace "urn:1C.ru:ClientBankExchange" -Name ПлатежныйДокумент
|
||||
```
|
||||
|
||||
Если известно только имя типа — укажи `-Name` и корень исходников: тип найдётся
|
||||
по всем пакетам. При нескольких совпадениях навык покажет, где именно, чтобы уточнить.
|
||||
|
||||
## Что в выводе
|
||||
|
||||
Свойства показаны так, как их предстоит заполнять в коде: тип значения — в нотации
|
||||
1С и с учётом ограничений (`Строка(6)`, `Число(18,2)`), обязательность и коллекции —
|
||||
флагами, для перечислимых типов перечислены допустимые значения. Непомеченное
|
||||
свойство необязательно.
|
||||
|
||||
Обозначения, которые сами по себе неочевидны, навык поясняет прямо в выводе —
|
||||
и только те, что в нём встретились.
|
||||
|
||||
## Типичный workflow
|
||||
|
||||
1. `/xdto-info src` — какие пакеты есть
|
||||
2. `/xdto-info src -Namespace "<URI>"` — точки входа и типы пакета
|
||||
3. `/xdto-info src -Namespace "<URI>" -Name <Тип> -Depth 2` — структура для кода
|
||||
4. Перед правкой типа: `-Mode used-by -Name <Тип>` — кого затронет
|
||||
|
||||
Нужна сама XML-схема, а не сводка, — это `/xdto-decompile`.
|
||||
@@ -1,694 +0,0 @@
|
||||
# xdto-info v1.0 — Analyze 1C XDTO package structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Alias('Path')]
|
||||
[string]$PackagePath,
|
||||
[string]$Package,
|
||||
[string]$Namespace,
|
||||
[string]$Name,
|
||||
[ValidateSet("auto", "used-by")]
|
||||
[string]$Mode = "auto",
|
||||
[int]$Depth = 1,
|
||||
[switch]$RequiredOnly,
|
||||
[int]$Limit = 150,
|
||||
[int]$Offset = 0,
|
||||
[string]$OutFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
$XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
$XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
$MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
|
||||
# --- Output ---
|
||||
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
function O([string]$line = "") { [void]$sb.AppendLine($line) }
|
||||
|
||||
function Fail([string]$msg) {
|
||||
# Отрицательный результат поиска — не исключение: печатаем сообщение
|
||||
# и выходим с кодом 1, без стектрейса PowerShell
|
||||
Write-Host $msg
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Flush-Output {
|
||||
$text = $sb.ToString().TrimEnd()
|
||||
if ($OutFile) {
|
||||
$dir = [System.IO.Path]::GetDirectoryName($OutFile)
|
||||
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
[System.IO.File]::WriteAllText($OutFile, $text + "`r`n", (New-Object System.Text.UTF8Encoding($true)))
|
||||
Write-Host "✓ Записано: $OutFile"
|
||||
} else {
|
||||
Write-Host $text
|
||||
}
|
||||
}
|
||||
|
||||
# --- Path resolution -----------------------------------------------------------
|
||||
# Путь может указывать на корень конфигурации (тогда работаем со всеми пакетами)
|
||||
# либо на конкретный пакет.
|
||||
|
||||
if (-not [System.IO.Path]::IsPathRooted($PackagePath)) {
|
||||
$PackagePath = Join-Path (Get-Location).Path $PackagePath
|
||||
}
|
||||
if (-not (Test-Path $PackagePath)) { Fail "Путь не найден: $PackagePath" }
|
||||
|
||||
$configRoot = $null
|
||||
$directPkgDir = $null
|
||||
|
||||
if (Test-Path (Join-Path $PackagePath "Configuration.xml")) {
|
||||
$configRoot = $PackagePath
|
||||
} elseif ((Split-Path $PackagePath -Leaf) -eq "XDTOPackages") {
|
||||
$configRoot = Split-Path $PackagePath -Parent
|
||||
} elseif (Test-Path (Join-Path (Join-Path $PackagePath "Ext") "Package.bin")) {
|
||||
$directPkgDir = $PackagePath
|
||||
$pkgRoot = Split-Path $PackagePath -Parent
|
||||
$configRoot = Split-Path $pkgRoot -Parent
|
||||
} elseif ((Test-Path $PackagePath -PathType Leaf) -and ([System.IO.Path]::GetFileName($PackagePath) -eq "Package.bin")) {
|
||||
$directPkgDir = Split-Path (Split-Path $PackagePath -Parent) -Parent
|
||||
$configRoot = Split-Path (Split-Path $directPkgDir -Parent) -Parent
|
||||
} elseif ($PackagePath.EndsWith(".xml")) {
|
||||
$stem = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($PackagePath),
|
||||
[System.IO.Path]::GetFileNameWithoutExtension($PackagePath))
|
||||
if (Test-Path (Join-Path (Join-Path $stem "Ext") "Package.bin")) {
|
||||
$directPkgDir = $stem
|
||||
$configRoot = Split-Path (Split-Path $stem -Parent) -Parent
|
||||
}
|
||||
}
|
||||
if (-not $configRoot -and -not $directPkgDir) { Fail "Не удалось определить пакет или конфигурацию по пути: $PackagePath" }
|
||||
|
||||
# Sort-Object в PowerShell сортирует по культуре, sorted() в Python — по кодам.
|
||||
# Для паритета портов сортируем ординально в обоих.
|
||||
function Sort-Ordinal($items) {
|
||||
$arr = [string[]]@($items)
|
||||
[array]::Sort($arr, [StringComparer]::Ordinal)
|
||||
return ,$arr
|
||||
}
|
||||
|
||||
# --- Package index -------------------------------------------------------------
|
||||
|
||||
function Read-Package([string]$pkgDir) {
|
||||
$bin = Join-Path (Join-Path $pkgDir "Ext") "Package.bin"
|
||||
if (-not (Test-Path $bin)) { return $null }
|
||||
try {
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
$doc.Load($bin)
|
||||
} catch { return $null }
|
||||
$root = $doc.DocumentElement
|
||||
if ($root.get_LocalName() -ne "package") { return $null }
|
||||
|
||||
$info = [pscustomobject]@{
|
||||
Name = [System.IO.Path]::GetFileName($pkgDir)
|
||||
Dir = $pkgDir
|
||||
Namespace = $root.GetAttribute("targetNamespace")
|
||||
Root = $root
|
||||
Imports = (New-Object System.Collections.ArrayList)
|
||||
Types = @{}
|
||||
GlobalProps= (New-Object System.Collections.ArrayList)
|
||||
}
|
||||
foreach ($n in $root.ChildNodes) {
|
||||
if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
switch ($n.get_LocalName()) {
|
||||
"import" { [void]$info.Imports.Add($n.GetAttribute("namespace")) }
|
||||
"objectType" { $info.Types[$n.GetAttribute("name")] = $n }
|
||||
"valueType" { $info.Types[$n.GetAttribute("name")] = $n }
|
||||
"property" { [void]$info.GlobalProps.Add($n) }
|
||||
}
|
||||
}
|
||||
return $info
|
||||
}
|
||||
|
||||
$packages = New-Object System.Collections.ArrayList
|
||||
$byNamespace = @{}
|
||||
|
||||
if ($configRoot -and (Test-Path (Join-Path $configRoot "XDTOPackages"))) {
|
||||
foreach ($dn in (Sort-Ordinal ((Get-ChildItem (Join-Path $configRoot "XDTOPackages") -Directory -ErrorAction SilentlyContinue).Name))) {
|
||||
$p = Read-Package (Join-Path (Join-Path $configRoot "XDTOPackages") $dn)
|
||||
if ($p) {
|
||||
[void]$packages.Add($p)
|
||||
if (-not $byNamespace.ContainsKey($p.Namespace)) { $byNamespace[$p.Namespace] = $p }
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($directPkgDir -and $packages.Count -eq 0) {
|
||||
$p = Read-Package $directPkgDir
|
||||
if ($p) { [void]$packages.Add($p); $byNamespace[$p.Namespace] = $p }
|
||||
}
|
||||
if ($packages.Count -eq 0) { Fail "Пакеты XDTO не найдены: $PackagePath" }
|
||||
|
||||
# --- Type notation: XSD -> 1С ---------------------------------------------------
|
||||
|
||||
$XS_TO_1C = @{
|
||||
"string" = "Строка"; "normalizedString" = "Строка"; "token" = "Строка"; "NCName" = "Строка"
|
||||
"Name" = "Строка"; "QName" = "Строка"; "anyURI" = "Строка"; "language" = "Строка"
|
||||
"ID" = "Строка"; "IDREF" = "Строка"; "NMTOKEN" = "Строка"
|
||||
"decimal" = "Число"; "integer" = "Число"; "int" = "Число"; "long" = "Число"; "short" = "Число"
|
||||
"byte" = "Число"; "float" = "Число"; "double" = "Число"
|
||||
"nonNegativeInteger" = "Число"; "positiveInteger" = "Число"; "nonPositiveInteger" = "Число"
|
||||
"negativeInteger" = "Число"; "unsignedInt" = "Число"; "unsignedLong" = "Число"
|
||||
"unsignedShort" = "Число"; "unsignedByte" = "Число"
|
||||
"date" = "Дата"; "dateTime" = "Дата"; "time" = "Дата"
|
||||
"boolean" = "Булево"
|
||||
"base64Binary" = "ДвоичныеДанные"; "hexBinary" = "ДвоичныеДанные"
|
||||
"anyType" = "произвольный"; "anySimpleType" = "произвольный"
|
||||
}
|
||||
|
||||
function Split-Ref([System.Xml.XmlElement]$el, [string]$raw) {
|
||||
if (-not $raw) { return $null }
|
||||
if ($raw.StartsWith("{")) {
|
||||
$close = $raw.IndexOf("}")
|
||||
if ($close -lt 0) { return $null }
|
||||
return [pscustomobject]@{ Ns = $raw.Substring(1, $close - 1); Local = $raw.Substring($close + 1) }
|
||||
}
|
||||
$parts = $raw.Split(":")
|
||||
if ($parts.Count -eq 2) {
|
||||
return [pscustomobject]@{ Ns = $el.GetNamespaceOfPrefix($parts[0]); Local = $parts[1] }
|
||||
}
|
||||
return [pscustomobject]@{ Ns = $null; Local = $parts[0] }
|
||||
}
|
||||
|
||||
function Get-Facets([System.Xml.XmlElement]$t) {
|
||||
$res = @{}
|
||||
foreach ($f in @("length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive")) {
|
||||
$v = $t.GetAttribute($f)
|
||||
if ($v) { $res[$f] = $v }
|
||||
}
|
||||
$pat = $null
|
||||
foreach ($c in $t.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "pattern") { $pat = $c.InnerText; break }
|
||||
}
|
||||
if ($pat) { $res["pattern"] = $pat }
|
||||
return $res
|
||||
}
|
||||
|
||||
function Get-Enumerations([System.Xml.XmlElement]$t) {
|
||||
$vals = @()
|
||||
foreach ($c in $t.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "enumeration") { $vals += $c.InnerText }
|
||||
}
|
||||
return $vals
|
||||
}
|
||||
|
||||
# Разворачивает цепочку псевдонимов до примитива, собирая фасеты по пути.
|
||||
# Возвращает @{ Base1C; Facets; Alias; Enum; Kind }
|
||||
function Resolve-Scalar([System.Xml.XmlElement]$t, $pkg, [int]$guard = 0) {
|
||||
$acc = @{ Base1C = $null; Facets = @{}; Alias = $null; Enum = @(); Kind = "scalar" }
|
||||
if ($guard -gt 10 -or -not $t) { return $acc }
|
||||
|
||||
$variety = $t.GetAttribute("variety")
|
||||
if ($variety -eq "List") {
|
||||
$it = Split-Ref $t $t.GetAttribute("itemType")
|
||||
$acc.Kind = "list"
|
||||
$acc.Base1C = "список " + $(if ($it) { Format-RefName $it $pkg } else { "значений" })
|
||||
return $acc
|
||||
}
|
||||
if ($variety -eq "Union" -or $t.GetAttribute("memberTypes")) {
|
||||
$members = @()
|
||||
foreach ($m in (($t.GetAttribute("memberTypes") -split "\s+") | Where-Object { $_ })) {
|
||||
$q = Split-Ref $t $m
|
||||
if ($q) { $members += (Format-RefName $q $pkg) }
|
||||
}
|
||||
foreach ($c in $t.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") {
|
||||
$inner = Resolve-Scalar $c $pkg ($guard + 1)
|
||||
$members += $inner.Base1C
|
||||
}
|
||||
}
|
||||
$acc.Kind = "union"
|
||||
$acc.Base1C = "одно из (" + (($members | Where-Object { $_ }) -join " | ") + ")"
|
||||
return $acc
|
||||
}
|
||||
|
||||
$acc.Facets = Get-Facets $t
|
||||
$acc.Enum = Get-Enumerations $t
|
||||
|
||||
# базовый тип: атрибут base или вложенный анонимный typeDef
|
||||
$baseQ = Split-Ref $t $t.GetAttribute("base")
|
||||
$anonBase = $null
|
||||
foreach ($c in $t.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") { $anonBase = $c; break }
|
||||
}
|
||||
if (-not $baseQ -and $anonBase) {
|
||||
$inner = Resolve-Scalar $anonBase $pkg ($guard + 1)
|
||||
$acc.Base1C = $inner.Base1C
|
||||
foreach ($k in $inner.Facets.Keys) { if (-not $acc.Facets.ContainsKey($k)) { $acc.Facets[$k] = $inner.Facets[$k] } }
|
||||
if ($inner.Enum.Count -gt 0 -and $acc.Enum.Count -eq 0) { $acc.Enum = $inner.Enum }
|
||||
return $acc
|
||||
}
|
||||
if (-not $baseQ) { $acc.Base1C = "произвольный"; return $acc }
|
||||
|
||||
if ($baseQ.Ns -eq $XS_NS) {
|
||||
$acc.Base1C = $(if ($XS_TO_1C.ContainsKey($baseQ.Local)) { $XS_TO_1C[$baseQ.Local] } else { "xs:$($baseQ.Local)" })
|
||||
return $acc
|
||||
}
|
||||
|
||||
# база — именованный тип значения: разворачиваем дальше
|
||||
$target = Find-Type $baseQ $pkg
|
||||
if ($target -and $target.Element.get_LocalName() -eq "valueType") {
|
||||
$inner = Resolve-Scalar $target.Element $target.Package ($guard + 1)
|
||||
$acc.Base1C = $inner.Base1C
|
||||
foreach ($k in $inner.Facets.Keys) { if (-not $acc.Facets.ContainsKey($k)) { $acc.Facets[$k] = $inner.Facets[$k] } }
|
||||
if ($inner.Enum.Count -gt 0 -and $acc.Enum.Count -eq 0) { $acc.Enum = $inner.Enum }
|
||||
if (-not $acc.Alias) { $acc.Alias = $baseQ.Local }
|
||||
return $acc
|
||||
}
|
||||
$acc.Base1C = $baseQ.Local
|
||||
return $acc
|
||||
}
|
||||
|
||||
function Find-Type($q, $pkg) {
|
||||
if (-not $q) { return $null }
|
||||
$targetPkg = $null
|
||||
if (-not $q.Ns -or ($pkg -and $q.Ns -eq $pkg.Namespace)) { $targetPkg = $pkg }
|
||||
elseif ($byNamespace.ContainsKey($q.Ns)) { $targetPkg = $byNamespace[$q.Ns] }
|
||||
if (-not $targetPkg) { return $null }
|
||||
if (-not $targetPkg.Types.ContainsKey($q.Local)) { return $null }
|
||||
return [pscustomobject]@{ Element = $targetPkg.Types[$q.Local]; Package = $targetPkg }
|
||||
}
|
||||
|
||||
function Format-RefName($q, $pkg) {
|
||||
if (-not $q) { return "" }
|
||||
if ($q.Ns -eq $XS_NS) {
|
||||
return $(if ($XS_TO_1C.ContainsKey($q.Local)) { $XS_TO_1C[$q.Local] } else { "xs:$($q.Local)" })
|
||||
}
|
||||
return $q.Local
|
||||
}
|
||||
|
||||
function Format-Scalar($res) {
|
||||
$t = $res.Base1C
|
||||
$f = $res.Facets
|
||||
if ($t -eq "Строка") {
|
||||
if ($f.ContainsKey("length")) { $t = "Строка($($f['length']))" }
|
||||
elseif ($f.ContainsKey("maxLength")) { $t = "Строка($($f['maxLength']))" }
|
||||
} elseif ($t -eq "Число") {
|
||||
if ($f.ContainsKey("totalDigits")) {
|
||||
$frac = $(if ($f.ContainsKey("fractionDigits")) { $f["fractionDigits"] } else { "0" })
|
||||
$t = "Число($($f['totalDigits']),$frac)"
|
||||
}
|
||||
}
|
||||
return $t
|
||||
}
|
||||
|
||||
function Format-Notes($res) {
|
||||
$notes = @()
|
||||
if ($res.Alias) { $notes += "← $($res.Alias)" }
|
||||
if ($res.Facets.ContainsKey("pattern")) {
|
||||
$p = $res.Facets["pattern"]
|
||||
if ($p.Length -gt 40) { $p = $p.Substring(0, 40) + "…" }
|
||||
$notes += "шаблон $p"
|
||||
}
|
||||
foreach ($k in @("minInclusive", "maxInclusive", "minExclusive", "maxExclusive")) {
|
||||
if ($res.Facets.ContainsKey($k)) { $notes += "$k $($res.Facets[$k])" }
|
||||
}
|
||||
return $notes
|
||||
}
|
||||
|
||||
# --- Property rendering ---------------------------------------------------------
|
||||
|
||||
function Get-PropRows([System.Xml.XmlElement]$type, $pkg, [int]$depth, [int]$indent, $seen) {
|
||||
$rows = New-Object System.Collections.ArrayList
|
||||
foreach ($p in $type.ChildNodes) {
|
||||
if ($p.NodeType -ne [System.Xml.XmlNodeType]::Element -or $p.get_LocalName() -ne "property") { continue }
|
||||
|
||||
$pname = $p.GetAttribute("name")
|
||||
if (-not $pname) {
|
||||
$refQ = Split-Ref $p $p.GetAttribute("ref")
|
||||
$pname = $(if ($refQ) { $refQ.Local } else { "(без имени)" })
|
||||
}
|
||||
$lower = $p.GetAttribute("lowerBound")
|
||||
$upper = $p.GetAttribute("upperBound")
|
||||
$flags = @()
|
||||
# В модели умолчание lowerBound = 1; помечаем обязательные, как в meta-info
|
||||
if ($lower -ne "0") { $flags += "обязательный" }
|
||||
if ($upper -eq "-1") { $flags += "список" }
|
||||
elseif ($upper -and $upper -ne "1") { $flags += "до $upper" }
|
||||
if ($p.GetAttribute("form") -eq "Text") { $flags += "значение элемента" }
|
||||
|
||||
$notes = @()
|
||||
$typeText = ""
|
||||
$children = $null
|
||||
$childPkg = $pkg
|
||||
# Именованный вложенный тип берётся так же, как корневой; окольный путь
|
||||
# через свойство владельца нужен только анонимному — имени у него нет
|
||||
$objName = $null
|
||||
$objNs = $null
|
||||
|
||||
$anon = $null
|
||||
foreach ($c in $p.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "typeDef") { $anon = $c; break }
|
||||
}
|
||||
|
||||
if ($anon) {
|
||||
if ($anon.GetAttribute("type", $XSI_NS) -eq "ObjectType") {
|
||||
$typeText = "объект (анонимный)"
|
||||
$objName = "(анонимный)"
|
||||
$children = $anon # анонимные раскрываем всегда: смотреть отдельно негде
|
||||
} else {
|
||||
$res = Resolve-Scalar $anon $pkg
|
||||
$typeText = Format-Scalar $res
|
||||
$notes += (Format-Notes $res)
|
||||
if ($res.Enum.Count -gt 0) { $notes += "значения: " + (($res.Enum | Select-Object -First 8) -join ", ") }
|
||||
}
|
||||
} else {
|
||||
$q = Split-Ref $p $p.GetAttribute("type")
|
||||
if (-not $q) {
|
||||
$typeText = "произвольный"
|
||||
} elseif ($q.Ns -eq $XS_NS) {
|
||||
$typeText = $(if ($XS_TO_1C.ContainsKey($q.Local)) { $XS_TO_1C[$q.Local] } else { "xs:$($q.Local)" })
|
||||
} else {
|
||||
$target = Find-Type $q $pkg
|
||||
if (-not $target) {
|
||||
$typeText = "объект $($q.Local)"
|
||||
$notes += "(пакет не найден: $($q.Ns))"
|
||||
} elseif ($target.Element.get_LocalName() -eq "objectType") {
|
||||
$typeText = "объект $($q.Local)"
|
||||
if ($target.Package.Namespace -ne $pkg.Namespace) { $typeText += " · $($target.Package.Name)" }
|
||||
$objName = $q.Local
|
||||
$objNs = $target.Package.Namespace
|
||||
$children = $target.Element
|
||||
$childPkg = $target.Package
|
||||
} else {
|
||||
$res = Resolve-Scalar $target.Element $target.Package
|
||||
$typeText = Format-Scalar $res
|
||||
$notes += "← $($q.Local)"
|
||||
$n2 = Format-Notes $res | Where-Object { -not $_.StartsWith("←") }
|
||||
$notes += $n2
|
||||
if ($res.Enum.Count -gt 0) { $notes += "значения: " + (($res.Enum | Select-Object -First 8) -join ", ") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[void]$rows.Add([pscustomobject]@{
|
||||
Indent = $indent; Name = $pname; Type = $typeText
|
||||
Flags = $flags; Notes = ($notes | Where-Object { $_ })
|
||||
ObjName = $objName; ObjNs = $objNs
|
||||
})
|
||||
|
||||
if ($children) {
|
||||
$key = "$($childPkg.Namespace)#$($children.GetAttribute('name'))"
|
||||
$isAnon = -not $children.GetAttribute("name")
|
||||
if (-not $isAnon -and $seen.Contains($key)) {
|
||||
[void]$rows.Add([pscustomobject]@{ Indent = $indent + 1; Name = "(раскрыт выше)"; Type = ""; Flags = @(); Notes = @() })
|
||||
} elseif ($isAnon -or $depth -gt 1) {
|
||||
$nextSeen = New-Object System.Collections.Generic.HashSet[string] (,[string[]]$seen)
|
||||
if (-not $isAnon) { [void]$nextSeen.Add($key) }
|
||||
$nextDepth = $(if ($isAnon) { $depth } else { $depth - 1 })
|
||||
foreach ($r in (Get-PropRows $children $childPkg $nextDepth ($indent + 1) $nextSeen)) { [void]$rows.Add($r) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return ,$rows
|
||||
}
|
||||
|
||||
# Оставить только обязательные свойства. Ребёнок необязательного объекта тоже
|
||||
# уходит: он лежит под необязательной веткой и заполнять его не обязательно.
|
||||
function Select-Required($rows) {
|
||||
$res = New-Object System.Collections.ArrayList
|
||||
$cutFrom = -1
|
||||
foreach ($r in $rows) {
|
||||
if ($cutFrom -ge 0 -and $r.Indent -gt $cutFrom) { continue }
|
||||
$cutFrom = -1
|
||||
if ($r.Flags -notcontains "обязательный") { $cutFrom = $r.Indent; continue }
|
||||
[void]$res.Add($r)
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Write-Rows($rows) {
|
||||
if ($rows.Count -eq 0) { O " (нет свойств)"; return }
|
||||
$shown = $rows
|
||||
if ($Offset -gt 0 -or $rows.Count -gt $Limit) {
|
||||
$end = [Math]::Min($Offset + $Limit, $rows.Count) - 1
|
||||
if ($Offset -le $end) { $shown = $rows[$Offset..$end] } else { $shown = @() }
|
||||
}
|
||||
$wName = 0; $wType = 0
|
||||
foreach ($r in $shown) {
|
||||
$n = (" " * $r.Indent) + $r.Name
|
||||
if ($n.Length -gt $wName) { $wName = $n.Length }
|
||||
if ($r.Type.Length -gt $wType) { $wType = $r.Type.Length }
|
||||
}
|
||||
foreach ($r in $shown) {
|
||||
$n = (" " * $r.Indent) + $r.Name
|
||||
$line = " " + $n.PadRight($wName + 2) + $r.Type.PadRight($wType + 2)
|
||||
if ($r.Flags.Count -gt 0) { $line += "[" + ($r.Flags -join ", ") + "] " }
|
||||
if ($r.Notes.Count -gt 0) { $line += ($r.Notes -join ", ") }
|
||||
O $line.TrimEnd()
|
||||
}
|
||||
if ($rows.Count -gt $shown.Count) {
|
||||
O " … показано $($shown.Count) из $($rows.Count); листать через -Offset/-Limit"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Modes ----------------------------------------------------------------------
|
||||
|
||||
function Show-PackageList {
|
||||
O "=== Пакеты XDTO: $($packages.Count) ==="
|
||||
O ""
|
||||
$shown = $packages
|
||||
if ($Offset -gt 0 -or $packages.Count -gt $Limit) {
|
||||
$end = [Math]::Min($Offset + $Limit, $packages.Count) - 1
|
||||
if ($Offset -le $end) { $shown = $packages[$Offset..$end] } else { $shown = @() }
|
||||
}
|
||||
$wn = 0
|
||||
foreach ($p in $shown) { if ($p.Name.Length -gt $wn) { $wn = $p.Name.Length } }
|
||||
foreach ($p in $shown) {
|
||||
$cnt = $p.Types.Count
|
||||
O (" " + $p.Name.PadRight($wn + 2) + "$cnt".PadLeft(4) + " " + $p.Namespace)
|
||||
}
|
||||
if ($packages.Count -gt $shown.Count) {
|
||||
O ""
|
||||
O " … показано $($shown.Count) из $($packages.Count); листать через -Offset/-Limit"
|
||||
}
|
||||
O ""
|
||||
O "Колонки: имя пакета, число типов, namespace."
|
||||
O "Следующий шаг: -Package <имя> или -Namespace <URI> — состав пакета; -Name <Тип> — поиск типа по всем пакетам"
|
||||
}
|
||||
|
||||
function Show-PackageOverview($pkg) {
|
||||
O "=== Пакет XDTO: $($pkg.Name) ==="
|
||||
O "Namespace: $($pkg.Namespace)"
|
||||
if ($pkg.Imports.Count -gt 0) {
|
||||
O ""
|
||||
O "Импорты ($($pkg.Imports.Count)):"
|
||||
foreach ($i in $pkg.Imports) {
|
||||
$dep = $(if ($byNamespace.ContainsKey($i)) { $byNamespace[$i].Name } else { "(пакет не найден)" })
|
||||
O " $i → $dep"
|
||||
}
|
||||
}
|
||||
if ($pkg.GlobalProps.Count -eq 0) {
|
||||
O ""
|
||||
O "Точки входа: нет — пакет не объявляет корневых элементов документа"
|
||||
} else {
|
||||
O ""
|
||||
O "Точки входа ($($pkg.GlobalProps.Count)) — корневые элементы документа:"
|
||||
foreach ($gp in $pkg.GlobalProps) {
|
||||
$q = Split-Ref $gp $gp.GetAttribute("type")
|
||||
$tn = $(if ($q) { Format-RefName $q $pkg } else { "произвольный" })
|
||||
$form = $(if ($gp.GetAttribute("form") -eq "Attribute") { " (атрибут)" } else { "" })
|
||||
O (" <" + $gp.GetAttribute("name") + "> → " + $tn + $form)
|
||||
}
|
||||
}
|
||||
$objs = @(); $vals = @()
|
||||
foreach ($k in (Sort-Ordinal $pkg.Types.Keys)) {
|
||||
if ($pkg.Types[$k].get_LocalName() -eq "objectType") { $objs += $k } else { $vals += $k }
|
||||
}
|
||||
if ($objs.Count -gt 0) {
|
||||
O ""
|
||||
O "Объектные типы ($($objs.Count)):"
|
||||
foreach ($n in $objs) {
|
||||
$cnt = 0
|
||||
foreach ($c in $pkg.Types[$n].ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element -and $c.get_LocalName() -eq "property") { $cnt++ }
|
||||
}
|
||||
$base = Split-Ref $pkg.Types[$n] $pkg.Types[$n].GetAttribute("base")
|
||||
$suffix = $(if ($base) { " ← $($base.Local)" } else { "" })
|
||||
O (" " + $n.PadRight(40) + "свойств: $cnt" + $suffix)
|
||||
}
|
||||
}
|
||||
if ($vals.Count -gt 0) {
|
||||
O ""
|
||||
O "Типы значений ($($vals.Count)):"
|
||||
foreach ($n in $vals) {
|
||||
$res = Resolve-Scalar $pkg.Types[$n] $pkg
|
||||
$line = " " + $n.PadRight(40) + (Format-Scalar $res)
|
||||
if ($res.Enum.Count -gt 0) { $line += " значения: " + (($res.Enum | Select-Object -First 6) -join ", ") }
|
||||
O $line
|
||||
}
|
||||
}
|
||||
O ""
|
||||
O "Следующий шаг: -Name <Тип> — структура типа для заполнения"
|
||||
}
|
||||
|
||||
# Легенда едет вместе с выводом, а не живёт в инструкции: показываем только те
|
||||
# обозначения, которые реально встретились, иначе она сама становится шумом.
|
||||
function Write-Legend($rows) {
|
||||
$text = ($rows | ForEach-Object { $_.Type + " " + ($_.Flags -join ",") + " " + ($_.Notes -join ",") }) -join " "
|
||||
$items = @()
|
||||
if ($text -match "объект ") { $items += "объект X — присвоить вложенный объект XDTO, состав раскрывает -Depth" }
|
||||
if ($text -match "←") { $items += "← Имя — исходный тип из схемы, слева от стрелки развёрнутое значение" }
|
||||
if ($text -match "список") { $items += "список — коллекция, заполняется через .Добавить()" }
|
||||
if ($text -match "до \d") { $items += "до N — коллекция с ограничением сверху" }
|
||||
if ($text -match "значение элемента") { $items += "значение элемента — собственное значение узла XML" }
|
||||
if ($text -match "·") { $items += "· Пакет — тип объявлен в другом пакете" }
|
||||
if ($items.Count -eq 0) { return }
|
||||
O ""
|
||||
O "Обозначения:"
|
||||
foreach ($i in $items) { O " $i" }
|
||||
}
|
||||
|
||||
function Show-Type($pkg, [string]$typeName) {
|
||||
$el = $pkg.Types[$typeName]
|
||||
$kind = $el.get_LocalName()
|
||||
if ($kind -eq "valueType") {
|
||||
$res = Resolve-Scalar $el $pkg
|
||||
O "=== Тип значения XDTO: $typeName ==="
|
||||
O "Пакет: $($pkg.Name) · $($pkg.Namespace)"
|
||||
O ""
|
||||
O "Значение: $(Format-Scalar $res)"
|
||||
foreach ($n in (Format-Notes $res)) { O " $n" }
|
||||
if ($res.Enum.Count -gt 0) {
|
||||
O ""
|
||||
O "Допустимые значения ($($res.Enum.Count)):"
|
||||
foreach ($v in $res.Enum) { O " $v" }
|
||||
}
|
||||
O ""
|
||||
O "Создание:"
|
||||
# Создать(<Тип>, <Значение>) принимает именно ТипЗначенияXDTO —
|
||||
# для объектного типа эта форма неприменима
|
||||
O " Значение = ФабрикаXDTO.Создать(ФабрикаXDTO.Тип(`"$($pkg.Namespace)`", `"$typeName`"), Значение);"
|
||||
return
|
||||
}
|
||||
|
||||
$hdr = "=== Тип XDTO: $typeName ==="
|
||||
if ($Depth -gt 1) { $hdr += " (глубина $Depth)" }
|
||||
O $hdr
|
||||
O "Пакет: $($pkg.Name) · $($pkg.Namespace)"
|
||||
$base = Split-Ref $el $el.GetAttribute("base")
|
||||
if ($base) { O "Наследует: $($base.Local)" }
|
||||
if ($el.GetAttribute("abstract") -eq "true") { O "Абстрактный — создаётся только тип-наследник" }
|
||||
if ($el.GetAttribute("open") -eq "true") { O "Открытый — допускает произвольные элементы и атрибуты" }
|
||||
O ""
|
||||
|
||||
$seen = New-Object System.Collections.Generic.HashSet[string]
|
||||
[void]$seen.Add("$($pkg.Namespace)#$typeName")
|
||||
$rows = Get-PropRows $el $pkg $Depth 0 $seen
|
||||
$own = @($rows | Where-Object { $_.Indent -eq 0 })
|
||||
if ($RequiredOnly) {
|
||||
$all = $rows.Count
|
||||
$rows = Select-Required $rows
|
||||
$ownReq = @($rows | Where-Object { $_.Indent -eq 0 })
|
||||
# Фильтр обязан сообщать о себе: иначе список читается как полный
|
||||
O "Свойства: обязательных $($ownReq.Count) из $($own.Count) (-RequiredOnly; скрыто строк: $($all - $rows.Count))"
|
||||
} else {
|
||||
O "Свойства ($($own.Count)):"
|
||||
}
|
||||
Write-Rows $rows
|
||||
Write-Legend $rows
|
||||
O ""
|
||||
O "Создание:"
|
||||
O " Тип = ФабрикаXDTO.Тип(`"$($pkg.Namespace)`", `"$typeName`");"
|
||||
O " Объект = ФабрикаXDTO.Создать(Тип);"
|
||||
# Рецепты для вложенных и анонимных типов: имени у анонимного нет, через
|
||||
# ФабрикаXDTO.Тип(ns, имя) его не получить — только от свойства владельца
|
||||
$named = @($rows | Where-Object { $_.ObjName -and $_.ObjName -ne "(анонимный)" } | Select-Object -First 1)
|
||||
if ($named.Count -gt 0) {
|
||||
O " // вложенный именованный тип — так же, как корневой:"
|
||||
O " $($named[0].Name) = ФабрикаXDTO.Создать(ФабрикаXDTO.Тип(`"$($named[0].ObjNs)`", `"$($named[0].ObjName)`"));"
|
||||
}
|
||||
$anonRow = @($rows | Where-Object { $_.ObjName -eq "(анонимный)" } | Select-Object -First 1)
|
||||
if ($anonRow.Count -gt 0) {
|
||||
O " // у анонимного типа нет имени — только через свойство владельца:"
|
||||
O " $($anonRow[0].Name) = ФабрикаXDTO.Создать(Тип.Свойства.Получить(`"$($anonRow[0].Name)`").Тип);"
|
||||
}
|
||||
$textRow = @($rows | Where-Object { $_.Flags -contains "значение элемента" } | Select-Object -First 1)
|
||||
if ($textRow.Count -gt 0) {
|
||||
O " // собственное значение узла лежит в свойстве $($textRow[0].Name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Show-UsedBy([string]$typeName, $ownerPkg) {
|
||||
O "=== Ссылки на тип: $typeName ==="
|
||||
if ($ownerPkg) { O "Объявлен в: $($ownerPkg.Name) · $($ownerPkg.Namespace)" }
|
||||
O ""
|
||||
$hits = New-Object System.Collections.ArrayList
|
||||
foreach ($p in $packages) {
|
||||
foreach ($node in $p.Root.SelectNodes("//*")) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
foreach ($a in @("type", "base", "itemType", "memberTypes")) {
|
||||
$raw = $node.GetAttribute($a)
|
||||
if (-not $raw) { continue }
|
||||
foreach ($one in ($raw -split "\s+")) {
|
||||
$q = Split-Ref $node $one
|
||||
if (-not $q -or $q.Local -ne $typeName) { continue }
|
||||
if ($ownerPkg -and $q.Ns -and $q.Ns -ne $ownerPkg.Namespace) { continue }
|
||||
$owner = $node
|
||||
while ($owner -and @("objectType", "valueType") -notcontains $owner.get_LocalName()) { $owner = $owner.ParentNode }
|
||||
$where = $(if ($owner -and $owner.GetAttribute("name")) { $owner.GetAttribute("name") } else { "(верхний уровень)" })
|
||||
$what = $(if ($node.GetAttribute("name")) { $node.GetAttribute("name") } else { $node.get_LocalName() })
|
||||
[void]$hits.Add(" $($p.Name).$where.$what ($a)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($hits.Count -eq 0) { O " Ссылок не найдено"; return }
|
||||
O "Найдено ($($hits.Count)):"
|
||||
foreach ($h in (Sort-Ordinal ($hits | Select-Object -Unique))) { O $h }
|
||||
}
|
||||
|
||||
# --- Dispatch -------------------------------------------------------------------
|
||||
|
||||
# Выбор пакета: явный путь, затем -Namespace / -Package, затем поиск типа по всем
|
||||
$selected = $null
|
||||
if ($directPkgDir) {
|
||||
$leaf = [System.IO.Path]::GetFileName($directPkgDir)
|
||||
$selected = $packages | Where-Object { $_.Name -eq $leaf } | Select-Object -First 1
|
||||
}
|
||||
if (-not $selected -and $Namespace) {
|
||||
$selected = $packages | Where-Object { $_.Namespace -eq $Namespace } | Select-Object -First 1
|
||||
if (-not $selected) { Fail "Пакет с namespace `"$Namespace`" не найден. Список: -PackagePath <корень> без параметров" }
|
||||
}
|
||||
if (-not $selected -and $Package) {
|
||||
$selected = $packages | Where-Object { $_.Name -eq $Package } | Select-Object -First 1
|
||||
if (-not $selected) { Fail "Пакет `"$Package`" не найден. Список: -PackagePath <корень> без параметров" }
|
||||
}
|
||||
|
||||
if ($Mode -eq "used-by") {
|
||||
if (-not $Name) { Fail "Режим used-by требует -Name <Тип>" }
|
||||
$ownerPkg = $selected
|
||||
if (-not $ownerPkg) { $ownerPkg = ($packages | Where-Object { $_.Types.ContainsKey($Name) } | Select-Object -First 1) }
|
||||
Show-UsedBy $Name $ownerPkg
|
||||
Flush-Output
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Name) {
|
||||
if (-not $selected) {
|
||||
# Тип известен, пакет — нет: ищем по всей конфигурации
|
||||
$found = @($packages | Where-Object { $_.Types.ContainsKey($Name) })
|
||||
if ($found.Count -eq 0) { Fail "Тип `"$Name`" не найден ни в одном пакете конфигурации" }
|
||||
if ($found.Count -gt 1) {
|
||||
O "=== Тип `"$Name`" найден в нескольких пакетах ($($found.Count)) ==="
|
||||
O "Уточните через -Namespace или -Package:"
|
||||
O ""
|
||||
foreach ($f in $found) { O " $($f.Name) · $($f.Namespace)" }
|
||||
Flush-Output
|
||||
exit 0
|
||||
}
|
||||
$selected = $found[0]
|
||||
}
|
||||
if (-not $selected.Types.ContainsKey($Name)) {
|
||||
Fail "В пакете $($selected.Name) нет типа `"$Name`". Список типов: тот же вызов без -Name"
|
||||
}
|
||||
Show-Type $selected $Name
|
||||
Flush-Output
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($selected) { Show-PackageOverview $selected } else { Show-PackageList }
|
||||
Flush-Output
|
||||
exit 0
|
||||
@@ -1,698 +0,0 @@
|
||||
# xdto-info v1.0 — Analyze 1C XDTO package structure (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-PackagePath", "-Path", required=True)
|
||||
parser.add_argument("-Package", default="")
|
||||
parser.add_argument("-Namespace", default="")
|
||||
parser.add_argument("-Name", default="")
|
||||
parser.add_argument("-Mode", default="auto", choices=["auto", "used-by"])
|
||||
parser.add_argument("-Depth", type=int, default=1)
|
||||
parser.add_argument("-RequiredOnly", action="store_true")
|
||||
parser.add_argument("-Limit", type=int, default=150)
|
||||
parser.add_argument("-Offset", type=int, default=0)
|
||||
parser.add_argument("-OutFile", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
LIMIT, OFFSET, DEPTH = args.Limit, args.Offset, args.Depth
|
||||
|
||||
lines = []
|
||||
|
||||
|
||||
def O(line=""):
|
||||
lines.append(line)
|
||||
|
||||
|
||||
def flush_output():
|
||||
text = "\n".join(lines).rstrip()
|
||||
if args.OutFile:
|
||||
d = os.path.dirname(args.OutFile)
|
||||
if d and not os.path.isdir(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(args.OutFile, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbf" + (text + "\r\n").encode("utf-8"))
|
||||
print(f"✓ Записано: {args.OutFile}")
|
||||
else:
|
||||
print(text)
|
||||
|
||||
|
||||
def die(msg):
|
||||
# Отрицательный результат поиска — не исключение: сообщение и код 1,
|
||||
# без трассировки
|
||||
print(msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _parse_xml(source, from_string=False):
|
||||
"""Разбор с узким отступлением для не-URI пространств имён.
|
||||
|
||||
Платформа допускает в targetNamespace произвольную строку (в выгрузке БП есть
|
||||
пакет с кириллическим «ДопФайлУниверсальный»), .NET такое принимает, а libxml2
|
||||
отвергает. Откатываемся на восстанавливающий разбор ТОЛЬКО на этой ошибке,
|
||||
иначе по-настоящему битый XML перестал бы отличаться от корректного.
|
||||
"""
|
||||
try:
|
||||
return (etree.fromstring(source) if from_string else etree.parse(source))
|
||||
except etree.XMLSyntaxError as e:
|
||||
if "is not a valid URI" not in str(e):
|
||||
raise
|
||||
p = etree.XMLParser(recover=True)
|
||||
return (etree.fromstring(source, p) if from_string else etree.parse(source, p))
|
||||
|
||||
|
||||
def local(el):
|
||||
return etree.QName(el).localname
|
||||
|
||||
|
||||
# ── resolve path ─────────────────────────────────────────────
|
||||
|
||||
package_path = os.path.abspath(args.PackagePath)
|
||||
if not os.path.exists(package_path):
|
||||
die(f"Путь не найден: {package_path}")
|
||||
|
||||
config_root = None
|
||||
direct_pkg_dir = None
|
||||
|
||||
if os.path.exists(os.path.join(package_path, "Configuration.xml")):
|
||||
config_root = package_path
|
||||
elif os.path.basename(package_path.rstrip("\\/")) == "XDTOPackages":
|
||||
config_root = os.path.dirname(package_path.rstrip("\\/"))
|
||||
elif os.path.exists(os.path.join(package_path, "Ext", "Package.bin")):
|
||||
direct_pkg_dir = package_path
|
||||
config_root = os.path.dirname(os.path.dirname(package_path))
|
||||
elif os.path.isfile(package_path) and os.path.basename(package_path) == "Package.bin":
|
||||
direct_pkg_dir = os.path.dirname(os.path.dirname(package_path))
|
||||
config_root = os.path.dirname(os.path.dirname(direct_pkg_dir))
|
||||
elif package_path.endswith(".xml"):
|
||||
stem = os.path.join(os.path.dirname(package_path),
|
||||
os.path.splitext(os.path.basename(package_path))[0])
|
||||
if os.path.exists(os.path.join(stem, "Ext", "Package.bin")):
|
||||
direct_pkg_dir = stem
|
||||
config_root = os.path.dirname(os.path.dirname(stem))
|
||||
|
||||
if not config_root and not direct_pkg_dir:
|
||||
die(f"Не удалось определить пакет или конфигурацию по пути: {package_path}")
|
||||
|
||||
|
||||
# ── package index ────────────────────────────────────────────
|
||||
|
||||
class Pkg:
|
||||
__slots__ = ("Name", "Dir", "Namespace", "Root", "Imports", "Types", "GlobalProps")
|
||||
|
||||
|
||||
def read_package(pkg_dir):
|
||||
b = os.path.join(pkg_dir, "Ext", "Package.bin")
|
||||
if not os.path.exists(b):
|
||||
return None
|
||||
try:
|
||||
root = _parse_xml(b).getroot()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if local(root) != "package":
|
||||
return None
|
||||
p = Pkg()
|
||||
p.Name = os.path.basename(pkg_dir.rstrip("\\/"))
|
||||
p.Dir = pkg_dir
|
||||
p.Namespace = root.get("targetNamespace")
|
||||
p.Root = root
|
||||
p.Imports = []
|
||||
p.Types = {}
|
||||
p.GlobalProps = []
|
||||
for n in root:
|
||||
if not isinstance(n.tag, str):
|
||||
continue
|
||||
ln = local(n)
|
||||
if ln == "import":
|
||||
p.Imports.append(n.get("namespace"))
|
||||
elif ln in ("objectType", "valueType"):
|
||||
p.Types[n.get("name")] = n
|
||||
elif ln == "property":
|
||||
p.GlobalProps.append(n)
|
||||
return p
|
||||
|
||||
|
||||
packages = []
|
||||
by_namespace = {}
|
||||
|
||||
if config_root and os.path.isdir(os.path.join(config_root, "XDTOPackages")):
|
||||
base = os.path.join(config_root, "XDTOPackages")
|
||||
for dn in sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d))):
|
||||
p = read_package(os.path.join(base, dn))
|
||||
if p:
|
||||
packages.append(p)
|
||||
by_namespace.setdefault(p.Namespace, p)
|
||||
if direct_pkg_dir and not packages:
|
||||
p = read_package(direct_pkg_dir)
|
||||
if p:
|
||||
packages.append(p)
|
||||
by_namespace[p.Namespace] = p
|
||||
if not packages:
|
||||
die(f"Пакеты XDTO не найдены: {package_path}")
|
||||
|
||||
# ── type notation: XSD -> 1С ─────────────────────────────────
|
||||
|
||||
XS_TO_1C = {
|
||||
"string": "Строка", "normalizedString": "Строка", "token": "Строка", "NCName": "Строка",
|
||||
"Name": "Строка", "QName": "Строка", "anyURI": "Строка", "language": "Строка",
|
||||
"ID": "Строка", "IDREF": "Строка", "NMTOKEN": "Строка",
|
||||
"decimal": "Число", "integer": "Число", "int": "Число", "long": "Число", "short": "Число",
|
||||
"byte": "Число", "float": "Число", "double": "Число",
|
||||
"nonNegativeInteger": "Число", "positiveInteger": "Число", "nonPositiveInteger": "Число",
|
||||
"negativeInteger": "Число", "unsignedInt": "Число", "unsignedLong": "Число",
|
||||
"unsignedShort": "Число", "unsignedByte": "Число",
|
||||
"date": "Дата", "dateTime": "Дата", "time": "Дата",
|
||||
"boolean": "Булево",
|
||||
"base64Binary": "ДвоичныеДанные", "hexBinary": "ДвоичныеДанные",
|
||||
"anyType": "произвольный", "anySimpleType": "произвольный",
|
||||
}
|
||||
|
||||
FACET_NAMES = ["length", "minLength", "maxLength", "totalDigits", "fractionDigits",
|
||||
"minInclusive", "maxInclusive", "minExclusive", "maxExclusive"]
|
||||
|
||||
|
||||
def split_ref(el, raw):
|
||||
if not raw:
|
||||
return None
|
||||
if raw.startswith("{"):
|
||||
close = raw.find("}")
|
||||
if close < 0:
|
||||
return None
|
||||
return (raw[1:close], raw[close + 1:])
|
||||
parts = raw.split(":")
|
||||
if len(parts) == 2:
|
||||
return (el.nsmap.get(parts[0]), parts[1])
|
||||
return (None, parts[0])
|
||||
|
||||
|
||||
def get_facets(t):
|
||||
res = {}
|
||||
for f in FACET_NAMES:
|
||||
v = t.get(f)
|
||||
if v:
|
||||
res[f] = v
|
||||
for c in t:
|
||||
if isinstance(c.tag, str) and local(c) == "pattern":
|
||||
res["pattern"] = c.text or ""
|
||||
break
|
||||
return res
|
||||
|
||||
|
||||
def get_enumerations(t):
|
||||
return [(c.text or "") for c in t if isinstance(c.tag, str) and local(c) == "enumeration"]
|
||||
|
||||
|
||||
def find_type(q, pkg):
|
||||
if not q:
|
||||
return None
|
||||
ns, loc = q
|
||||
target = None
|
||||
if not ns or (pkg and ns == pkg.Namespace):
|
||||
target = pkg
|
||||
elif ns in by_namespace:
|
||||
target = by_namespace[ns]
|
||||
if not target or loc not in target.Types:
|
||||
return None
|
||||
return (target.Types[loc], target)
|
||||
|
||||
|
||||
def format_ref_name(q, pkg):
|
||||
if not q:
|
||||
return ""
|
||||
ns, loc = q
|
||||
if ns == XS_NS:
|
||||
return XS_TO_1C.get(loc, f"xs:{loc}")
|
||||
return loc
|
||||
|
||||
|
||||
def resolve_scalar(t, pkg, guard=0):
|
||||
acc = {"Base1C": None, "Facets": {}, "Alias": None, "Enum": [], "Kind": "scalar"}
|
||||
if guard > 10 or t is None:
|
||||
return acc
|
||||
|
||||
variety = t.get("variety")
|
||||
if variety == "List":
|
||||
it = split_ref(t, t.get("itemType"))
|
||||
acc["Kind"] = "list"
|
||||
acc["Base1C"] = "список " + (format_ref_name(it, pkg) if it else "значений")
|
||||
return acc
|
||||
if variety == "Union" or t.get("memberTypes"):
|
||||
members = []
|
||||
for m in (t.get("memberTypes") or "").split():
|
||||
q = split_ref(t, m)
|
||||
if q:
|
||||
members.append(format_ref_name(q, pkg))
|
||||
for c in t:
|
||||
if isinstance(c.tag, str) and local(c) == "typeDef":
|
||||
members.append(resolve_scalar(c, pkg, guard + 1)["Base1C"])
|
||||
acc["Kind"] = "union"
|
||||
acc["Base1C"] = "одно из (" + " | ".join(m for m in members if m) + ")"
|
||||
return acc
|
||||
|
||||
acc["Facets"] = get_facets(t)
|
||||
acc["Enum"] = get_enumerations(t)
|
||||
|
||||
base_q = split_ref(t, t.get("base"))
|
||||
anon_base = next((c for c in t if isinstance(c.tag, str) and local(c) == "typeDef"), None)
|
||||
if not base_q and anon_base is not None:
|
||||
inner = resolve_scalar(anon_base, pkg, guard + 1)
|
||||
acc["Base1C"] = inner["Base1C"]
|
||||
for k, v in inner["Facets"].items():
|
||||
acc["Facets"].setdefault(k, v)
|
||||
if inner["Enum"] and not acc["Enum"]:
|
||||
acc["Enum"] = inner["Enum"]
|
||||
return acc
|
||||
if not base_q:
|
||||
acc["Base1C"] = "произвольный"
|
||||
return acc
|
||||
|
||||
if base_q[0] == XS_NS:
|
||||
acc["Base1C"] = XS_TO_1C.get(base_q[1], f"xs:{base_q[1]}")
|
||||
return acc
|
||||
|
||||
target = find_type(base_q, pkg)
|
||||
if target and local(target[0]) == "valueType":
|
||||
inner = resolve_scalar(target[0], target[1], guard + 1)
|
||||
acc["Base1C"] = inner["Base1C"]
|
||||
for k, v in inner["Facets"].items():
|
||||
acc["Facets"].setdefault(k, v)
|
||||
if inner["Enum"] and not acc["Enum"]:
|
||||
acc["Enum"] = inner["Enum"]
|
||||
if not acc["Alias"]:
|
||||
acc["Alias"] = base_q[1]
|
||||
return acc
|
||||
acc["Base1C"] = base_q[1]
|
||||
return acc
|
||||
|
||||
|
||||
def format_scalar(res):
|
||||
t, f = res["Base1C"], res["Facets"]
|
||||
if t == "Строка":
|
||||
if "length" in f:
|
||||
t = f'Строка({f["length"]})'
|
||||
elif "maxLength" in f:
|
||||
t = f'Строка({f["maxLength"]})'
|
||||
elif t == "Число":
|
||||
if "totalDigits" in f:
|
||||
t = f'Число({f["totalDigits"]},{f.get("fractionDigits", "0")})'
|
||||
return t
|
||||
|
||||
|
||||
def format_notes(res):
|
||||
notes = []
|
||||
if res["Alias"]:
|
||||
notes.append("← " + res["Alias"])
|
||||
if "pattern" in res["Facets"]:
|
||||
p = res["Facets"]["pattern"]
|
||||
if len(p) > 40:
|
||||
p = p[:40] + "…"
|
||||
notes.append("шаблон " + p)
|
||||
for k in ("minInclusive", "maxInclusive", "minExclusive", "maxExclusive"):
|
||||
if k in res["Facets"]:
|
||||
notes.append(f'{k} {res["Facets"][k]}')
|
||||
return notes
|
||||
|
||||
|
||||
# ── property rendering ───────────────────────────────────────
|
||||
|
||||
def get_prop_rows(type_el, pkg, depth, indent, seen):
|
||||
rows = []
|
||||
for p in type_el:
|
||||
if not isinstance(p.tag, str) or local(p) != "property":
|
||||
continue
|
||||
|
||||
pname = p.get("name")
|
||||
if not pname:
|
||||
ref_q = split_ref(p, p.get("ref"))
|
||||
pname = ref_q[1] if ref_q else "(без имени)"
|
||||
lower, upper = p.get("lowerBound"), p.get("upperBound")
|
||||
flags = []
|
||||
# В модели умолчание lowerBound = 1; помечаем обязательные, как в meta-info
|
||||
if lower != "0":
|
||||
flags.append("обязательный")
|
||||
if upper == "-1":
|
||||
flags.append("список")
|
||||
elif upper and upper != "1":
|
||||
flags.append("до " + upper)
|
||||
if p.get("form") == "Text":
|
||||
flags.append("значение элемента")
|
||||
|
||||
notes = []
|
||||
type_text = ""
|
||||
children = None
|
||||
child_pkg = pkg
|
||||
# Именованный вложенный тип берётся так же, как корневой; окольный путь
|
||||
# через свойство владельца нужен только анонимному — имени у него нет
|
||||
obj_name = None
|
||||
obj_ns = None
|
||||
|
||||
anon = next((c for c in p if isinstance(c.tag, str) and local(c) == "typeDef"), None)
|
||||
|
||||
if anon is not None:
|
||||
if anon.get(f"{{{XSI_NS}}}type") == "ObjectType":
|
||||
type_text = "объект (анонимный)"
|
||||
obj_name = "(анонимный)"
|
||||
children = anon # анонимные раскрываем всегда: смотреть отдельно негде
|
||||
else:
|
||||
res = resolve_scalar(anon, pkg)
|
||||
type_text = format_scalar(res)
|
||||
notes += format_notes(res)
|
||||
if res["Enum"]:
|
||||
notes.append("значения: " + ", ".join(res["Enum"][:8]))
|
||||
else:
|
||||
q = split_ref(p, p.get("type"))
|
||||
if not q:
|
||||
type_text = "произвольный"
|
||||
elif q[0] == XS_NS:
|
||||
type_text = XS_TO_1C.get(q[1], f"xs:{q[1]}")
|
||||
else:
|
||||
target = find_type(q, pkg)
|
||||
if not target:
|
||||
type_text = "объект " + q[1]
|
||||
notes.append(f"(пакет не найден: {q[0]})")
|
||||
elif local(target[0]) == "objectType":
|
||||
type_text = "объект " + q[1]
|
||||
if target[1].Namespace != pkg.Namespace:
|
||||
type_text += " · " + target[1].Name
|
||||
obj_name = q[1]
|
||||
obj_ns = target[1].Namespace
|
||||
children = target[0]
|
||||
child_pkg = target[1]
|
||||
else:
|
||||
res = resolve_scalar(target[0], target[1])
|
||||
type_text = format_scalar(res)
|
||||
notes.append("← " + q[1])
|
||||
notes += [n for n in format_notes(res) if not n.startswith("←")]
|
||||
if res["Enum"]:
|
||||
notes.append("значения: " + ", ".join(res["Enum"][:8]))
|
||||
|
||||
rows.append({"Indent": indent, "Name": pname, "Type": type_text,
|
||||
"Flags": flags, "Notes": [n for n in notes if n],
|
||||
"ObjName": obj_name, "ObjNs": obj_ns})
|
||||
|
||||
if children is not None:
|
||||
cname = children.get("name")
|
||||
key = f"{child_pkg.Namespace}#{cname}"
|
||||
is_anon = not cname
|
||||
if not is_anon and key in seen:
|
||||
rows.append({"Indent": indent + 1, "Name": "(раскрыт выше)", "Type": "",
|
||||
"Flags": [], "Notes": [], "ObjName": None, "ObjNs": None})
|
||||
elif is_anon or depth > 1:
|
||||
next_seen = set(seen)
|
||||
if not is_anon:
|
||||
next_seen.add(key)
|
||||
next_depth = depth if is_anon else depth - 1
|
||||
rows += get_prop_rows(children, child_pkg, next_depth, indent + 1, next_seen)
|
||||
return rows
|
||||
|
||||
|
||||
# Оставить только обязательные свойства. Ребёнок необязательного объекта тоже
|
||||
# уходит: он лежит под необязательной веткой и заполнять его не обязательно.
|
||||
def select_required(rows):
|
||||
res = []
|
||||
cut_from = -1
|
||||
for r in rows:
|
||||
if cut_from >= 0 and r["Indent"] > cut_from:
|
||||
continue
|
||||
cut_from = -1
|
||||
if "обязательный" not in r["Flags"]:
|
||||
cut_from = r["Indent"]
|
||||
continue
|
||||
res.append(r)
|
||||
return res
|
||||
|
||||
|
||||
def write_rows(rows):
|
||||
if not rows:
|
||||
O(" (нет свойств)")
|
||||
return
|
||||
shown = rows
|
||||
if OFFSET > 0 or len(rows) > LIMIT:
|
||||
shown = rows[OFFSET:OFFSET + LIMIT]
|
||||
w_name = max((len(" " * r["Indent"] + r["Name"]) for r in shown), default=0)
|
||||
w_type = max((len(r["Type"]) for r in shown), default=0)
|
||||
for r in shown:
|
||||
n = " " * r["Indent"] + r["Name"]
|
||||
line = " " + n.ljust(w_name + 2) + r["Type"].ljust(w_type + 2)
|
||||
if r["Flags"]:
|
||||
line += "[" + ", ".join(r["Flags"]) + "] "
|
||||
if r["Notes"]:
|
||||
line += ", ".join(r["Notes"])
|
||||
O(line.rstrip())
|
||||
if len(rows) > len(shown):
|
||||
O(f" … показано {len(shown)} из {len(rows)}; листать через -Offset/-Limit")
|
||||
|
||||
|
||||
# ── modes ────────────────────────────────────────────────────
|
||||
|
||||
def show_package_list():
|
||||
O(f"=== Пакеты XDTO: {len(packages)} ===")
|
||||
O("")
|
||||
shown = packages
|
||||
if OFFSET > 0 or len(packages) > LIMIT:
|
||||
shown = packages[OFFSET:OFFSET + LIMIT]
|
||||
wn = max((len(p.Name) for p in shown), default=0)
|
||||
for p in shown:
|
||||
O(" " + p.Name.ljust(wn + 2) + str(len(p.Types)).rjust(4) + " " + p.Namespace)
|
||||
if len(packages) > len(shown):
|
||||
O("")
|
||||
O(f" … показано {len(shown)} из {len(packages)}; листать через -Offset/-Limit")
|
||||
O("")
|
||||
O("Колонки: имя пакета, число типов, namespace.")
|
||||
O("Следующий шаг: -Package <имя> или -Namespace <URI> — состав пакета; "
|
||||
"-Name <Тип> — поиск типа по всем пакетам")
|
||||
|
||||
|
||||
def show_package_overview(pkg):
|
||||
O(f"=== Пакет XDTO: {pkg.Name} ===")
|
||||
O(f"Namespace: {pkg.Namespace}")
|
||||
if pkg.Imports:
|
||||
O("")
|
||||
O(f"Импорты ({len(pkg.Imports)}):")
|
||||
for i in pkg.Imports:
|
||||
dep = by_namespace[i].Name if i in by_namespace else "(пакет не найден)"
|
||||
O(f" {i} → {dep}")
|
||||
if not pkg.GlobalProps:
|
||||
O("")
|
||||
O("Точки входа: нет — пакет не объявляет корневых элементов документа")
|
||||
else:
|
||||
O("")
|
||||
O(f"Точки входа ({len(pkg.GlobalProps)}) — корневые элементы документа:")
|
||||
for gp in pkg.GlobalProps:
|
||||
q = split_ref(gp, gp.get("type"))
|
||||
tn = format_ref_name(q, pkg) if q else "произвольный"
|
||||
form = " (атрибут)" if gp.get("form") == "Attribute" else ""
|
||||
O(f' <{gp.get("name")}> → {tn}{form}')
|
||||
objs = [k for k in sorted(pkg.Types) if local(pkg.Types[k]) == "objectType"]
|
||||
vals = [k for k in sorted(pkg.Types) if local(pkg.Types[k]) == "valueType"]
|
||||
if objs:
|
||||
O("")
|
||||
O(f"Объектные типы ({len(objs)}):")
|
||||
for n in objs:
|
||||
cnt = sum(1 for c in pkg.Types[n] if isinstance(c.tag, str) and local(c) == "property")
|
||||
base = split_ref(pkg.Types[n], pkg.Types[n].get("base"))
|
||||
suffix = f" ← {base[1]}" if base else ""
|
||||
O(" " + n.ljust(40) + f"свойств: {cnt}" + suffix)
|
||||
if vals:
|
||||
O("")
|
||||
O(f"Типы значений ({len(vals)}):")
|
||||
for n in vals:
|
||||
res = resolve_scalar(pkg.Types[n], pkg)
|
||||
line = " " + n.ljust(40) + format_scalar(res)
|
||||
if res["Enum"]:
|
||||
line += " значения: " + ", ".join(res["Enum"][:6])
|
||||
O(line)
|
||||
O("")
|
||||
O("Следующий шаг: -Name <Тип> — структура типа для заполнения")
|
||||
|
||||
|
||||
# Легенда едет вместе с выводом, а не живёт в инструкции: показываем только те
|
||||
# обозначения, которые реально встретились, иначе она сама становится шумом.
|
||||
def write_legend(rows):
|
||||
text = " ".join(r["Type"] + " " + ",".join(r["Flags"]) + " " + ",".join(r["Notes"]) for r in rows)
|
||||
items = []
|
||||
if "объект " in text:
|
||||
items.append("объект X — присвоить вложенный объект XDTO, состав раскрывает -Depth")
|
||||
if "←" in text:
|
||||
items.append("← Имя — исходный тип из схемы, слева от стрелки развёрнутое значение")
|
||||
if "список" in text:
|
||||
items.append("список — коллекция, заполняется через .Добавить()")
|
||||
if re.search(r"до \d", text):
|
||||
items.append("до N — коллекция с ограничением сверху")
|
||||
if "значение элемента" in text:
|
||||
items.append("значение элемента — собственное значение узла XML")
|
||||
if "·" in text:
|
||||
items.append("· Пакет — тип объявлен в другом пакете")
|
||||
if not items:
|
||||
return
|
||||
O("")
|
||||
O("Обозначения:")
|
||||
for i in items:
|
||||
O(" " + i)
|
||||
|
||||
|
||||
def show_type(pkg, type_name):
|
||||
el = pkg.Types[type_name]
|
||||
if local(el) == "valueType":
|
||||
res = resolve_scalar(el, pkg)
|
||||
O(f"=== Тип значения XDTO: {type_name} ===")
|
||||
O(f"Пакет: {pkg.Name} · {pkg.Namespace}")
|
||||
O("")
|
||||
O("Значение: " + format_scalar(res))
|
||||
for n in format_notes(res):
|
||||
O(" " + n)
|
||||
if res["Enum"]:
|
||||
O("")
|
||||
O(f'Допустимые значения ({len(res["Enum"])}):')
|
||||
for v in res["Enum"]:
|
||||
O(" " + v)
|
||||
O("")
|
||||
O("Создание:")
|
||||
# Создать(<Тип>, <Значение>) принимает именно ТипЗначенияXDTO —
|
||||
# для объектного типа эта форма неприменима
|
||||
O(f' Значение = ФабрикаXDTO.Создать(ФабрикаXDTO.Тип("{pkg.Namespace}", "{type_name}"), Значение);')
|
||||
return
|
||||
|
||||
hdr = f"=== Тип XDTO: {type_name} ==="
|
||||
if DEPTH > 1:
|
||||
hdr += f" (глубина {DEPTH})"
|
||||
O(hdr)
|
||||
O(f"Пакет: {pkg.Name} · {pkg.Namespace}")
|
||||
base = split_ref(el, el.get("base"))
|
||||
if base:
|
||||
O("Наследует: " + base[1])
|
||||
if el.get("abstract") == "true":
|
||||
O("Абстрактный — создаётся только тип-наследник")
|
||||
if el.get("open") == "true":
|
||||
O("Открытый — допускает произвольные элементы и атрибуты")
|
||||
O("")
|
||||
|
||||
seen = {f"{pkg.Namespace}#{type_name}"}
|
||||
rows = get_prop_rows(el, pkg, DEPTH, 0, seen)
|
||||
own = [r for r in rows if r["Indent"] == 0]
|
||||
if args.RequiredOnly:
|
||||
total = len(rows)
|
||||
rows = select_required(rows)
|
||||
own_req = [r for r in rows if r["Indent"] == 0]
|
||||
# Фильтр обязан сообщать о себе: иначе список читается как полный
|
||||
O(f"Свойства: обязательных {len(own_req)} из {len(own)} "
|
||||
f"(-RequiredOnly; скрыто строк: {total - len(rows)})")
|
||||
else:
|
||||
O(f"Свойства ({len(own)}):")
|
||||
write_rows(rows)
|
||||
write_legend(rows)
|
||||
O("")
|
||||
O("Создание:")
|
||||
O(f' Тип = ФабрикаXDTO.Тип("{pkg.Namespace}", "{type_name}");')
|
||||
O(" Объект = ФабрикаXDTO.Создать(Тип);")
|
||||
# Рецепты для вложенных и анонимных типов: имени у анонимного нет, через
|
||||
# ФабрикаXDTO.Тип(ns, имя) его не получить — только от свойства владельца
|
||||
named = next((r for r in rows if r.get("ObjName") and r.get("ObjName") != "(анонимный)"), None)
|
||||
if named:
|
||||
O(" // вложенный именованный тип — так же, как корневой:")
|
||||
O(f' {named["Name"]} = ФабрикаXDTO.Создать(ФабрикаXDTO.Тип("{named["ObjNs"]}", "{named["ObjName"]}"));')
|
||||
anon_row = next((r for r in rows if r.get("ObjName") == "(анонимный)"), None)
|
||||
if anon_row:
|
||||
O(" // у анонимного типа нет имени — только через свойство владельца:")
|
||||
O(f' {anon_row["Name"]} = ФабрикаXDTO.Создать(Тип.Свойства.Получить("{anon_row["Name"]}").Тип);')
|
||||
text_row = next((r for r in rows if "значение элемента" in r["Flags"]), None)
|
||||
if text_row:
|
||||
O(f' // собственное значение узла лежит в свойстве {text_row["Name"]}')
|
||||
|
||||
|
||||
def show_used_by(type_name, owner_pkg):
|
||||
O(f"=== Ссылки на тип: {type_name} ===")
|
||||
if owner_pkg:
|
||||
O(f"Объявлен в: {owner_pkg.Name} · {owner_pkg.Namespace}")
|
||||
O("")
|
||||
hits = []
|
||||
for p in packages:
|
||||
for node in p.Root.iter():
|
||||
if not isinstance(node.tag, str):
|
||||
continue
|
||||
for a in ("type", "base", "itemType", "memberTypes"):
|
||||
raw = node.get(a)
|
||||
if not raw:
|
||||
continue
|
||||
for one in raw.split():
|
||||
q = split_ref(node, one)
|
||||
if not q or q[1] != type_name:
|
||||
continue
|
||||
if owner_pkg and q[0] and q[0] != owner_pkg.Namespace:
|
||||
continue
|
||||
owner = node
|
||||
while owner is not None and local(owner) not in ("objectType", "valueType"):
|
||||
owner = owner.getparent()
|
||||
where = owner.get("name") if (owner is not None and owner.get("name")) else "(верхний уровень)"
|
||||
what = node.get("name") or local(node)
|
||||
hits.append(f" {p.Name}.{where}.{what} ({a})")
|
||||
if not hits:
|
||||
O(" Ссылок не найдено")
|
||||
return
|
||||
O(f"Найдено ({len(hits)}):")
|
||||
for h in sorted(set(hits)):
|
||||
O(h)
|
||||
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────
|
||||
|
||||
selected = None
|
||||
if direct_pkg_dir:
|
||||
leaf = os.path.basename(direct_pkg_dir.rstrip("\\/"))
|
||||
selected = next((p for p in packages if p.Name == leaf), None)
|
||||
if not selected and args.Namespace:
|
||||
selected = next((p for p in packages if p.Namespace == args.Namespace), None)
|
||||
if not selected:
|
||||
die(f'Пакет с namespace "{args.Namespace}" не найден. '
|
||||
"Список: -PackagePath <корень> без параметров")
|
||||
if not selected and args.Package:
|
||||
selected = next((p for p in packages if p.Name == args.Package), None)
|
||||
if not selected:
|
||||
die(f'Пакет "{args.Package}" не найден. Список: -PackagePath <корень> без параметров')
|
||||
|
||||
if args.Mode == "used-by":
|
||||
if not args.Name:
|
||||
die("Режим used-by требует -Name <Тип>")
|
||||
owner = selected or next((p for p in packages if args.Name in p.Types), None)
|
||||
show_used_by(args.Name, owner)
|
||||
flush_output()
|
||||
sys.exit(0)
|
||||
|
||||
if args.Name:
|
||||
if not selected:
|
||||
# Тип известен, пакет — нет: ищем по всей конфигурации
|
||||
found = [p for p in packages if args.Name in p.Types]
|
||||
if not found:
|
||||
die(f'Тип "{args.Name}" не найден ни в одном пакете конфигурации')
|
||||
if len(found) > 1:
|
||||
O(f'=== Тип "{args.Name}" найден в нескольких пакетах ({len(found)}) ===')
|
||||
O("Уточните через -Namespace или -Package:")
|
||||
O("")
|
||||
for f in found:
|
||||
O(f" {f.Name} · {f.Namespace}")
|
||||
flush_output()
|
||||
sys.exit(0)
|
||||
selected = found[0]
|
||||
if args.Name not in selected.Types:
|
||||
die(f'В пакете {selected.Name} нет типа "{args.Name}". Список типов: тот же вызов без -Name')
|
||||
show_type(selected, args.Name)
|
||||
flush_output()
|
||||
sys.exit(0)
|
||||
|
||||
if selected:
|
||||
show_package_overview(selected)
|
||||
else:
|
||||
show_package_list()
|
||||
flush_output()
|
||||
sys.exit(0)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: xdto-validate
|
||||
description: Валидация пакета XDTO 1С. Используй после создания или модификации пакета XDTO для проверки корректности
|
||||
argument-hint: <PackagePath> [-ConfigDir <каталог>] [-Detailed] [-MaxErrors N] [-OutFile <файл>]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /xdto-validate — Валидация пакета XDTO
|
||||
|
||||
Проверяет модель пакета, объект метаданных и его связь с конфигурацией.
|
||||
Каждая находка выводится отдельной строкой с объяснением. Exit code `1` при ошибках.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|--------------|----------|
|
||||
| `PackagePath` | да | Каталог пакета, `Ext/Package.bin` или `<Имя>.xml` объекта метаданных. Псевдоним — `-Path` |
|
||||
| `ConfigDir` | нет | Корень исходников. По умолчанию определяется по расположению пакета |
|
||||
| `Detailed` | нет | Показывать успешные проверки, а не только проблемы |
|
||||
| `MaxErrors` | нет | Остановиться после N ошибок. По умолчанию 20 |
|
||||
| `OutFile` | нет | Записать отчёт в файл |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/xdto-validate/scripts/xdto-validate.py" -PackagePath "<путь>"
|
||||
```
|
||||
|
||||
`[ERROR]` — платформа такой пакет не примет либо примет неправильно.
|
||||
`[WARN]` — пакет рабочий, но есть риск, о котором стоит знать.
|
||||
|
||||
## Зачем запускать, если пакет и так грузится
|
||||
|
||||
Часть дефектов платформа не диагностирует: неразрешённый тип из чужого пространства
|
||||
имён она молча подменяет на `xs:anyType`, и пакет выглядит загруженным, пока
|
||||
`ФабрикаXDTO` не отдаст в рантайме бесструктурное значение. Такие вещи видно только
|
||||
статически — до загрузки в базу.
|
||||
|
||||
## Типичный workflow
|
||||
|
||||
1. `/xdto-compile`, `/xdto-edit` или переработка через `/xdto-decompile` → `/xdto-compile -Force`
|
||||
2. `/xdto-validate <путь>` — до загрузки в базу
|
||||
3. `/db-load-xml` + `/db-update`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user