mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-21 12:11:02 +03:00
Compare commits
617
Commits
@@ -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,267 +0,0 @@
|
||||
# db-create v1.7 — 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
|
||||
Имя базы в списке
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
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" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("CREATEINFOBASE")
|
||||
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.7 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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="")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
||||
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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
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"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
# No embedded quotes: subprocess quotes the whole token; 1C's argv parser
|
||||
# strips outer quotes. Inner quotes get escaped by list2cmdline and break parsing.
|
||||
arguments.append(f'Srvr={args.InfoBaseServer};Ref={args.InfoBaseRef}')
|
||||
else:
|
||||
arguments.append(f'File={args.InfoBasePath}')
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", args.UseTemplate])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", args.ListName])
|
||||
else:
|
||||
arguments.append("/AddToList")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,276 +0,0 @@
|
||||
# db-dump-cf v1.9 — 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
|
||||
Выгрузить все расширения
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
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" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.9 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments.extend(["/DumpCfg", args.OutputFile])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", args.Extension])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,72 +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-файлу |
|
||||
|
||||
> `*` — нужен либо `-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,249 +0,0 @@
|
||||
# db-dump-dt v1.8 — 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-файлу
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
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" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.8 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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)
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments.extend(["/DumpIB", args.OutputFile])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,312 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.11 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath]
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments += ["/DumpConfigToFiles", 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", 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", args.Extension]
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,277 +0,0 @@
|
||||
# db-load-cf v1.10 — 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
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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 ""
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.10 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments.extend(["/LoadCfg", args.InputFile])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", args.Extension])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,92 +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`), если заблокировано начало сеансов |
|
||||
|
||||
> `*` — нужен либо `-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,266 +0,0 @@
|
||||
# db-load-dt v1.9 — 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) — если заблокировано начало сеансов
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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 ""
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.9 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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="")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", 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", 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", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,442 +0,0 @@
|
||||
# db-load-xml v1.16 — 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)
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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 ""
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($applyArgs -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($applyOut) { Write-Host ($applyOut | Out-String) }
|
||||
}
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
|
||||
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,415 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.16 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(apply_args), 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)
|
||||
if ar.stdout:
|
||||
print(ar.stdout)
|
||||
if ar.stderr:
|
||||
print(ar.stderr, file=sys.stderr)
|
||||
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", args.InfoBasePath]
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", 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", generated_list_file]
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", 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", out_file]
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---")
|
||||
|
||||
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,165 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.4 — 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
|
||||
|
||||
|
||||
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="")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- 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")
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), 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,296 +0,0 @@
|
||||
# db-update v1.10 — 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
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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 ""
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- 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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.10 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", 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", args.Extension])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,277 +0,0 @@
|
||||
# epf-build v1.9 — 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-файлу
|
||||
|
||||
.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
|
||||
)
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
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" }
|
||||
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..."
|
||||
$stubArgs = "-SourceDir `"$sourceDir`" -V8Path `"$V8Path`" -TempBasePath `"$autoBasePath`""
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$stubScript`" $stubArgs" -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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
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,264 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.9 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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")
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
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...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path, "-TempBasePath", auto_base_path],
|
||||
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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath]
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", args.SourceFile, args.OutputFile]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
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,276 +0,0 @@
|
||||
# epf-dump v1.8 — 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)
|
||||
|
||||
.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"
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- 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 Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
$p.WaitForExit()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
|
||||
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" }
|
||||
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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $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
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.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 ---"
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.8 — 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
|
||||
|
||||
|
||||
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 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()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
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)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- 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}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), 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)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, 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", args.InfoBasePath]
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", args.OutputDir, args.InputFile]
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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
|
||||
|
||||
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,139 +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` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||
|
||||
### `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` | `[]` | массив ссылок-владельцев: `["CatalogRef.Контрагенты"]` |
|
||||
| `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,57 +0,0 @@
|
||||
# HTTPService, WebService (Веб-сервисы)
|
||||
|
||||
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
||||
|
||||
## HTTPService (HTTP-сервис)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||
|
||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||
- строка — URL-путь без методов: `"/health"`;
|
||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `methods` — `{ "ИмяМетода": "HTTPMethod" }`.
|
||||
|
||||
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||
Обработчик метода в модуле именуется `{ИмяШаблона}{ИмяМетода}`.
|
||||
|
||||
```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` | пусто | XDTO-пакеты |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `operations` | `{}` | операции (см. ниже) |
|
||||
|
||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||
`handler` (имя процедуры, по умолчанию = имя операции), `parameters`.
|
||||
|
||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||
- строка — XDTO-тип (`direction` = `In`);
|
||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`), `direction` (`In` / `Out` / `InOut`).
|
||||
|
||||
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||
|
||||
```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`.
|
||||
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: skd-decompile
|
||||
description: Декомпиляция схемы компоновки данных 1С (СКД) в JSON-черновик в формате skd-compile. Используй для scaffold нового отчёта по образцу или структурного рефакторинга. Не для точечных правок
|
||||
argument-hint: <TemplatePath> [-OutputPath <out.json>]
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /skd-decompile — JSON-черновик из Template.xml СКД
|
||||
|
||||
Читает Template.xml и эмитит JSON в формате `skd-compile`. **Результат — черновик**, а не обратимое представление: см. раздел «Что получаешь».
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- **Scaffold нового отчёта по образцу** — взять существующий СКД, получить JSON, поправить и скомпилировать в новый.
|
||||
- **Структурный рефакторинг** — переписать вариант, перерисовать шаблон, перебрать набор полей.
|
||||
|
||||
## Когда **не** использовать
|
||||
|
||||
- **Точечные правки готового отчёта** (добавить поле, фильтр, итог, переименовать) → `/skd-edit`. Цикл «декомпиляция → правка JSON → компиляция» переписывает шаблон целиком, может терять непокрытые конструкции и даёт большой diff в исходниках. `/skd-edit` правит адресно, без полной реконструкции.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `TemplatePath` | Путь к Template.xml (обязательный) |
|
||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||
|
||||
```powershell
|
||||
python ".augment/skills/skd-decompile/scripts/skd-decompile.py" -TemplatePath "<Template.xml>" -OutputPath "<out.json>"
|
||||
```
|
||||
|
||||
## Что получаешь
|
||||
|
||||
JSON-черновик в формате `/skd-compile` — **не полное обратимое представление СКД**. На вход компилятору такой JSON напрямую может не пойти: в нём встречаются sentinel-узлы (маркер `__unsupported__`).
|
||||
|
||||
- **Готовые узлы** — большая часть СКД (поля, параметры, шаблоны, варианты со structure/filter/order/conditionalAppearance и т.п.) ложится в JSON как обычные узлы DSL.
|
||||
- **Sentinel-узлы** — места, где встретилась конструкция, которую декомпилятор не умеет выразить в DSL. JSON остаётся валидным, но компилятор откажется его собирать, пока sentinel не **заменён ручной реализацией** (явный raw `template`, прописанный appearance и т.п.) **или не удалён**, если в новом отчёте конструкция не нужна. Это намеренный барьер — чтобы непокрытое не уехало в финальный отчёт незамеченным.
|
||||
- **`<basename>.warnings.md`** рядом с `OutputPath` — список всех sentinel-узлов с координатами в исходнике, по нему удобно обходить места под ручную доработку.
|
||||
- **Критичные конструкции** (Picture cells, ХранилищеЗначения, вложенные схемы, не-СКД root) — скрипт падает с ненулевым кодом и сообщением в stderr; такой Template как образец не годится.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `/skd-decompile <Template.xml> -OutputPath draft.json` — получить черновик.
|
||||
2. Открыть `draft.warnings.md`, посмотреть, что не покрылось.
|
||||
3. Поправить JSON под задачу. Sentinel-узлы — заменить на ручную реализацию (через явный raw `template`, через ручное описание appearance и т.п.) либо удалить, если конструкция в новом отчёте не нужна.
|
||||
4. `/skd-compile -DefinitionFile draft.json -OutputPath new-Template.xml` — собрать обратно.
|
||||
5. `/skd-validate` + `/skd-info` — проверить.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,442 +0,0 @@
|
||||
# Regression suite authoring
|
||||
|
||||
Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
|
||||
|
||||
The runner is the same `run.mjs`. The mode is `test`:
|
||||
|
||||
```bash
|
||||
node $RUN test <dir|file>... [flags]
|
||||
```
|
||||
|
||||
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=<url>`.
|
||||
|
||||
`webtest.config.mjs` and `_hooks.mjs` always come from the suite root, whatever path you pass: `test tests/myapp/sales/` and `test tests/myapp/sales/01-order.test.mjs` both run under the config and hooks of `tests/myapp/`, no `--url=` needed. Paths from two different suites in one run are refused — pass one suite and narrow with `--grep=` / `--tags=`.
|
||||
|
||||
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
|
||||
|
||||
## When to choose `test` over `exec`
|
||||
|
||||
| Goal | Mode |
|
||||
|------|------|
|
||||
| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
|
||||
| Reproduce a bug as a failing test before fixing it | `test` |
|
||||
| Cover a feature so future changes are checked automatically | `test` |
|
||||
| Run the project's regression on a new build | `test` |
|
||||
| Generate a screencast walkthrough | `exec` with `startRecording` |
|
||||
|
||||
Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
|
||||
|
||||
## Before writing tests — recon
|
||||
|
||||
Two layers, in order.
|
||||
|
||||
**1. Static recon — metadata.** Never invent identifiers. For every metadata object the user mentions, run the matching info skill first: `/meta-info` (attributes/tabular sections), `/form-info` (form layout), `/skd-info` (DCS), `/mxl-info` (templates), `/role-info` (rights), `/subsystem-info` (composition / command interface). If the user names objects you can't find — stop and ask.
|
||||
|
||||
**2. Live recon — interactive walkthrough.** For any non-trivial scenario, walk the path live in `exec` mode before transcribing it. Metadata tells you what exists; the live walkthrough tells you what actually happens. Capture from `getFormState()`: exact button names (`'Провести и закрыть'`, not `'Сохранить'`), table section names for multi-grid forms, required fields, places where a real async wait is needed. Then transcribe the working sequence into `*.test.mjs`, wrapping logical chunks in `step('...', async () => { ... })`.
|
||||
|
||||
The mechanics of `exec` / `getFormState` / `fillFields` / `clickElement` are in [SKILL.md](SKILL.md) — read it before recon if you haven't already.
|
||||
|
||||
When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, user-customised forms.
|
||||
|
||||
## Suite layout
|
||||
|
||||
**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
|
||||
|
||||
```
|
||||
tests/
|
||||
<app-name>/ # application regression — one per solution
|
||||
_hooks.mjs
|
||||
webtest.config.mjs
|
||||
_allure/ # optional static Allure config
|
||||
01-login/
|
||||
02-counterparties/
|
||||
...
|
||||
<another-app>/ # second solution, fully isolated
|
||||
```
|
||||
|
||||
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order — discovery walks recursively and sorts files by full relative path; entries starting with `_` or `.` are skipped (so `_hooks.mjs`, `_allure/` won't be picked up as tests).
|
||||
|
||||
```
|
||||
tests/<app-name>/
|
||||
01-login/
|
||||
01-open-base.test.mjs
|
||||
02-section-navigation.test.mjs
|
||||
02-counterparties/
|
||||
01-create.test.mjs
|
||||
02-edit-phone.test.mjs
|
||||
03-goods-receipt/
|
||||
01-fill.test.mjs
|
||||
02-post.test.mjs
|
||||
05-approval-process/
|
||||
01-end-to-end.test.mjs # multi-user
|
||||
```
|
||||
|
||||
Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded, whichever subfolder you point the runner at.
|
||||
|
||||
## Test file anatomy
|
||||
|
||||
```js
|
||||
export const name = 'Создание контрагента'; // required
|
||||
export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
|
||||
export const timeout = 60000; // optional, default 30000
|
||||
// export const skip = 'pending fix #123'; // optional: true | string
|
||||
// export const only = true; // debug-only — never commit
|
||||
// export const context = 'manager'; // optional, single non-default context
|
||||
// export const contexts = ['clerk', 'manager']; // optional, multi-user test
|
||||
// export const severity = 'critical'; // optional, overrides config severity
|
||||
|
||||
export async function setup(ctx) {
|
||||
// per-test prep — runs before default. Skip if not needed.
|
||||
}
|
||||
|
||||
export async function teardown(ctx) {
|
||||
// per-test cleanup — runs after default, always (even on failure).
|
||||
}
|
||||
|
||||
export default async function(ctx) {
|
||||
const { navigateSection, openCommand, clickElement, fillFields,
|
||||
readTable, closeForm, getFormState,
|
||||
assert, step, log } = ctx;
|
||||
|
||||
await step('Открыть список контрагентов', async () => {
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Контрагенты');
|
||||
});
|
||||
|
||||
await step('Создать нового контрагента', async () => {
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Наименование': 'Тест ' + Date.now() });
|
||||
await clickElement('Записать и закрыть');
|
||||
});
|
||||
|
||||
await step('Убедиться, что элемент появился в списке', async () => {
|
||||
const t = await readTable();
|
||||
assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`), not a tag (`'create'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
|
||||
|
||||
## `ctx` contract
|
||||
|
||||
The runner injects every `browser.mjs` export into `ctx` (all 1C action functions auto-detect platform errors — see SKILL.md), plus the test utilities below.
|
||||
|
||||
### Test utilities
|
||||
|
||||
```js
|
||||
step(name, fn) // async wrapper. Records start/stop. Nested calls supported.
|
||||
// On throw: marks the step failed, re-throws.
|
||||
// On screenshot='every-step': captures after fn().
|
||||
log(...args) // adds a line to ctx.testInfo's output (goes into JSON / Allure
|
||||
// attachment). Use instead of console.log inside tests.
|
||||
assert.* // see "Assertions" below
|
||||
```
|
||||
|
||||
### `ctx.testInfo` (always set, read-only)
|
||||
|
||||
```js
|
||||
{
|
||||
name, // 'Навигация по разделам' (with params substituted)
|
||||
file, // '01-navigation.test.mjs' (basename)
|
||||
filePath, // relative path inside testDir
|
||||
tags, // ['nav', 'smoke']
|
||||
timeout, // ms
|
||||
attempt, // 1..maxAttempts (1-based)
|
||||
maxAttempts, // 1 + retry
|
||||
param, // { ... } | undefined (only when export const params is set)
|
||||
contexts: { // mirrors config.contexts; includes custom fields like displayName
|
||||
clerk: { url, isolation, displayName, ... },
|
||||
manager: { ... },
|
||||
},
|
||||
primaryContext, // 'clerk' — name of the context active at test entry
|
||||
// (= t.context for single, t.contexts[0] for multi)
|
||||
}
|
||||
```
|
||||
|
||||
### `ctx.testResult` (only in `afterEach`)
|
||||
|
||||
```js
|
||||
{
|
||||
status, // 'passed' | 'failed'
|
||||
duration, // ms
|
||||
attempts, // attempts actually executed
|
||||
error, // { message, step?, screenshot? } | null
|
||||
steps, // array of step results (each: { name, start, stop, status, error?, steps[] })
|
||||
}
|
||||
```
|
||||
|
||||
### Context shape
|
||||
|
||||
- **Single-context (default or `export const context = 'manager'`):** all API on `ctx` top-level — `ctx.clickElement(...)`, `ctx.getFormState()`, etc.
|
||||
- **Multi-context (`export const contexts = ['clerk', 'manager']`):** each name is its own scoped namespace — `ctx.clerk.clickElement(...)`, `ctx.manager.fillFields(...)`. `step`, `assert`, `log`, `testInfo` stay top-level. Scoped methods auto-switch the active page before each call.
|
||||
|
||||
## Assertions
|
||||
|
||||
All on `ctx.assert`. Throw `AssertionError` with `.message`, `.actual`, `.expected`. No dependencies.
|
||||
|
||||
```js
|
||||
// generic
|
||||
assert.ok(value, msg?) // truthy
|
||||
assert.equal(actual, expected, msg?) // ===
|
||||
assert.notEqual(actual, expected, msg?) // !==
|
||||
assert.deepEqual(actual, expected, msg?) // JSON-compare
|
||||
assert.includes(haystack, needle, msg?) // string.includes / array.includes
|
||||
assert.match(string, regex, msg?) // regex.test(string)
|
||||
await assert.throws(asyncFn, msg?) // passes if fn throws (use await)
|
||||
|
||||
// 1C-specific — operate on getFormState() / readTable() output
|
||||
assert.formHasField(state, 'Контрагент', msg?) // fields[] contains a field with that name
|
||||
assert.formTitle(state, expected, msg?) // state.title includes expected (null title → fails saying so)
|
||||
assert.tableHasRow(table, predicate, msg?) // predicate: object (partial match) or fn(row) => bool
|
||||
// object form: { 'Наименование': 'Тест' }
|
||||
// fn form: r => r['Сумма'] > 100
|
||||
assert.tableRowCount(table, expected, msg?) // table.rows.length === expected
|
||||
assert.noErrors(state, msg?) // !state.errors
|
||||
```
|
||||
|
||||
Beyond these, just use plain JS (`throw new Error(...)`) — there's no custom matcher extension API. The 1C-specific helpers are the ones worth preferring over hand-rolled equivalents because their error messages name the actual fields/rows present, which speeds up triage.
|
||||
|
||||
## webtest.config.mjs
|
||||
|
||||
```js
|
||||
export default {
|
||||
// Single-context shorthand:
|
||||
url: 'http://localhost:9191/myapp/ru_RU',
|
||||
|
||||
// OR multi-context:
|
||||
// contexts: {
|
||||
// clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
|
||||
// manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
|
||||
// },
|
||||
// defaultContext: 'clerk',
|
||||
|
||||
// Context-pool / 1C license management (all optional; omit = no cap, default stays open).
|
||||
// maxContexts: 2, // cap on simultaneous 1C sessions; omit for unlimited
|
||||
// contextPolicy: 'reuse', // 'reuse' (keep open within cap) | 'strict' (close after each test)
|
||||
// pinnedContexts: [], // never evicted; defaults to [defaultContext], [] makes default evictable
|
||||
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
screenshot: 'on-failure', // 'every-step' | 'off'
|
||||
record: false,
|
||||
|
||||
// Severity → tags mapping for Allure. Each tag at most one bucket.
|
||||
severity: {
|
||||
critical: ['smoke', 'crud'],
|
||||
minor: ['recording'],
|
||||
},
|
||||
defaultSeverity: 'normal',
|
||||
};
|
||||
```
|
||||
|
||||
CLI flags override config. Use latin context IDs + Russian `displayName` for ergonomics — `ctx.testInfo.contexts.clerk.displayName` is friendlier than mixed-case Cyrillic keys.
|
||||
|
||||
## _hooks.mjs
|
||||
|
||||
Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
|
||||
|
||||
```js
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Infra — runs once around the whole suite.
|
||||
export async function prepare({ hookArgs, log, config }) {
|
||||
// hookArgs: everything after `--` on the CLI, as a string[]. Parse yourself.
|
||||
const force = hookArgs.includes('--rebuild-stand');
|
||||
const dataArg = hookArgs.find(a => a.startsWith('--data='))?.slice('--data='.length);
|
||||
log('preparing stand, force=', force, 'data=', dataArg);
|
||||
// Idempotent hash-locks on inputs (config sources, EPF spec, DB dump) keep
|
||||
// warm starts to a liveness probe.
|
||||
}
|
||||
|
||||
export async function cleanup({ log, config }) { /* optional */ }
|
||||
|
||||
// Testlevel — runs with browser ctx.
|
||||
export async function beforeAll(ctx) { /* once after first context opens */ }
|
||||
export async function afterAll(ctx) { /* once before final teardown */ }
|
||||
export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
|
||||
export async function afterEach(ctx) { /* ctx.testInfo + ctx.testResult set */ }
|
||||
|
||||
// Per-context — runs whenever a context is created/closed.
|
||||
export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
|
||||
export async function beforeCloseContext(ctx, name, spec) { }
|
||||
```
|
||||
|
||||
Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it in `afterEach`.
|
||||
|
||||
Pass hook args after `--`:
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/ --bail -- --rebuild-stand --data=demo
|
||||
└─runner─┘ └────── hookArgs ─────────┘
|
||||
```
|
||||
|
||||
**Where to put data setup:**
|
||||
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks).
|
||||
- Test-specific seed data → per-test `setup`.
|
||||
- Shared session-wide warmup → `beforeAll`.
|
||||
|
||||
## Ready-to-paste patterns
|
||||
|
||||
A minimal CRUD shape is in *Test file anatomy* above — use it as the rhythm for catalog/document tests, swapping in the right section/command/fields. The patterns below cover what's specific to the regression engine, not the browser API (those live in SKILL.md).
|
||||
|
||||
### DCS report
|
||||
|
||||
```js
|
||||
await openCommand('Остатки товаров');
|
||||
// Reset user settings — 1C persists them between sessions.
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
|
||||
await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
|
||||
assert.ok(r.data.length >= 1);
|
||||
assert.ok(r.totals?.['Сумма']);
|
||||
```
|
||||
|
||||
### Multi-user process
|
||||
|
||||
```js
|
||||
export const contexts = ['clerk', 'manager'];
|
||||
|
||||
export default async function({ clerk, manager, step, assert }) {
|
||||
await step('Кладовщик создаёт накладную', async () => {
|
||||
await clerk.navigateSection('Склад');
|
||||
await clerk.openCommand('Приходные накладные');
|
||||
await clerk.clickElement('Создать');
|
||||
await clerk.fillFields({ 'Контрагент': 'ООО Север' });
|
||||
await clerk.clickElement('Записать');
|
||||
});
|
||||
await step('Менеджер утверждает накладную', async () => {
|
||||
await manager.navigateSection('Согласование');
|
||||
await manager.openCommand('На утверждении');
|
||||
await manager.clickElement('ООО Север', { dblclick: true });
|
||||
await manager.clickElement('Утвердить');
|
||||
});
|
||||
await step('Кладовщик видит новый статус', async () => {
|
||||
const s = await clerk.getFormState();
|
||||
assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
|
||||
});
|
||||
await step('Освободить сессию кладовщика', async () => {
|
||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state. On tight-license stands prefer configuring the pool (`maxContexts` + `contextPolicy` + `pinnedContexts`) over manual per-test closing — the runner then evicts and reuses sessions automatically.
|
||||
|
||||
**Context pool (1C licenses).** With `maxContexts` set, the runner caps simultaneous 1C sessions: before each test it evicts least-recently-used contexts that are neither pinned nor needed, reusing already-open ones. `contextPolicy: 'reuse'` (default) keeps sessions for speed; `'strict'` closes a test's non-pinned contexts right after it. `pinnedContexts` are never evicted (default `[defaultContext]`; set `[]` to make the default context evictable on a tight stand). If the pool can't fit even after eviction, the test fails with a clear `context pool exhausted` error instead of an opaque connection failure.
|
||||
|
||||
### Failing-test repro
|
||||
|
||||
```js
|
||||
export const name = 'Bug #123: накладная без контрагента не должна проводиться';
|
||||
export const tags = ['bug', 'validation'];
|
||||
|
||||
export default async function({ openCommand, clickElement, getFormState, assert, step }) {
|
||||
await openCommand('Приходные накладные');
|
||||
await clickElement('Создать');
|
||||
await clickElement('Провести');
|
||||
const s = await getFormState();
|
||||
assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required,
|
||||
'Должна быть ошибка валидации или поле помечено обязательным');
|
||||
}
|
||||
```
|
||||
|
||||
Write it red first, hand it to the user, fix the underlying issue, re-run green.
|
||||
|
||||
### Parameterised test
|
||||
|
||||
```js
|
||||
export const name = 'Заполнение поля {type}';
|
||||
export const params = [
|
||||
{ type: 'String', field: 'Наименование', value: 'Тест' },
|
||||
{ type: 'Number', field: 'Цена', value: '100.50' },
|
||||
{ type: 'Date', field: 'ДатаПоступления', value: '01.01.2024' },
|
||||
];
|
||||
|
||||
export default async function({ fillFields, getFormState, assert }, { type, field, value }) {
|
||||
await fillFields({ [field]: value });
|
||||
const state = await getFormState();
|
||||
assert.equal(state.fields.find(f => f.name === field)?.value, String(value));
|
||||
}
|
||||
```
|
||||
|
||||
Each `params` entry becomes its own test in the report. `{key}` placeholders in `name` get substituted; without placeholders, a `[index]` suffix is added. `ctx.testInfo.param` carries the current row.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/ # full app suite
|
||||
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
|
||||
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
|
||||
node $RUN test tests/<app-name>/02-x.test.mjs tests/<app-name>/05-y.test.mjs # several files
|
||||
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
|
||||
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
|
||||
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
||||
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
||||
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
||||
node $RUN test tests/<app-name>/ --global-timeout=3600000 # ceiling for the whole run (exit 2)
|
||||
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
||||
```
|
||||
|
||||
**Timeouts and hangs.** A test's `timeout` is a contract, not a wish: when it expires the runner probes the
|
||||
context and destroys whatever is wedged, so the run always moves on. The failure carries a verdict — `hang`
|
||||
(browser alive, renderer's JS thread blocked; the context is aborted, its 1C seance released from Node, and
|
||||
the next test recreates it) versus `slow`/`slow-network` (nothing is broken — raise `export const timeout`).
|
||||
A `hang` is never retried. Exit codes: `1` red tests, `2` `--global-timeout` fired (report written, seances
|
||||
released), `3` the shutdown itself wedged. Allure results are written per test as it finishes, so a hang
|
||||
cannot destroy the results collected before it — no external watchdog needed.
|
||||
|
||||
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
||||
|
||||
### Allure static config — `_allure/`
|
||||
|
||||
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Drop in `categories.json` (regex-based failure classification — useful for 1C-specific buckets: license pool exhaustion, platform exceptions, runner timeouts, assertion failures), `environment.properties` (optional, often emitted dynamically by `prepare()`), `executor.json` (CI metadata, skip locally). The underscore prefix keeps the directory out of test discovery.
|
||||
|
||||
## Severity guidance
|
||||
|
||||
When the user doesn't dictate, default to:
|
||||
|
||||
| Test kind | Severity |
|
||||
|-----------|----------|
|
||||
| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
|
||||
| Documents posting, report generation, end-to-end processes | `critical` |
|
||||
| Field-level edge cases, formatting, optional flows | `normal` |
|
||||
| Cosmetic / recording / non-functional | `minor` |
|
||||
| Reserved for show-stopper protections | `blocker` (use sparingly) |
|
||||
|
||||
Don't promote everything to `critical` — it loses signal in the Allure dashboard.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — wait on `getFormState` instead.
|
||||
- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
|
||||
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by a unique marker (`Date.now()` suffix) instead.
|
||||
- **Hand-writing reset code in `afterEach`.** The runner already closes forms and dismisses errors after the hook.
|
||||
- **Cross-test state assumptions.** Each test must start from the desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
|
||||
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
|
||||
- **Skipping recon** because "I know what this catalog looks like." The project's customisation almost certainly differs from stock.
|
||||
|
||||
(General browser-API anti-patterns — raw DOM, `clickElement('Закрыть')` instead of `closeForm()` — live in SKILL.md.)
|
||||
|
||||
## After a run — failure triage
|
||||
|
||||
1. Scan the JSON or Allure summary for `failed`.
|
||||
2. For each failure, read `error.message` + `error.step` + screenshot.
|
||||
3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
|
||||
4. Classify:
|
||||
- **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
|
||||
- **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
|
||||
- **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
|
||||
5. After fixes, re-run only the affected files before the full suite.
|
||||
|
||||
Report back to the user with the classification, not raw failure dumps.
|
||||
|
||||
## Reference
|
||||
|
||||
- Browser API: [SKILL.md](SKILL.md)
|
||||
- Video and narration: [recording.md](recording.md)
|
||||
@@ -1,59 +0,0 @@
|
||||
// web-test browser v1.19 — engine facade: re-exports the public API from engine/*
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
||||
* Implementation lives in `./engine/*`. External callers (run.mjs, exec scripts,
|
||||
* tests) import from this file; engine internals import each other directly.
|
||||
*/
|
||||
|
||||
// ── core ──────────────────────────────────────────────────────────────────
|
||||
export {
|
||||
isConnected, getPage, ensureConnected, setPreserveClipboard,
|
||||
} from './engine/core/state.mjs';
|
||||
export {
|
||||
pasteText, saveClipboard, restoreClipboard,
|
||||
} from './engine/core/clipboard.mjs';
|
||||
export { getFormState } from './engine/forms/state.mjs';
|
||||
export { fetchErrorStack } from './engine/core/errors.mjs';
|
||||
export { clickElement } from './engine/core/click.mjs';
|
||||
|
||||
// ── session ───────────────────────────────────────────────────────────────
|
||||
export {
|
||||
connect, disconnect, attach, detach, getSession,
|
||||
createContext, setActiveContext, listContexts, getActiveContext,
|
||||
hasContext, closeContext,
|
||||
// Unresponsive-context handling (test runner). abortContext is the sanctioned way to
|
||||
// mutate the registry from outside — the `contexts` Map itself stays private.
|
||||
abortContext, probeContext, getContextDiagnostics,
|
||||
} from './engine/core/session.mjs';
|
||||
|
||||
// ── navigation ────────────────────────────────────────────────────────────
|
||||
export {
|
||||
getPageState, getSections, navigateSection, getCommands,
|
||||
openCommand, switchTab, openFile, navigateLink,
|
||||
} from './engine/nav/navigation.mjs';
|
||||
|
||||
// ── forms ─────────────────────────────────────────────────────────────────
|
||||
export { selectValue } from './engine/forms/select-value.mjs';
|
||||
export { fillFields, fillField } from './engine/forms/fill.mjs';
|
||||
export { closeForm } from './engine/forms/close.mjs';
|
||||
|
||||
// ── tables ────────────────────────────────────────────────────────────────
|
||||
export { readTable, deleteTableRow } from './engine/table/grid.mjs';
|
||||
export { readSpreadsheet } from './engine/spreadsheet/spreadsheet.mjs';
|
||||
export { fillTableRow } from './engine/table/row-fill.mjs';
|
||||
export { filterList, unfilterList } from './engine/table/filter.mjs';
|
||||
|
||||
// ── recording / overlays ──────────────────────────────────────────────────
|
||||
export {
|
||||
screenshot, wait, isRecording, startRecording, stopRecording,
|
||||
} from './engine/recording/capture.mjs';
|
||||
export {
|
||||
showCaption, hideCaption, getCaptions,
|
||||
showTitleSlide, hideTitleSlide,
|
||||
showImage, hideImage,
|
||||
} from './engine/recording/captions.mjs';
|
||||
export {
|
||||
highlight, unhighlight, setHighlight, isHighlightMode,
|
||||
} from './engine/recording/highlight.mjs';
|
||||
export { addNarration } from './engine/recording/narration.mjs';
|
||||
@@ -1,36 +0,0 @@
|
||||
// web-test cli/commands/exec v1.0 — send script to running server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import http from 'http';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { out, die, readStdin } from '../util.mjs';
|
||||
import { loadSession } from '../session.mjs';
|
||||
|
||||
export async function cmdExec(fileOrDash, flags = {}) {
|
||||
if (!fileOrDash) die('Usage: node src/run.mjs exec <file|-> [--no-record]');
|
||||
|
||||
const code = fileOrDash === '-'
|
||||
? await readStdin()
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
const sess = loadSession();
|
||||
const headers = {};
|
||||
if (flags.noRecord) headers['x-no-record'] = '1';
|
||||
const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000;
|
||||
const result = await new Promise((resolveP, reject) => {
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
||||
method: 'POST', timeout: timeoutMs, headers,
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); });
|
||||
req.write(code);
|
||||
req.end();
|
||||
});
|
||||
out(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// web-test cli/commands/run v1.1 — autonomous connect → exec → disconnect (no server)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die, readStdin } from '../util.mjs';
|
||||
import { executeScript } from '../exec-context.mjs';
|
||||
|
||||
export async function cmdRun(url, fileOrDash) {
|
||||
if (!url || !fileOrDash) die('Usage: node src/run.mjs run <url> <file|->');
|
||||
|
||||
const code = fileOrDash === '-'
|
||||
? await readStdin()
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
// Same as cmdStart: a startup blocker is a diagnosis, not a crash. connect() has already
|
||||
// released the seance and closed the browser; a stack trace pointing into session.mjs would
|
||||
// read as an engine bug and send the reader off to debug the wrong thing.
|
||||
try {
|
||||
await browser.connect(url);
|
||||
} catch (e) {
|
||||
die(e.message);
|
||||
}
|
||||
const result = await executeScript(code);
|
||||
await browser.disconnect();
|
||||
|
||||
out(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// web-test cli/commands/shot v1.0 — take screenshot via server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { writeFileSync } from 'fs';
|
||||
import { out, die } from '../util.mjs';
|
||||
import { loadSession } from '../session.mjs';
|
||||
|
||||
export async function cmdShot(file) {
|
||||
const sess = loadSession();
|
||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.text();
|
||||
die(`Screenshot failed: ${err}`);
|
||||
}
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const outFile = file || 'shot.png';
|
||||
writeFileSync(outFile, buf);
|
||||
out({ ok: true, file: outFile });
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// web-test cli/commands/start v1.1
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import http from 'http';
|
||||
import { writeFileSync } from 'fs';
|
||||
import * as browser from '../../browser.mjs';
|
||||
import { out, die } from '../util.mjs';
|
||||
import { SESSION_FILE, cleanup } from '../session.mjs';
|
||||
import { handleRequest } from '../server.mjs';
|
||||
|
||||
export async function cmdStart(url) {
|
||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||
|
||||
// A startup blocker (no free 1C licence, publication asking for credentials) is a diagnosis,
|
||||
// not a crash — connect() already released the seance and closed the browser, so print the
|
||||
// message and leave instead of dumping a stack trace that reads like an engine failure.
|
||||
let state;
|
||||
try {
|
||||
state = await browser.connect(url);
|
||||
} catch (e) {
|
||||
die(e.message);
|
||||
}
|
||||
|
||||
const httpServer = http.createServer(handleRequest);
|
||||
httpServer.listen(0, '127.0.0.1', () => {
|
||||
const port = httpServer.address().port;
|
||||
const session = {
|
||||
port,
|
||||
url,
|
||||
pid: process.pid,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2));
|
||||
out({ ok: true, message: 'Browser ready', port, ...state });
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
await browser.disconnect();
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
// web-test cli/commands/stop v1.0 — send stop to server
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { out } from '../util.mjs';
|
||||
import { loadSession, cleanup } from '../session.mjs';
|
||||
|
||||
export async function cmdStop() {
|
||||
const sess = loadSession();
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' });
|
||||
const result = await resp.json();
|
||||
out(result);
|
||||
} catch {
|
||||
// Server may have already exited before responding
|
||||
out({ ok: true, message: 'Stopped' });
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
@@ -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,164 +0,0 @@
|
||||
// web-test cli/exec-context v1.1 — buildContext + executeScript для run/exec/test
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as browser from '../browser.mjs';
|
||||
import { elapsed, slugify } from './util.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
||||
|
||||
// Where 1C-error screenshots go. The default is a single fixed file: fine for
|
||||
// interactive `exec`/`run` (last error wins, easy to find), wrong for a test run
|
||||
// where every test needs its own file inside reportDir. cmdTest overrides this.
|
||||
let errorShotDir = null;
|
||||
let errorShotSeq = 0;
|
||||
|
||||
export function setErrorShotDir(dir) {
|
||||
errorShotDir = dir;
|
||||
errorShotSeq = 0;
|
||||
}
|
||||
|
||||
function nextErrorShotPath(label) {
|
||||
if (!errorShotDir) return DEFAULT_ERROR_SHOT_PATH;
|
||||
return resolve(errorShotDir, `onec-error-${++errorShotSeq}-${slugify(label || 'shot')}.png`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-context wrapper: same shape as buildContext output, but every call
|
||||
* is prefixed with `setActiveContext(name)` so the test can interleave actions
|
||||
* across contexts (`ctx.a.click(...); ctx.b.click(...)`).
|
||||
*/
|
||||
export function buildScopedContext(name) {
|
||||
const inner = buildContext({ noRecord: false });
|
||||
const scoped = {};
|
||||
for (const [k, v] of Object.entries(inner)) {
|
||||
if (typeof v === 'function') {
|
||||
scoped[k] = async (...args) => {
|
||||
await browser.setActiveContext(name);
|
||||
return v(...args);
|
||||
};
|
||||
} else {
|
||||
scoped[k] = v;
|
||||
}
|
||||
}
|
||||
return scoped;
|
||||
}
|
||||
|
||||
export function buildContext({ noRecord = false } = {}) {
|
||||
const ctx = {};
|
||||
for (const [k, v] of Object.entries(browser)) {
|
||||
if (k !== 'default') ctx[k] = v;
|
||||
}
|
||||
ctx.writeFileSync = writeFileSync;
|
||||
ctx.readFileSync = readFileSync;
|
||||
|
||||
// --no-record: stub recording/narration functions to return safe defaults
|
||||
if (noRecord) {
|
||||
const noop = async () => {};
|
||||
ctx.startRecording = noop;
|
||||
ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 });
|
||||
ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 });
|
||||
for (const fn of ['showCaption', 'hideCaption']) {
|
||||
ctx[fn] = noop;
|
||||
}
|
||||
ctx.isRecording = () => false;
|
||||
ctx.getCaptions = () => [];
|
||||
}
|
||||
|
||||
// Wrap action functions to auto-detect 1C errors (modal, balloon)
|
||||
// and stop execution immediately with diagnostic info
|
||||
const ACTION_FNS = [
|
||||
'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow',
|
||||
'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile',
|
||||
'closeForm', 'filterList', 'unfilterList'
|
||||
];
|
||||
for (const name of ACTION_FNS) {
|
||||
if (typeof ctx[name] !== 'function') continue;
|
||||
const orig = ctx[name];
|
||||
ctx[name] = async (...args) => {
|
||||
const result = await orig(...args);
|
||||
const errors = result?.errors;
|
||||
if (errors?.modal || errors?.balloon) {
|
||||
// Screenshot while the error modal is still visible (before fetchErrorStack closes it)
|
||||
let errorShot;
|
||||
try {
|
||||
const png = await ctx.screenshot();
|
||||
errorShot = nextErrorShotPath(name);
|
||||
writeFileSync(errorShot, png);
|
||||
} catch {}
|
||||
// Try to fetch call stack for modal errors before throwing
|
||||
let stack = null;
|
||||
if (errors?.modal && typeof ctx.fetchErrorStack === 'function') {
|
||||
try {
|
||||
stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport);
|
||||
} catch { /* don't fail if stack fetch fails */ }
|
||||
}
|
||||
const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error';
|
||||
const err = new Error(msg);
|
||||
err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot };
|
||||
throw err;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export async function executeScript(code, { noRecord } = {}) {
|
||||
const output = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
console.log = (...a) => output.push(a.map(String).join(' '));
|
||||
console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' '));
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const ctx = buildContext({ noRecord });
|
||||
|
||||
// Normalize Windows backslash paths to prevent JS parse errors
|
||||
// (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence")
|
||||
code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/'));
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||
const fn = new AsyncFunction(...Object.keys(ctx), code);
|
||||
await fn(...Object.values(ctx));
|
||||
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) };
|
||||
} catch (e) {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
|
||||
// Auto-stop recording if active (prevents "Already recording" on next exec)
|
||||
if (browser.isRecording()) {
|
||||
try { await browser.stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
// Error screenshot (skip if already taken before fetchErrorStack closed the modal)
|
||||
let shotFile = e.onecError?.screenshot;
|
||||
if (!shotFile) {
|
||||
try {
|
||||
const png = await browser.screenshot();
|
||||
shotFile = nextErrorShotPath('script');
|
||||
writeFileSync(shotFile, png);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) };
|
||||
|
||||
// Enrich with 1C error context if available
|
||||
if (e.onecError) {
|
||||
result.step = e.onecError.step;
|
||||
result.stepArgs = e.onecError.args;
|
||||
result.onecErrors = e.onecError.errors;
|
||||
result.formState = e.onecError.formState;
|
||||
if (e.onecError.stack) result.stack = e.onecError.stack;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import * as browser from '../browser.mjs';
|
||||
import { json, readBody } from './util.mjs';
|
||||
import { cleanup } from './session.mjs';
|
||||
import { executeScript } from './exec-context.mjs';
|
||||
|
||||
export async function handleRequest(req, res) {
|
||||
try {
|
||||
if (req.method === 'POST' && req.url === '/exec') {
|
||||
const code = await readBody(req);
|
||||
const noRecord = req.headers['x-no-record'] === '1';
|
||||
const result = await executeScript(code, { noRecord });
|
||||
json(res, result);
|
||||
|
||||
} else if (req.method === 'GET' && req.url === '/shot') {
|
||||
const png = await browser.screenshot();
|
||||
res.writeHead(200, { 'Content-Type': 'image/png' });
|
||||
res.end(png);
|
||||
|
||||
} else if (req.method === 'POST' && req.url === '/stop') {
|
||||
json(res, { ok: true, message: 'Stopping' });
|
||||
await browser.disconnect();
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
|
||||
} else if (req.method === 'GET' && req.url === '/status') {
|
||||
json(res, { ok: true, connected: browser.isConnected() });
|
||||
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
} catch (e) {
|
||||
json(res, { ok: false, error: e.message }, 500);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// web-test cli/session v1.0 — session-file helpers for HTTP-server mode
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { die } from './util.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json');
|
||||
|
||||
export function loadSession() {
|
||||
if (!existsSync(SESSION_FILE)) {
|
||||
die('No active session. Run: node src/run.mjs start <url>');
|
||||
}
|
||||
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
try { unlinkSync(SESSION_FILE); } catch {}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// web-test cli/test-runner/assertions v1.1 — ctx.assert API
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
export function createAssertions() {
|
||||
class AssertionError extends Error {
|
||||
constructor(msg, actual, expected) {
|
||||
super(msg);
|
||||
this.name = 'AssertionError';
|
||||
this.actual = actual;
|
||||
this.expected = expected;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok(value, msg) {
|
||||
if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true);
|
||||
},
|
||||
equal(actual, expected, msg) {
|
||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected);
|
||||
},
|
||||
notEqual(actual, expected, msg) {
|
||||
if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected);
|
||||
},
|
||||
deepEqual(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual), b = JSON.stringify(expected);
|
||||
if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected);
|
||||
},
|
||||
includes(haystack, needle, msg) {
|
||||
const h = Array.isArray(haystack) ? haystack : String(haystack);
|
||||
if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle);
|
||||
},
|
||||
match(string, regex, msg) {
|
||||
if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex);
|
||||
},
|
||||
async throws(fn, msg) {
|
||||
try { await fn(); } catch { return; }
|
||||
throw new AssertionError(msg || 'Expected function to throw');
|
||||
},
|
||||
// 1C-specific
|
||||
// `fields` is an ARRAY of { name, value, ... } — indexing it by field name yields undefined,
|
||||
// which used to make this assertion throw on every call, including the valid ones.
|
||||
formHasField(state, fieldName, msg) {
|
||||
const names = (state?.fields || []).map(f => f.name);
|
||||
if (!names.includes(fieldName)) {
|
||||
throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${names.join(', ')}`, null, fieldName);
|
||||
}
|
||||
},
|
||||
formTitle(state, expected, msg) {
|
||||
// `title` is null when the form exposes no caption and the open-windows panel is off —
|
||||
// say so instead of reporting a mismatch against "null".
|
||||
if (state?.title == null) {
|
||||
throw new AssertionError(msg || `Form title is not available (state.title is null), expected it to contain "${expected}"`, null, expected);
|
||||
}
|
||||
if (!state.title.includes(expected)) {
|
||||
throw new AssertionError(msg || `Form title "${state.title}" does not contain "${expected}"`, state.title, expected);
|
||||
}
|
||||
},
|
||||
tableHasRow(table, predicate, msg) {
|
||||
const rows = table?.rows || [];
|
||||
let found;
|
||||
if (typeof predicate === 'function') {
|
||||
found = rows.some(predicate);
|
||||
} else {
|
||||
found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v));
|
||||
}
|
||||
if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate);
|
||||
},
|
||||
tableRowCount(table, expected, msg) {
|
||||
const actual = table?.rows?.length ?? 0;
|
||||
if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected);
|
||||
},
|
||||
noErrors(state, msg) {
|
||||
if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,113 +0,0 @@
|
||||
// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs';
|
||||
import { resolve, dirname, basename, relative } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { xmlEscape } from '../util.mjs';
|
||||
import { resolveSeverity } from './severity.mjs';
|
||||
|
||||
/**
|
||||
* Copy any files from `<testDir>/_allure/` into `reportDir`. Convention for
|
||||
* Allure customization that doesn't fit inside per-test JSON:
|
||||
* - `categories.json` — failure classification (regex → bucket)
|
||||
* - `environment.properties` — values shown in the Environment widget
|
||||
* - `executor.json` — CI/CD metadata
|
||||
* Underscored folder mirrors `_hooks.mjs` convention (infra, not a test).
|
||||
* Silent if folder absent.
|
||||
*/
|
||||
export function syncAllureExtras(testDir, reportDir) {
|
||||
const extrasDir = resolve(testDir, '_allure');
|
||||
if (!existsSync(extrasDir)) return;
|
||||
try {
|
||||
if (!statSync(extrasDir).isDirectory()) return;
|
||||
} catch { return; }
|
||||
for (const entry of readdirSync(extrasDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAllure(results, reportDir, severityIndex) {
|
||||
for (const tr of results) {
|
||||
if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop
|
||||
const uuid = randomUUID();
|
||||
const suite = dirname(tr.file);
|
||||
const suiteLabel = (suite && suite !== '.') ? suite : 'root';
|
||||
const severity = resolveSeverity(tr, severityIndex);
|
||||
const out = {
|
||||
uuid,
|
||||
name: tr.name,
|
||||
fullName: tr.file,
|
||||
status: tr.status,
|
||||
stage: 'finished',
|
||||
start: tr.start,
|
||||
stop: tr.stop,
|
||||
labels: [
|
||||
...(tr.tags || []).map(t => ({ name: 'tag', value: t })),
|
||||
{ name: 'suite', value: suiteLabel },
|
||||
{ name: 'severity', value: severity },
|
||||
],
|
||||
steps: (tr.steps || []).map(allureStep),
|
||||
attachments: [
|
||||
...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []),
|
||||
...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []),
|
||||
],
|
||||
};
|
||||
if (tr.status === 'failed' && tr.error) {
|
||||
const traceParts = [];
|
||||
if (tr.output) traceParts.push(tr.output);
|
||||
const onecStack = tr.error.onecError?.stack?.raw;
|
||||
if (onecStack) {
|
||||
if (traceParts.length) traceParts.push('\n--- 1C stack ---\n');
|
||||
traceParts.push(onecStack);
|
||||
}
|
||||
out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') };
|
||||
}
|
||||
writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function allureStep(s) {
|
||||
const out = {
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
stage: 'finished',
|
||||
start: s.start,
|
||||
stop: s.stop,
|
||||
steps: (s.steps || []).map(allureStep),
|
||||
};
|
||||
if (s.screenshot) {
|
||||
out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }];
|
||||
}
|
||||
if (s.status === 'failed' && s.error) {
|
||||
out.statusDetails = { message: s.error, trace: s.error };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildJUnit(report, testDir) {
|
||||
const { summary, duration, tests } = report;
|
||||
const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.';
|
||||
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
|
||||
lines.push(`<testsuites name="web-test" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||
lines.push(` <testsuite name="${xmlEscape(suiteName)}" tests="${summary.total}" failures="${summary.failed}" skipped="${summary.skipped}" time="${duration.toFixed(3)}">`);
|
||||
for (const t of tests) {
|
||||
const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`;
|
||||
if (t.status === 'passed') {
|
||||
lines.push(` <testcase ${attrs}/>`);
|
||||
} else if (t.status === 'skipped') {
|
||||
lines.push(` <testcase ${attrs}><skipped/></testcase>`);
|
||||
} else {
|
||||
lines.push(` <testcase ${attrs}>`);
|
||||
const msg = t.error?.message || '';
|
||||
const trace = t.output || '';
|
||||
lines.push(` <failure message="${xmlEscape(msg)}">${xmlEscape(trace)}</failure>`);
|
||||
if (t.screenshot) lines.push(` <system-out>screenshot: ${xmlEscape(t.screenshot)}</system-out>`);
|
||||
lines.push(` </testcase>`);
|
||||
}
|
||||
}
|
||||
lines.push(` </testsuite>`);
|
||||
lines.push(`</testsuites>`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { die } from '../util.mjs';
|
||||
|
||||
export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 };
|
||||
export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK);
|
||||
|
||||
/**
|
||||
* Validate config.severity (inverted map: severity → [tags]) at config load time.
|
||||
* Returns:
|
||||
* - tagToSeverity: Map<tag, severity> (precomputed lookup for the resolver)
|
||||
* - defaultSeverity: string (validated, defaults to 'normal')
|
||||
* Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets.
|
||||
*/
|
||||
export function buildSeverityIndex(config) {
|
||||
const tagToSeverity = new Map();
|
||||
const sev = config.severity || {};
|
||||
if (typeof sev !== 'object' || Array.isArray(sev)) {
|
||||
die(`config.severity must be an object, got ${typeof sev}`);
|
||||
}
|
||||
for (const [level, tags] of Object.entries(sev)) {
|
||||
if (!SEVERITY_LEVELS.includes(level)) {
|
||||
die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||
}
|
||||
if (!Array.isArray(tags)) {
|
||||
die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`);
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (tagToSeverity.has(tag)) {
|
||||
die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`);
|
||||
}
|
||||
tagToSeverity.set(tag, level);
|
||||
}
|
||||
}
|
||||
const def = config.defaultSeverity || 'normal';
|
||||
if (!SEVERITY_LEVELS.includes(def)) {
|
||||
die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`);
|
||||
}
|
||||
return { tagToSeverity, defaultSeverity: def };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a test's severity. Precedence:
|
||||
* 1. explicit `export const severity` from the test module
|
||||
* 2. max-rank severity found among tags (either standard severity name, or mapped via config)
|
||||
* 3. defaultSeverity from config (or 'normal' if not set)
|
||||
* Returns one of SEVERITY_LEVELS.
|
||||
*/
|
||||
export function resolveSeverity(t, severityIndex) {
|
||||
if (t.severity) {
|
||||
if (!SEVERITY_LEVELS.includes(t.severity)) {
|
||||
return severityIndex.defaultSeverity;
|
||||
}
|
||||
return t.severity;
|
||||
}
|
||||
let best = null;
|
||||
for (const tag of t.tags || []) {
|
||||
let candidate = null;
|
||||
if (SEVERITY_LEVELS.includes(tag)) candidate = tag;
|
||||
else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag);
|
||||
if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best || severityIndex.defaultSeverity;
|
||||
}
|
||||
@@ -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,134 +0,0 @@
|
||||
// web-test cli/util v1.4 — generic helpers for CLI commands
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
// Wall-clock bounds live in the engine (session.mjs needs them too, and the engine must not
|
||||
// depend on cli/). Re-exported here so CLI callers have one import site.
|
||||
export { withDeadline, softDeadline, DeadlineError } from '../engine/core/deadline.mjs';
|
||||
|
||||
export function out(obj) {
|
||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||
}
|
||||
|
||||
export function die(msg) {
|
||||
process.stderr.write(msg + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function json(res, obj, status = 200) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
export async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
export function elapsed(t0) {
|
||||
return Math.round((Date.now() - t0) / 100) / 10;
|
||||
}
|
||||
|
||||
export function elapsed2(start, stop) {
|
||||
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
||||
}
|
||||
|
||||
const TRANSLIT = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i',
|
||||
й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't',
|
||||
у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '',
|
||||
э: 'e', ю: 'yu', я: 'ya',
|
||||
};
|
||||
|
||||
/**
|
||||
* ASCII-only slug for artifact file names (screenshots, videos).
|
||||
* Non-ASCII names are unusable as Allure attachments: the Allure CLI silently
|
||||
* fails to resolve them and emits `"size": 0` with no link to the file
|
||||
* (JAVA_OPTS encoding flags do not help). Cyrillic is transliterated so the
|
||||
* name stays readable; anything else non-ASCII collapses to `-`.
|
||||
*/
|
||||
export function slugify(s) {
|
||||
const ascii = String(s).trim().toLowerCase()
|
||||
.replace(/[а-яё]/g, ch => TRANSLIT[ch] ?? '-');
|
||||
return ascii
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 60)
|
||||
.replace(/^-|-$/g, '') || 'step';
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round((seconds - m * 60) * 10) / 10;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
export function xmlEscape(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function interpolate(template, params) {
|
||||
return String(template).replace(/\{(\w+)\}/g, (_, key) =>
|
||||
params[key] !== undefined ? String(params[key]) : `{${key}}`);
|
||||
}
|
||||
|
||||
export function printSteps(W, steps, indent) {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const s = steps[i];
|
||||
const last = i === steps.length - 1;
|
||||
const prefix = last ? '└' : '├';
|
||||
const mark = s.status === 'failed' ? '✗ ' : '';
|
||||
W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`);
|
||||
if (s.error && s.status === 'failed') {
|
||||
W.write(`${indent} ${s.error}\n`);
|
||||
}
|
||||
if (s.steps.length) printSteps(W, s.steps, indent + ' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function usage() {
|
||||
die(`Usage: node run.mjs <command> [args]
|
||||
|
||||
Commands:
|
||||
start <url> Launch browser and connect to 1C web client
|
||||
run <url> <file|-> Autonomous: connect, execute script, disconnect
|
||||
exec <file|-> [options] Execute script (file path or - for stdin)
|
||||
shot [file] Take screenshot (default: shot.png)
|
||||
stop Logout and close browser
|
||||
status Check session status
|
||||
test <dir|file>... Run regression tests (*.test.mjs); accepts multiple paths
|
||||
|
||||
Options for exec:
|
||||
--no-record Skip video recording (record() becomes no-op)
|
||||
|
||||
Global options (any command):
|
||||
--no-preserve-clipboard Don't save/restore OS clipboard around action calls.
|
||||
Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally).
|
||||
|
||||
Options for test:
|
||||
--url=URL Override the base URL (default: from webtest.config.mjs)
|
||||
--tags=smoke,crud Filter tests by tags
|
||||
--grep=pattern Filter tests by name (regex)
|
||||
--bail Stop on first failure
|
||||
--retry=N Retry failed tests N times
|
||||
--timeout=ms Per-test timeout (default: 30000)
|
||||
--report=path Write machine report (JSON/JUnit) to file
|
||||
--report=- Write machine report to stdout (progress moves to stderr)
|
||||
--report-dir=path Directory for screenshots and other artifacts
|
||||
--screenshot=mode on-failure (default) | every-step | off
|
||||
--format=fmt json (default) | allure | junit
|
||||
--record Record video for each test (mp4 in report-dir)
|
||||
-- <hook-args...> Everything after \`--\` is forwarded to _hooks.mjs
|
||||
prepare/cleanup as hookArgs (runner does not parse it).
|
||||
Example: ... tests/web-test/ -- --rebuild-stand`);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// web-test dom v1.20 — facade re-exporting injectable DOM scripts from dom/
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Facade: re-exports DOM selector & semantic mapping script generators.
|
||||
* Внутренности живут в dom/*. Публичный набор имён неизменен.
|
||||
*
|
||||
* All functions return JavaScript strings for page.evaluate().
|
||||
* They produce clean semantic structures — no DOM IDs or CSS classes leak out.
|
||||
* Only non-default property values are included to minimize response size.
|
||||
*/
|
||||
|
||||
export {
|
||||
detectFormScript,
|
||||
closeCrossScript,
|
||||
readFormScript,
|
||||
findClickTargetScript,
|
||||
findFieldButtonScript,
|
||||
resolveFieldsScript,
|
||||
detectNewFormScript,
|
||||
findSearchInputScript,
|
||||
findNamedButtonScript,
|
||||
findCompareTypeRadioScript,
|
||||
isFormVisibleScript,
|
||||
findPatternInputIdScript,
|
||||
isTypeDialogScript,
|
||||
isNotInListCloudVisibleScript,
|
||||
clickShowAllInNotInListCloudScript,
|
||||
findChildFormByButtonScript,
|
||||
readTypeDialogVisibleRowsScript,
|
||||
} from './dom/forms.mjs';
|
||||
|
||||
export {
|
||||
findFirstGridCellCoordsScript,
|
||||
findColumnFirstCellCoordsScript,
|
||||
readFieldSelectorInfoScript,
|
||||
pickFieldInSelectorDropdownScript,
|
||||
readFilterDialogInfoScript,
|
||||
findFilterBadgeCloseScript,
|
||||
findFirstFilterBadgeCloseScript,
|
||||
} from './dom/filter.mjs';
|
||||
|
||||
export {
|
||||
isInputFocusedScript,
|
||||
isInputFocusedInGridScript,
|
||||
findOpenPopupScript,
|
||||
} from './dom/edit-state.mjs';
|
||||
|
||||
export {
|
||||
readEddScript,
|
||||
isEddVisibleScript,
|
||||
clickEddItemViaDispatchScript,
|
||||
clickShowAllInEddScript,
|
||||
} from './dom/edd.mjs';
|
||||
|
||||
export { getFormStateScript } from './dom/form-state.mjs';
|
||||
|
||||
export {
|
||||
resolveGridScript,
|
||||
readTableScript,
|
||||
countGridRowsScript,
|
||||
isTreeGridScript,
|
||||
findGridHeadCenterCoordsScript,
|
||||
getSelectedOrLastRowIndexScript,
|
||||
findGridCellScript,
|
||||
findFocusCellScript,
|
||||
snapshotGridScript,
|
||||
resolveCellTargetScript,
|
||||
} from './dom/grid.mjs';
|
||||
|
||||
export {
|
||||
sortFieldKeysByColindexScript,
|
||||
findCellCoordsByFieldsScript,
|
||||
findNextCellCoordsByKeyScript,
|
||||
findCheckboxAtPointScript,
|
||||
findRowCommitClickCoordsScript,
|
||||
getGridEditCheckScript,
|
||||
readActiveGridCellScript,
|
||||
getElementCenterCoordsByIdScript,
|
||||
} from './dom/grid-edit.mjs';
|
||||
|
||||
export {
|
||||
readSectionsScript,
|
||||
readTabsScript,
|
||||
switchTabScript,
|
||||
readCommandsScript,
|
||||
navigateSectionScript,
|
||||
openCommandScript,
|
||||
} from './dom/nav.mjs';
|
||||
|
||||
export {
|
||||
readSubmenuScript,
|
||||
clickPopupItemScript,
|
||||
readCloudDDScript,
|
||||
} from './dom/submenu.mjs';
|
||||
|
||||
export { checkErrorsScript } from './dom/errors.mjs';
|
||||
@@ -1,748 +0,0 @@
|
||||
// web-test dom shared v1.7 — 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) };
|
||||
}`;
|
||||
|
||||
/**
|
||||
* 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 }));
|
||||
});
|
||||
|
||||
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 + `
|
||||
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, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// 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) группа.
|
||||
// Обычные (несворачиваемые) группы не имеют ничего из этого — их не показываем.
|
||||
// Состояние:
|
||||
// • popup: display панели <base>#panel_div (none → закрыта);
|
||||
// • collapsible: DOM у 1С плоский (контент — сиблинги под mainGroup, не вложены), но при
|
||||
// глубинном обходе Form.xml ПЕРВЫЙ контент-сиблинг сразу за #title_div — всегда дочерний
|
||||
// элемент группы (свободные соседи идут после всех детей). Его display = состояние.
|
||||
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';
|
||||
g.collapsed = getComputedStyle(panelDiv).display === 'none';
|
||||
} else {
|
||||
const contentSib = document.getElementById(base + '#title_div')?.nextElementSibling;
|
||||
if (contentSib) g.collapsed = getComputedStyle(contentSib).display === 'none';
|
||||
}
|
||||
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,108 +0,0 @@
|
||||
// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Read the `#editDropDown` autocomplete popup.
|
||||
*
|
||||
* Returns `{ visible: false }` when EDD is absent/hidden, or
|
||||
* `{ visible: true, items: [{ name, x, y }] }` with center coords suitable
|
||||
* for `page.mouse.click(x, y)`.
|
||||
*
|
||||
* Note: `page.mouse.click` is often intercepted by `div.surface` overlays
|
||||
* from DLB — prefer `clickEddItemViaDispatchScript` for those cases.
|
||||
*/
|
||||
export function readEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { visible: false };
|
||||
const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
return {
|
||||
visible: true,
|
||||
items: eddTexts.map(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
})
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the EDD popup currently visible? Returns boolean.
|
||||
* Lighter than `readEddScript` when only presence matters.
|
||||
*/
|
||||
export function isEddVisibleScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
return !!(edd && edd.offsetWidth > 0);
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click an EDD item by name via `dispatchEvent` — bypasses `div.surface`
|
||||
* overlays from DLB that intercept `page.mouse.click`.
|
||||
*
|
||||
* Matching is fuzzy: exact (with optional `(suffix)` strip) → includes,
|
||||
* normalizes ё/е and NBSP.
|
||||
*
|
||||
* Returns the clicked item's innerText (trimmed), or `null` when no match.
|
||||
*/
|
||||
export function clickEddItemViaDispatchScript(itemName) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return null;
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(itemName.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0);
|
||||
function clickEl(el) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return el.innerText.trim();
|
||||
}
|
||||
// Pass 1: exact match (prefer over partial)
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t === target) return clickEl(el);
|
||||
const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, '');
|
||||
if (stripped === target) return clickEl(el);
|
||||
}
|
||||
// Pass 2: partial match
|
||||
for (const el of items) {
|
||||
const t = ny((el.innerText?.trim() || '').toLowerCase());
|
||||
if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el);
|
||||
}
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "Показать все" / "Show all" link in the EDD footer via
|
||||
* `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back
|
||||
* to scanning for span/div/a with the literal text.
|
||||
*
|
||||
* Returns boolean — whether the link was found and clicked.
|
||||
*/
|
||||
export function clickShowAllInEddScript() {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return false;
|
||||
let el = edd.querySelector('.eddBottom .hyperlink');
|
||||
if (!el || el.offsetWidth === 0) {
|
||||
const candidates = [...edd.querySelectorAll('span, div, a')]
|
||||
.filter(e => e.offsetWidth > 0 && e.children.length === 0);
|
||||
el = candidates.find(e => {
|
||||
const t = (e.innerText?.trim() || '').toLowerCase();
|
||||
return t === 'показать все' || t === 'show all';
|
||||
});
|
||||
}
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 };
|
||||
el.dispatchEvent(new MouseEvent('mousedown', opts));
|
||||
el.dispatchEvent(new MouseEvent('mouseup', opts));
|
||||
el.dispatchEvent(new MouseEvent('click', opts));
|
||||
return true;
|
||||
})()`;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// web-test dom/edit-state v1.1 — focus and popup detection inside the 1C web client
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Is the currently focused element an INPUT (optionally TEXTAREA too)?
|
||||
* Returns boolean.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA.
|
||||
*/
|
||||
export function isInputFocusedScript({ allowTextarea = false } = {}) {
|
||||
const cond = allowTextarea
|
||||
? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'`
|
||||
: `f.tagName === 'INPUT'`;
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
return !!(f && (${cond}));
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor?
|
||||
* Used to verify grid edit-mode (active cell editor).
|
||||
*
|
||||
* @param {string} [gridSelector] — when given, only `true` if the focused input
|
||||
* is inside that specific grid. Without it — any `.grid` ancestor counts.
|
||||
*
|
||||
* Returns boolean.
|
||||
*/
|
||||
export function isInputFocusedInGridScript(gridSelector) {
|
||||
const sel = gridSelector ? JSON.stringify(gridSelector) : 'null';
|
||||
return `(() => {
|
||||
const f = document.activeElement;
|
||||
if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false;
|
||||
const sel = ${sel};
|
||||
if (sel) {
|
||||
const grid = document.querySelector(sel);
|
||||
return !!(grid && grid.contains(f));
|
||||
}
|
||||
let n = f;
|
||||
while (n) {
|
||||
if (n.classList?.contains('grid')) return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible?
|
||||
* Returns `'calculator' | 'calendar' | null`.
|
||||
*
|
||||
* For the "popup gone" check, callers use: `!await findOpenPopup()`.
|
||||
*/
|
||||
export function findOpenPopupScript() {
|
||||
return `(() => {
|
||||
const calc = document.querySelector('.calculate');
|
||||
if (calc && calc.offsetWidth > 0) return 'calculator';
|
||||
const cal = document.querySelector('.frameCalendar');
|
||||
if (cal && cal.offsetWidth > 0) return 'calendar';
|
||||
return null;
|
||||
})()`;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
//
|
||||
// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link,
|
||||
// open detailed error dialog, read textarea, close cleanup dialogs.
|
||||
|
||||
/** Find OpenReport link coordinates on the error modal for given formNum. */
|
||||
export function getOpenReportCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const el = document.getElementById('form${formNum}_OpenReport#text');
|
||||
if (!el || el.offsetWidth <= 2) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */
|
||||
export function isErrorDetailLinkVisibleScript() {
|
||||
return `(() => {
|
||||
const links = document.querySelectorAll('a, [class*="hyper"], span');
|
||||
for (const el of links) {
|
||||
if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true;
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Read the largest visible non-empty textarea — contains the detailed error stack. */
|
||||
export function readLargestVisibleTextareaScript() {
|
||||
return `(() => {
|
||||
let best = null;
|
||||
document.querySelectorAll('textarea').forEach(ta => {
|
||||
if (ta.offsetWidth > 0 && ta.value.length > 0) {
|
||||
if (!best || ta.value.length > best.value.length) best = ta;
|
||||
}
|
||||
});
|
||||
return best?.value || null;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */
|
||||
export function clickTopCloudOkButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0)
|
||||
.sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0'));
|
||||
for (const w of psWins) {
|
||||
const ok = w.querySelector('button.webBtn, .pressDefault');
|
||||
if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
}
|
||||
|
||||
/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */
|
||||
export function clickReportCloseButtonScript() {
|
||||
return `(() => {
|
||||
const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')]
|
||||
.filter(w => w.offsetWidth > 0);
|
||||
for (const w of psWins) {
|
||||
const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]');
|
||||
if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; }
|
||||
}
|
||||
})()`;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow)
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
/**
|
||||
* Check for validation errors / diagnostics after an action.
|
||||
* Detects three patterns:
|
||||
* 1. Inline balloon tooltip (div.balloon with .balloonMessage)
|
||||
* 2. Messages panel (div.messages with msg0, msg1... grid rows)
|
||||
* 3. Modal error dialog (high-numbered form with pressDefault + static texts)
|
||||
* Returns { balloon, messages[], modal } or null if no errors.
|
||||
*/
|
||||
export function checkErrorsScript() {
|
||||
return `(() => {
|
||||
const result = {};
|
||||
|
||||
// 1. Inline balloon tooltip
|
||||
const balloon = document.querySelector('.balloon');
|
||||
if (balloon && balloon.offsetWidth > 0) {
|
||||
const msg = balloon.querySelector('.balloonMessage');
|
||||
const title = balloon.querySelector('.balloonTitle');
|
||||
if (msg) {
|
||||
result.balloon = {
|
||||
title: title?.innerText?.trim() || 'Ошибка',
|
||||
message: msg.innerText?.trim() || ''
|
||||
};
|
||||
// Count navigation arrows to indicate total errors
|
||||
const fwd = balloon.querySelector('.balloonJumpFwd');
|
||||
const back = balloon.querySelector('.balloonJumpBack');
|
||||
const fwdDisabled = fwd?.classList.contains('disabled');
|
||||
const backDisabled = back?.classList.contains('disabled');
|
||||
if (fwd && !fwdDisabled) result.balloon.hasNext = true;
|
||||
if (back && !backDisabled) result.balloon.hasPrev = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs)
|
||||
const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0);
|
||||
for (const msgPanel of msgPanels) {
|
||||
const msgs = [];
|
||||
msgPanel.querySelectorAll('[id^="msg"]').forEach(line => {
|
||||
if (line.offsetWidth === 0) return;
|
||||
const textEl = line.querySelector('.gridBoxText');
|
||||
const text = (textEl || line).innerText?.trim();
|
||||
if (text) msgs.push(text);
|
||||
});
|
||||
if (msgs.length > 0) { result.messages = msgs; break; }
|
||||
}
|
||||
|
||||
// 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault)
|
||||
// Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs
|
||||
// Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window)
|
||||
// so we always scan for small forms with button patterns, regardless of modalSurface state
|
||||
const formButtons = {};
|
||||
[...document.querySelectorAll('a.press.pressButton')].forEach(btn => {
|
||||
if (btn.offsetWidth === 0) return;
|
||||
const container = btn.closest('[id$="_container"]');
|
||||
const m = container?.id?.match(/^form(\\d+)_/);
|
||||
if (!m) return;
|
||||
const fn = m[1];
|
||||
if (!formButtons[fn]) formButtons[fn] = [];
|
||||
formButtons[fn].push(btn);
|
||||
});
|
||||
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue; // Skip large content forms
|
||||
if (buttons.length > 1) {
|
||||
// Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.)
|
||||
// Must have a Message element — real 1C confirmations always have form{N}_Message.
|
||||
// Without it, this is just a regular form with multiple buttons (e.g. EPF form).
|
||||
const msgEl = document.getElementById(p + 'Message');
|
||||
if (!msgEl || msgEl.offsetWidth === 0) continue;
|
||||
const message = msgEl.innerText?.trim() || '';
|
||||
const btnNames = buttons.map(el => {
|
||||
const b = { name: el.innerText?.trim() || '' };
|
||||
if (el.classList.contains('pressDefault')) b.default = true;
|
||||
return b;
|
||||
}).filter(b => b.name);
|
||||
result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Single-button modal: error dialog with pressDefault + staticText
|
||||
// Skip forms with input fields — those are data entry forms (e.g. register record),
|
||||
// not error dialogs. Real error modals only have staticText + buttons.
|
||||
if (!result.confirmation) {
|
||||
for (const [fn, buttons] of Object.entries(formButtons)) {
|
||||
const p = 'form' + fn + '_';
|
||||
const elCount = document.querySelectorAll('[id^="' + p + '"]').length;
|
||||
if (elCount > 100) continue;
|
||||
if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue;
|
||||
const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0;
|
||||
if (hasInputs) continue;
|
||||
const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')]
|
||||
.filter(el => el.offsetWidth > 0)
|
||||
.map(el => el.innerText?.trim())
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' };
|
||||
// Check if OpenReport link is available (platform exceptions have visible link text)
|
||||
const reportLink = document.getElementById(p + 'OpenReport#text');
|
||||
if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) {
|
||||
result.modal.hasReport = true;
|
||||
}
|
||||
// Grab AdditionalInfo/ServerText if filled (may contain extra error details)
|
||||
const addInfo = document.getElementById(p + 'AdditionalInfo');
|
||||
if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim();
|
||||
const srvText = document.getElementById(p + 'ServerText');
|
||||
if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. SpreadsheetDocument state window (info bar inside moxelContainer)
|
||||
// Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован"
|
||||
const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0);
|
||||
if (stateWins.length) {
|
||||
const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean);
|
||||
if (texts.length) result.stateText = texts;
|
||||
}
|
||||
|
||||
return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null;
|
||||
})()`;
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// web-test dom/filter v1.1 — DOM scripts for filterList / unfilterList
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import { COLUMN_MODEL_FN } from './_shared.mjs';
|
||||
|
||||
/**
|
||||
* Find the first grid cell on the form and return its center coords.
|
||||
* Used as a fallback target for Alt+F when there's no search input.
|
||||
*
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstGridCellCoordsScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return null;
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return null;
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return null;
|
||||
const r = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the grid cell of the first row in the column whose header text matches `field`
|
||||
* (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* If the column isn't in the grid, returns coords of the first cell + `needDlb: true`
|
||||
* so the caller can use DLB to switch FieldSelector after opening the dialog.
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, needDlb? } ` — coords to click (advanced search target)
|
||||
* - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'`
|
||||
*/
|
||||
export function findColumnFirstCellCoordsScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||
.find(g => g.offsetWidth > 0);
|
||||
if (!grid) return { error: 'no_grid' };
|
||||
${COLUMN_MODEL_FN}
|
||||
const targetField = ${JSON.stringify(field)};
|
||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||
if (!rows.length) return { error: 'no_rows' };
|
||||
|
||||
// Column resolution — shared model (dom/_shared.mjs), same as readTable/click. The old
|
||||
// positional index (Nth header box → Nth cell box) breaks on multi-row headers, where
|
||||
// header and cell counts and order differ.
|
||||
const model = buildColumnModel(grid);
|
||||
const col = resolveColumnByName(model, targetField);
|
||||
if (!col) {
|
||||
// Unknown column → click the first cell and let the caller fall back to DLB.
|
||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||
if (!cells.length) return { error: 'no_cells' };
|
||||
const r0 = cells[0].getBoundingClientRect();
|
||||
return { x: Math.round(r0.x + r0.width / 2), y: Math.round(r0.y + r0.height / 2), needDlb: true };
|
||||
}
|
||||
const cell = cellForColumn(model, rows[0], col);
|
||||
if (!cell) return { error: 'cell_not_found' };
|
||||
const r = cell.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read FieldSelector input + its DLB button coords on the advanced search dialog.
|
||||
* Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible).
|
||||
*/
|
||||
export function readFieldSelectorInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const dlb = document.getElementById(p + 'FieldSelector_DLB');
|
||||
return {
|
||||
current: fsInput?.value?.trim() || '',
|
||||
dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0,
|
||||
dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes,
|
||||
* normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, name }` — coords + matched name to click
|
||||
* - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names
|
||||
*/
|
||||
export function pickFieldInSelectorDropdownScript(field) {
|
||||
return `(() => {
|
||||
const edd = document.getElementById('editDropDown');
|
||||
if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' };
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...edd.querySelectorAll('div')].filter(el =>
|
||||
el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n'));
|
||||
const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target)
|
||||
|| items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target));
|
||||
if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) };
|
||||
const r = match.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read advanced search dialog state — FieldSelector value, Pattern input id+value,
|
||||
* and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern).
|
||||
*
|
||||
* Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`.
|
||||
*/
|
||||
export function readFilterDialogInfoScript(dialogForm) {
|
||||
return `(() => {
|
||||
const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_';
|
||||
const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id));
|
||||
const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')]
|
||||
.find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id));
|
||||
const ptLabel = ptInput?.closest('label');
|
||||
const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : [];
|
||||
const isDate = btns.some(c => c.includes('iCalendB'));
|
||||
const isRef = !isDate && btns.some(c => c.includes('iDLB'));
|
||||
return {
|
||||
fieldSelector: fsInput?.value?.trim() || '',
|
||||
patternValue: ptInput?.value?.trim() || '',
|
||||
patternId: ptInput?.id || '',
|
||||
isDate,
|
||||
isRef
|
||||
};
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the filter badge whose title matches `field`
|
||||
* (exact → includes; normalizes ё/е and NBSP).
|
||||
*
|
||||
* Returns:
|
||||
* - `{ x, y, field }` — coords + actual field title from the badge
|
||||
* - `{ error, available }` — `'not_found'` with list of available badge titles
|
||||
*/
|
||||
export function findFilterBadgeCloseScript(formNum, field) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || '';
|
||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
||||
const target = ny(${JSON.stringify(field.toLowerCase())});
|
||||
const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0);
|
||||
for (const item of items) {
|
||||
const titleEl = item.querySelector('.trainName');
|
||||
const title = ny(norm(titleEl?.innerText).toLowerCase());
|
||||
if (title === target || title.includes(target)) {
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (close) {
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) };
|
||||
}
|
||||
}
|
||||
}
|
||||
const available = items.map(item => norm(item.querySelector('.trainName')?.innerText));
|
||||
return { error: 'not_found', available };
|
||||
})()`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the × close button on the FIRST visible filter badge (for clear-all loop).
|
||||
* Returns `{ x, y } | null`.
|
||||
*/
|
||||
export function findFirstFilterBadgeCloseScript(formNum) {
|
||||
return `(() => {
|
||||
const p = 'form${formNum}_';
|
||||
const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')]
|
||||
.find(el => el.offsetWidth > 0);
|
||||
if (!item) return null;
|
||||
const close = item.querySelector('.trainClose');
|
||||
if (!close) return null;
|
||||
const r = close.getBoundingClientRect();
|
||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||
})()`;
|
||||
}
|
||||
@@ -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;
|
||||
})()`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user