mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-12 21:20:52 +03:00
Compare commits
537
Commits
@@ -1,164 +0,0 @@
|
||||
# cf-edit — справочник операций
|
||||
|
||||
## modify-property
|
||||
|
||||
Свойства для редактирования:
|
||||
|
||||
### Скалярные
|
||||
`Name`, `Version`, `Vendor`, `Comment`, `NamePrefix`, `UpdateCatalogAddress`
|
||||
|
||||
### LocalString (многоязычные)
|
||||
`Synonym`, `BriefInformation`, `DetailedInformation`, `Copyright`, `VendorInformationAddress`, `ConfigurationInformationAddress`
|
||||
|
||||
### Enum
|
||||
| Свойство | Допустимые значения |
|
||||
|----------|---------------------|
|
||||
| `CompatibilityMode` | `Version8_3_20` ... `Version8_3_28`, `Version8_5_1`, `DontUse` |
|
||||
| `ConfigurationExtensionCompatibilityMode` | то же |
|
||||
| `DefaultRunMode` | `ManagedApplication`, `OrdinaryApplication`, `Auto` |
|
||||
| `ScriptVariant` | `Russian`, `English` |
|
||||
| `DataLockControlMode` | `Managed`, `Automatic`, `AutomaticAndManaged` |
|
||||
| `ObjectAutonumerationMode` | `NotAutoFree`, `AutoFree` |
|
||||
| `ModalityUseMode` | `DontUse`, `Use`, `UseWithWarnings` |
|
||||
| `SynchronousPlatformExtensionAndAddInCallUseMode` | `DontUse`, `Use`, `UseWithWarnings` |
|
||||
| `InterfaceCompatibilityMode` | `Version8_2`, `Version8_2EnableTaxi`, `Taxi`, `TaxiEnableVersion8_2`, `TaxiEnableVersion8_5`, `Version8_5EnableTaxi`, `Version8_5` |
|
||||
| `DatabaseTablespacesUseMode` | `DontUse`, `Use` |
|
||||
| `MainClientApplicationWindowMode` | `Normal`, `Fullscreen`, `Kiosk` |
|
||||
|
||||
### Ref
|
||||
`DefaultLanguage` — значение вида `Language.Русский`
|
||||
|
||||
### Формат batch
|
||||
`"Version=1.0.0.1 ;; Vendor=Фирма 1С ;; Synonym=Тестовая конфигурация"`
|
||||
|
||||
## add-childObject / remove-childObject
|
||||
|
||||
Формат: `Type.Name` — XML-тип и имя объекта через точку.
|
||||
|
||||
**Важно про `add-childObject`**: регистрирует в `<ChildObjects>` объект, **файл которого уже существует на диске**. Если файла нет — exit 1. Для создания нового объекта используй профильный навык — `/meta-compile` (Catalog, Document, Enum, Report, регистры и т.д.), `/role-compile` (Role), `/subsystem-compile` (Subsystem). Они создают файл И регистрируют его за один вызов.
|
||||
|
||||
Batch: `"Catalog.Товары ;; Document.Заказ ;; Enum.ВидыОплат"`
|
||||
|
||||
## sort-childObjects
|
||||
|
||||
Упорядочивает объекты в `<ChildObjects>` по имени **внутри вида**. Значение — имя вида (`Catalog`, `Role`, …), batch через `;;`. Без значения обрабатываются все виды, какие есть в файле.
|
||||
|
||||
```
|
||||
-Operation sort-childObjects — все виды, кроме перечисленных ниже
|
||||
-Operation sort-childObjects -Value "Catalog" — только справочники
|
||||
-Operation sort-childObjects -Value "Catalog ;; Role"
|
||||
```
|
||||
|
||||
Не сортируются, пока вид не назван явно: `CommonAttribute`, `Subsystem`, `CommandGroup`, `Language`.
|
||||
|
||||
Вызов без значения дополнительно ставит группы видов в канонический порядок; вызов с явным видом трогает только имена внутри него.
|
||||
|
||||
## add-defaultRole / remove-defaultRole / set-defaultRoles
|
||||
|
||||
Имя роли: `ПолныеПрава` или `Role.ПолныеПрава` (префикс `Role.` добавляется автоматически).
|
||||
|
||||
`set-defaultRoles` полностью заменяет список ролей.
|
||||
|
||||
## set-panels
|
||||
|
||||
Перезаписывает `Ext/ClientApplicationInterface.xml` — раскладку панелей рабочего пространства Taxi. Файл создаётся с нуля; то, что не упомянуто в `value`, отсутствует на экране.
|
||||
|
||||
`value` — объект с ключами `top`, `left`, `right`, `bottom`. Каждый ключ — массив записей. Ключ можно опустить (= пустая сторона).
|
||||
|
||||
**Запись** — одна из:
|
||||
- Строка-алиас (одна панель в этом слоте)
|
||||
- Объект `{"group": [...]}` (стек: панели/подгруппы внутри располагаются друг под другом)
|
||||
|
||||
**Алиасы панелей:**
|
||||
|
||||
| Алиас | Панель |
|
||||
|-------|--------|
|
||||
| `sections` | Панель разделов |
|
||||
| `open` | Панель открытых |
|
||||
| `favorites` | Панель избранного |
|
||||
| `history` | Панель истории |
|
||||
| `functions` | Панель функций текущего раздела |
|
||||
|
||||
**Семантика:**
|
||||
- Несколько записей в одной стороне → отдельные слоты «рядом» (несколько тегов `<top>`/...)
|
||||
- `{"group":[...]}` → один тег с `<group>`-обёрткой, элементы внутри идут стеком
|
||||
|
||||
**Пример** (DefinitionFile):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"operation": "set-panels",
|
||||
"value": {
|
||||
"top": ["open"],
|
||||
"left": ["sections"],
|
||||
"right": [{ "group": ["favorites", "history"] }],
|
||||
"bottom": ["functions"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Через `-Value` (CLI): передай объект как JSON-строку — `... -Operation set-panels -Value '{"top":["open"]}'`.
|
||||
|
||||
## set-home-page
|
||||
|
||||
Перезаписывает `Ext/HomePageWorkArea.xml` — раскладка форм на начальной странице (рабочая область). Файл создаётся с нуля; то, что не упомянуто в `value`, отсутствует.
|
||||
|
||||
`value` — объект:
|
||||
|
||||
| Ключ | Канонич. (XML) | Описание |
|
||||
|------|----------------|----------|
|
||||
| `template` | `WorkingAreaTemplate` | `OneColumn` / `TwoColumnsEqualWidth` (дефолт) / `TwoColumnsVariableWidth` |
|
||||
| `left` | `LeftColumn` | массив записей форм |
|
||||
| `right` | `RightColumn` | массив записей форм (запрещён при `OneColumn`) |
|
||||
|
||||
Принимаются и короткие и канонич. ключи (XML-имена) — оба работают.
|
||||
|
||||
**Запись формы** — одна из:
|
||||
- Строка `"<form>"` — только имя формы, дефолты `height=10`, `visibility=true`
|
||||
- Объект `{form, height?, visibility?, roles?}`
|
||||
|
||||
| Поле | Канонич. | Дефолт | Описание |
|
||||
|------|----------|--------|----------|
|
||||
| `form` | `Form` | — | `CommonForm.X` или `Type.Object.Form.Name` (или UUID) |
|
||||
| `height` | `Height` | `10` | Высота |
|
||||
| `visibility` | `Visibility` | `true` | Общая видимость (`<xr:Common>`) |
|
||||
| `roles` | — | — | `{"Role.Имя": true|false, ...}` — переопределения по ролям |
|
||||
|
||||
**Семантика visibility:** `visibility` = общее правило, `roles` — точечные исключения. Скрыть для всех кроме одной роли: `{"visibility": false, "roles": {"Role.Опер": true}}`.
|
||||
|
||||
**Пример:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"operation": "set-home-page",
|
||||
"value": {
|
||||
"template": "TwoColumnsVariableWidth",
|
||||
"left": [
|
||||
"CommonForm.НачалоРаботы",
|
||||
{ "form": "CommonForm.СписокЗадач", "height": 100, "visibility": false },
|
||||
{ "form": "Catalog.Контрагенты.Form.ФормаСписка", "height": 50 },
|
||||
{
|
||||
"form": "CommonForm.РабочийСтолОператора",
|
||||
"visibility": false,
|
||||
"roles": { "Role.Оператор": true, "Role.ПолныеПрава": false }
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{ "form": "DataProcessor.Поиск.Form.ФормаПоиска", "height": 30 }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## DefinitionFile (JSON)
|
||||
|
||||
```json
|
||||
[
|
||||
{ "operation": "modify-property", "value": "Version=2.0.0.1 ;; Vendor=Test" },
|
||||
{ "operation": "add-childObject", "value": "Catalog.Товары ;; Document.Заказ" },
|
||||
{ "operation": "add-defaultRole", "value": "ПолныеПрава" }
|
||||
]
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: cf-init
|
||||
description: Создать пустую конфигурацию 1С (scaffold XML-исходников). Используй когда нужно начать новую конфигурацию с нуля
|
||||
argument-hint: <Name> [-Synonym <name>] [-OutputDir src]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cf-init — Создание пустой конфигурации 1С
|
||||
|
||||
Создаёт scaffold исходников пустой конфигурации 1С: `Configuration.xml`, `Languages/Русский.xml`.
|
||||
|
||||
## Параметры и команда
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `Name` | Имя конфигурации (обязат.) |
|
||||
| `Synonym` | Синоним (= Name если не указан) |
|
||||
| `OutputDir` | Каталог для создания (default: `src`) |
|
||||
| `Version` | Версия конфигурации |
|
||||
| `Vendor` | Поставщик |
|
||||
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
||||
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
|
||||
|
||||
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
|
||||
разным правилам.
|
||||
|
||||
`FormatVersion` — **не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
|
||||
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
|
||||
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
|
||||
|
||||
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
|
||||
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
|
||||
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
|
||||
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
|
||||
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
|
||||
не будет.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/cf-init/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Базовая конфигурация
|
||||
... -Name МояКонфигурация -Synonym "Моя конфигурация" -OutputDir test-tmp/cf
|
||||
|
||||
# С версией и поставщиком
|
||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||
|
||||
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cf-init TestConfig -OutputDir test-tmp/cf
|
||||
/cf-info test-tmp/cf — проверить созданное
|
||||
/cf-validate test-tmp/cf — валидировать
|
||||
```
|
||||
@@ -1,340 +0,0 @@
|
||||
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
[string]$Synonym = $Name,
|
||||
[string]$OutputDir = "src",
|
||||
[string]$Version,
|
||||
[string]$Vendor,
|
||||
[string]$CompatibilityMode = "Version8_3_24",
|
||||
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
|
||||
# совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
|
||||
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
|
||||
}
|
||||
|
||||
# --- Resolve output dir ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
||||
$OutputDir = Join-Path (Get-Location).Path $OutputDir
|
||||
}
|
||||
|
||||
# --- Check existing ---
|
||||
$cfgFile = Join-Path $OutputDir "Configuration.xml"
|
||||
if (Test-Path $cfgFile) {
|
||||
Write-Error "Configuration.xml already exists: $cfgFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Generate UUIDs ---
|
||||
$uuidCfg = [guid]::NewGuid().ToString()
|
||||
$uuidLang = [guid]::NewGuid().ToString()
|
||||
# 7 ContainedObject ObjectIds
|
||||
$co1 = [guid]::NewGuid().ToString()
|
||||
$co2 = [guid]::NewGuid().ToString()
|
||||
$co3 = [guid]::NewGuid().ToString()
|
||||
$co4 = [guid]::NewGuid().ToString()
|
||||
$co5 = [guid]::NewGuid().ToString()
|
||||
$co6 = [guid]::NewGuid().ToString()
|
||||
$co7 = [guid]::NewGuid().ToString()
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
$is221 = ($formatRank -ge 221)
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
$is218 = ($formatRank -ge 218)
|
||||
|
||||
$mobileFuncs = @(
|
||||
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
|
||||
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
|
||||
@("Calendars","false"), @("PushNotifications","false"), @("LocalNotifications","false"),
|
||||
@("InAppPurchases","false"), @("PersonalComputerFileExchange","false"), @("Ads","false"),
|
||||
@("NumberDialing","false"), @("CallProcessing","false"), @("CallLog","false"),
|
||||
@("AutoSendSMS","false"), @("ReceiveSMS","false"), @("SMSLog","false"),
|
||||
@("Camera","false"), @("Microphone","false"), @("MusicLibrary","false"),
|
||||
@("PictureAndVideoLibraries","false"), @("AudioPlaybackAndVibration","false"),
|
||||
@("BackgroundAudioPlaybackAndVibration","false"), @("InstallPackages","false"),
|
||||
@("OSBackup","true"), @("ApplicationUsageStatistics","false"),
|
||||
@("BarcodeScanning","false"), @("BackgroundAudioRecording","false"),
|
||||
@("AllFilesAccess","false"), @("Videoconferences","false"), @("NFC","false"),
|
||||
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
|
||||
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
|
||||
)
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
|
||||
|
||||
$mobileXml = ""
|
||||
foreach ($mf in $mobileFuncs) {
|
||||
$mobileXml += "`r`n`t`t`t`t<app:functionality>`r`n`t`t`t`t`t<app:functionality>$($mf[0])</app:functionality>`r`n`t`t`t`t`t<app:use>$($mf[1])</app:use>`r`n`t`t`t`t</app:functionality>"
|
||||
}
|
||||
|
||||
# --- Synonym XML ---
|
||||
$synonymXml = ""
|
||||
if ($Synonym) {
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
|
||||
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
|
||||
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
|
||||
# на своё место, а не в конец.
|
||||
$nl = "`r`n"
|
||||
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
|
||||
$palNs = ""
|
||||
if ($is221) {
|
||||
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
|
||||
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
|
||||
$f221AuxForms = $nl + ((@(
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
|
||||
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
|
||||
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
|
||||
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
|
||||
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
|
||||
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
|
||||
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9cd510cd-abfc-11d4-9434-004095e12fc7</xr:ClassId>
|
||||
<xr:ObjectId>$co1</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9fcd25a0-4822-11d4-9414-008048da11f9</xr:ClassId>
|
||||
<xr:ObjectId>$co2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e3687481-0a87-462c-a166-9f34594f9bba</xr:ClassId>
|
||||
<xr:ObjectId>$co3</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>9de14907-ec23-4a07-96f0-85521cb6b53b</xr:ClassId>
|
||||
<xr:ObjectId>$co4</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>51f2d5d8-ea4d-4064-8892-82951750031e</xr:ClassId>
|
||||
<xr:ObjectId>$co5</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e68182ea-4237-4383-967f-90c1e3370bc7</xr:ClassId>
|
||||
<xr:ObjectId>$co6</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>fb282519-d103-4dd3-bc12-cb271d631dfc</xr:ClassId>
|
||||
<xr:ObjectId>$co7</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles/>
|
||||
$vendorEl
|
||||
$versionEl
|
||||
<UpdateCatalogAddress/>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||
<AdditionalFullTextSearchDictionaries/>
|
||||
<CommonSettingsStorage/>
|
||||
<ReportsUserSettingsStorage/>
|
||||
<ReportsVariantsStorage/>
|
||||
<FormDataSettingsStorage/>
|
||||
<DynamicListsUserSettingsStorage/>
|
||||
<URLExternalDataStorage/>
|
||||
<Content/>
|
||||
<DefaultReportForm/>
|
||||
<DefaultReportVariantForm/>
|
||||
<DefaultReportSettingsForm/>
|
||||
<DefaultReportAppearanceTemplate/>
|
||||
<DefaultDynamicListSettingsForm/>
|
||||
<DefaultSearchForm/>
|
||||
<DefaultDataHistoryChangeHistoryForm/>
|
||||
<DefaultDataHistoryVersionDataForm/>
|
||||
<DefaultDataHistoryVersionDifferencesForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
|
||||
<RequiredMobileApplicationPermissions/>
|
||||
<UsedMobileApplicationFunctionalities>$mobileXml
|
||||
</UsedMobileApplicationFunctionalities>
|
||||
<StandaloneConfigurationRestrictionRoles/>
|
||||
<MobileApplicationURLs/>
|
||||
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
|
||||
<DefaultInterface/>$f221Captions
|
||||
<DefaultStyle/>
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
<DetailedInformation/>
|
||||
<Copyright/>
|
||||
<VendorInformationAddress/>
|
||||
<ConfigurationInformationAddress/>
|
||||
<DataLockControlMode>Managed</DataLockControlMode>
|
||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
</Properties>
|
||||
<ChildObjects>
|
||||
<Language>Русский</Language>
|
||||
</ChildObjects>
|
||||
</Configuration>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<Properties>
|
||||
<Name>Русский</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Русский</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<LanguageCode>ru</LanguageCode>
|
||||
</Properties>
|
||||
</Language>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
|
||||
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
|
||||
# via panelDef but not placed by default. Without this file the web client renders
|
||||
# section icons without labels (icon-only mode).
|
||||
$openPanelInst = [guid]::NewGuid().ToString()
|
||||
$sectionsPanelInst = [guid]::NewGuid().ToString()
|
||||
$caiXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
|
||||
<top>
|
||||
<panel id="$openPanelInst">
|
||||
<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
|
||||
</panel>
|
||||
</top>
|
||||
<left>
|
||||
<panel id="$sectionsPanelInst">
|
||||
<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
|
||||
</panel>
|
||||
</left>
|
||||
<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
|
||||
<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
|
||||
<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
|
||||
<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
|
||||
<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
|
||||
</ClientApplicationInterface>
|
||||
"@
|
||||
|
||||
# --- Create directories ---
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
$langDir = Join-Path $OutputDir "Languages"
|
||||
if (-not (Test-Path $langDir)) {
|
||||
New-Item -ItemType Directory -Path $langDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $OutputDir "Ext"
|
||||
if (-not (Test-Path $extDir)) {
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
Write-XmlFile $caiFile $caiXml $enc
|
||||
|
||||
# --- Output ---
|
||||
Write-Host "[OK] Создана конфигурация: $Name"
|
||||
Write-Host " Каталог: $OutputDir"
|
||||
Write-Host " Configuration.xml: $cfgFile"
|
||||
Write-Host " Languages: $langFile"
|
||||
Write-Host " Ext/CAI: $caiFile"
|
||||
@@ -1,340 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration."""
|
||||
import sys, os, argparse, re, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Create empty 1C configuration scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-OutputDir', dest='OutputDir', default='src')
|
||||
parser.add_argument('-Version', dest='Version', default='')
|
||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
|
||||
# Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if (args.CompatibilityMode or "").lower() == "dontuse":
|
||||
print("WARNING: CompatibilityMode 'DontUse' is not \"no restrictions\" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
output_dir = args.OutputDir
|
||||
version = args.Version
|
||||
vendor = args.Vendor
|
||||
compat = args.CompatibilityMode
|
||||
|
||||
# --- Resolve output dir ---
|
||||
if not os.path.isabs(output_dir):
|
||||
output_dir = os.path.join(os.getcwd(), output_dir)
|
||||
|
||||
# --- Check existing ---
|
||||
cfg_file = os.path.join(output_dir, "Configuration.xml")
|
||||
if os.path.exists(cfg_file):
|
||||
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Generate UUIDs ---
|
||||
uuid_cfg = new_uuid()
|
||||
uuid_lang = new_uuid()
|
||||
co = [new_uuid() for _ in range(7)]
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
is_221 = format_rank_value >= 221
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
is_218 = format_rank_value >= 218
|
||||
|
||||
mobile_funcs = [
|
||||
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
||||
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
||||
("Calendars","false"), ("PushNotifications","false"), ("LocalNotifications","false"),
|
||||
("InAppPurchases","false"), ("PersonalComputerFileExchange","false"), ("Ads","false"),
|
||||
("NumberDialing","false"), ("CallProcessing","false"), ("CallLog","false"),
|
||||
("AutoSendSMS","false"), ("ReceiveSMS","false"), ("SMSLog","false"),
|
||||
("Camera","false"), ("Microphone","false"), ("MusicLibrary","false"),
|
||||
("PictureAndVideoLibraries","false"), ("AudioPlaybackAndVibration","false"),
|
||||
("BackgroundAudioPlaybackAndVibration","false"), ("InstallPackages","false"),
|
||||
("OSBackup","true"), ("ApplicationUsageStatistics","false"),
|
||||
("BarcodeScanning","false"), ("BackgroundAudioRecording","false"),
|
||||
("AllFilesAccess","false"), ("Videoconferences","false"), ("NFC","false"),
|
||||
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
|
||||
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
|
||||
]
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if is_218:
|
||||
mobile_funcs.append(("TextToSpeech", "false"))
|
||||
|
||||
mobile_xml = ""
|
||||
for func_name, func_use in mobile_funcs:
|
||||
mobile_xml += f"\r\n\t\t\t\t<app:functionality>\r\n\t\t\t\t\t<app:functionality>{func_name}</app:functionality>\r\n\t\t\t\t\t<app:use>{func_use}</app:use>\r\n\t\t\t\t</app:functionality>"
|
||||
|
||||
# --- Synonym XML ---
|
||||
synonym_xml = ""
|
||||
if synonym:
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
class_ids = [
|
||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||
"9fcd25a0-4822-11d4-9414-008048da11f9",
|
||||
"e3687481-0a87-462c-a166-9f34594f9bba",
|
||||
"9de14907-ec23-4a07-96f0-85521cb6b53b",
|
||||
"51f2d5d8-ea4d-4064-8892-82951750031e",
|
||||
"e68182ea-4237-4383-967f-90c1e3370bc7",
|
||||
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
||||
]
|
||||
|
||||
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
|
||||
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
|
||||
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
|
||||
pal_ns = ""
|
||||
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
|
||||
if is_221:
|
||||
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
f221_aux_forms = "\r\n" + "\r\n".join(
|
||||
f"\t\t\t{t}" for t in (
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
|
||||
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
|
||||
"</MainClientApplicationWindowInterfaceVariant>"
|
||||
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
|
||||
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
|
||||
"</ClientApplicationWindowsOpenVariant>")
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
|
||||
"</Version85InterfaceMigrationMode>")
|
||||
|
||||
contained_objects = ""
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{co[i]}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles/>
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}
|
||||
\t\t\t<UpdateCatalogAddress/>
|
||||
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
\t\t\t<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||
\t\t\t<AdditionalFullTextSearchDictionaries/>
|
||||
\t\t\t<CommonSettingsStorage/>
|
||||
\t\t\t<ReportsUserSettingsStorage/>
|
||||
\t\t\t<ReportsVariantsStorage/>
|
||||
\t\t\t<FormDataSettingsStorage/>
|
||||
\t\t\t<DynamicListsUserSettingsStorage/>
|
||||
\t\t\t<URLExternalDataStorage/>
|
||||
\t\t\t<Content/>
|
||||
\t\t\t<DefaultReportForm/>
|
||||
\t\t\t<DefaultReportVariantForm/>
|
||||
\t\t\t<DefaultReportSettingsForm/>
|
||||
\t\t\t<DefaultReportAppearanceTemplate/>
|
||||
\t\t\t<DefaultDynamicListSettingsForm/>
|
||||
\t\t\t<DefaultSearchForm/>
|
||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
|
||||
\t\t\t<RequiredMobileApplicationPermissions/>
|
||||
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
||||
\t\t\t</UsedMobileApplicationFunctionalities>
|
||||
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
||||
\t\t\t<MobileApplicationURLs/>
|
||||
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
|
||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
|
||||
\t\t\t<DefaultInterface/>{f221_captions}
|
||||
\t\t\t<DefaultStyle/>
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
\t\t\t<DetailedInformation/>
|
||||
\t\t\t<Copyright/>
|
||||
\t\t\t<VendorInformationAddress/>
|
||||
\t\t\t<ConfigurationInformationAddress/>
|
||||
\t\t\t<DataLockControlMode>Managed</DataLockControlMode>
|
||||
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects>
|
||||
\t\t\t<Language>Русский</Language>
|
||||
\t\t</ChildObjects>
|
||||
\t</Configuration>
|
||||
</MetaDataObject>'''
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>Русский</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>Русский</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<LanguageCode>ru</LanguageCode>
|
||||
\t\t</Properties>
|
||||
\t</Language>
|
||||
</MetaDataObject>'''
|
||||
|
||||
# --- Ext/ClientApplicationInterface.xml (default ERP-style panel layout) ---
|
||||
# Open panel on top, Sections panel on left; Functions/Favorites/History declared
|
||||
# via panelDef but not placed by default. Without this file the web client renders
|
||||
# section icons without labels (icon-only mode).
|
||||
open_panel_inst = new_uuid()
|
||||
sections_panel_inst = new_uuid()
|
||||
cai_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
|
||||
\t<top>
|
||||
\t\t<panel id="{open_panel_inst}">
|
||||
\t\t\t<uuid>cbab57f2-a0f3-4f0a-89ea-4cb19570ab75</uuid>
|
||||
\t\t</panel>
|
||||
\t</top>
|
||||
\t<left>
|
||||
\t\t<panel id="{sections_panel_inst}">
|
||||
\t\t\t<uuid>b553047f-c9aa-4157-978d-448ecad24248</uuid>
|
||||
\t\t</panel>
|
||||
\t</left>
|
||||
\t<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
|
||||
\t<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
|
||||
\t<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
|
||||
\t<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
|
||||
\t<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
|
||||
</ClientApplicationInterface>'''
|
||||
|
||||
# --- Create directories ---
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
lang_dir = os.path.join(output_dir, "Languages")
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(output_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||
write_xml_file(cai_file, cai_xml)
|
||||
|
||||
print(f"[OK] Создана конфигурация: {name}")
|
||||
print(f" Каталог: {output_dir}")
|
||||
print(f" Configuration.xml: {cfg_file}")
|
||||
print(f" Languages: {lang_file}")
|
||||
print(f" Ext/CAI: {cai_file}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,147 +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) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Дословно — включая комментарии, регистр и пробелы внутри строки (`Х = Х + 1` ≠ `Х=Х+1`); свободны только отступ и пустые строки. Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
|
||||
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
|
||||
|
||||
## Актуализация
|
||||
|
||||
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
|
||||
|
||||
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
|
||||
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
|
||||
|
||||
Статусы в выводе:
|
||||
|
||||
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
|
||||
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
|
||||
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
|
||||
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
|
||||
|
||||
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
|
||||
|
||||
`-Check` смотрит исходники. Вердикт платформы — уже после загрузки в базу: `/db-cfe-admin check`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/cfe-patch-method/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: cfe-validate
|
||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /cfe-validate — валидация расширения конфигурации (CFE)
|
||||
|
||||
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
|
||||
|
||||
Проверяются исходники. Применимость — уже после загрузки в базу: `/db-cfe-admin check`.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обяз. | Умолч. | Описание |
|
||||
|---------------|:-----:|---------|-------------------------------------------------|
|
||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||
| OutFile | нет | — | Записать результат в файл |
|
||||
|
||||
### ConfigPath
|
||||
|
||||
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||
|
||||
Если пользователь не указал путь — определи сам:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||
3. Возьми её поле `configSrc`
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
|
||||
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||
powershell.exe -NoProfile -File ".agents/skills/cfe-validate/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||
```
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
name: db-cfe-admin
|
||||
description: Управление расширениями конфигурации в информационной базе 1С. Используй когда нужно узнать какие расширения подключены к базе, выполнить проверку применимости или синтаксическую проверку, изменить безопасный режим или активность, удалить расширение из базы
|
||||
argument-hint: <list|check|set-properties|delete> [database] [-Name <Имя>]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-cfe-admin — Управление расширениями конфигурации
|
||||
|
||||
Расширения **на стороне базы**: состав и свойства подключения, проверки, удаление.
|
||||
Про исходники расширения — другие навыки, см. «Смежное».
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-cfe-admin list [database]
|
||||
/db-cfe-admin check [database] [-Name Расш1]
|
||||
/db-cfe-admin set-properties [database] -Name Расш1 -SafeMode off
|
||||
/db-cfe-admin delete [database] -Name Расш1
|
||||
```
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-cfe-admin/scripts/db-cfe-admin.ps1" -Command <команда> <параметры>
|
||||
```
|
||||
|
||||
### Общие параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-Command <команда>` | да | `list` / `check` / `set-properties` / `delete` |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Пользователь базы |
|
||||
| `-Password <пароль>` | нет | Пароль пользователя |
|
||||
| `-Name <имя>` | усл. | Расширение. Обязателен для `set-properties`; в `delete` — вместо `-All`. Без него `list` и `check` работают по всем расширениям |
|
||||
| `-All` | усл. | Только для `delete`: удалить все расширения. Вместо `-Name`, а не вместе с ним |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
### Параметры `check`
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-Checks <список>` | `apply` — применимость расширения, `modules` — синтаксическая проверка, `config` — проверки конфигурации (целостность, ссылки, неиспользуемые процедуры и обработчики). Через запятую, по умолчанию `apply,modules` |
|
||||
| `-Context <список>` | Контексты синтаксической проверки: `ThinClient`, `WebClient`, `Server`, `ExternalConnection`, `ThickClientManagedApplication`, `ThickClientOrdinaryApplication`, `MobileClient` и др. Через запятую, по умолчанию `ThinClient,Server` |
|
||||
|
||||
Применимость и синтаксис проверяют разное и друг друга не заменяют. Проверяется только
|
||||
расширение: ошибки самой конфигурации сюда не попадают.
|
||||
|
||||
### Свойства `set-properties`
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-SafeMode <on/off>` | Безопасный режим |
|
||||
| `-Active <on/off>` | Активность расширения |
|
||||
| `-UnsafeActionProtection <on/off>` | Защита от опасных действий |
|
||||
| `-UsedInDistributedInfobase <on/off>` | Использование в распределённой ИБ |
|
||||
| `-Scope <область>` | `infobase` / `data-separation` |
|
||||
| `-SecurityProfile <имя>` | Профиль безопасности |
|
||||
|
||||
Передавай только то, что меняешь: не указанное свойство остаётся как было. Расширение, впервые
|
||||
попавшее в базу загрузкой, создаётся с включённым безопасным режимом, а в нём расширение модуля не
|
||||
применяется.
|
||||
|
||||
Свойствами управляет `ibcmd` — он есть не в каждой установке платформы и работает с файловой базой;
|
||||
остальные команды работают всегда.
|
||||
|
||||
## Смежное
|
||||
|
||||
| Задача | Навык |
|
||||
|--------|-------|
|
||||
| Создать расширение, заимствовать объекты, перехватить метод | `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`, `/cfe-validate` |
|
||||
| Загрузить исходники расширения в базу | `/db-load-xml -Extension` (из коммита Git — `/db-load-git`) |
|
||||
| Загрузить готовый `.cfe` | `/db-load-cf -Extension` |
|
||||
| Выгрузить расширение из базы | `/db-dump-xml -Extension`, `/db-dump-cf -Extension` |
|
||||
| Обновить конфигурацию базы после загрузки | `/db-update -Extension` |
|
||||
| Проверить дрейф контролируемых методов по исходникам | `/cfe-patch-method -Check` |
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Что подключено к базе
|
||||
... -Command list -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
# Проверить расширение
|
||||
... -Command check -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
|
||||
|
||||
# Синтаксическая проверка в контексте веб-клиента
|
||||
... -Command check -InfoBasePath "C:\Bases\MyDB" -Checks modules -Context WebClient,Server
|
||||
|
||||
# Снять безопасный режим
|
||||
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -SafeMode off
|
||||
|
||||
# Отключить, не удаляя
|
||||
... -Command set-properties -InfoBasePath "C:\Bases\MyDB" -Name "Расш1" -Active off
|
||||
|
||||
# Убрать расширение из базы
|
||||
... -Command delete -InfoBasePath "C:\Bases\MyDB" -Name "Расш1"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,495 +0,0 @@
|
||||
# db-create v1.14 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Создание информационной базы 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Создаёт новую информационную базу 1С (файловую или серверную).
|
||||
Поддерживает создание из шаблона и добавление в список баз.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UseTemplate
|
||||
Путь к файлу шаблона (.cf или .dt)
|
||||
|
||||
.PARAMETER AddToList
|
||||
Добавить в список баз 1С
|
||||
|
||||
.PARAMETER ListName
|
||||
Имя базы в списке
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UseTemplate,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AddToList,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-FileIbCreated {
|
||||
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$IbPath)
|
||||
$f = Join-Path $IbPath "1Cv8.1CD"
|
||||
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/UseTemplate' = '-UseTemplate'; '/AddToList' = '-AddToList'; '--db-path' = '-InfoBasePath'; '--load' = '-UseTemplate'; '--restore' = '-UseTemplate' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate template ---
|
||||
if ($UseTemplate -and -not (Test-Path $UseTemplate)) {
|
||||
Write-Host "Error: template file not found: $UseTemplate" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_create_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "create", "--db-path=$InfoBasePath", "--create-database")
|
||||
if ($UseTemplate) {
|
||||
if ([System.IO.Path]::GetExtension($UseTemplate) -ieq ".dt") {
|
||||
$arguments += "--restore=$UseTemplate"
|
||||
} else {
|
||||
$arguments += "--load=$UseTemplate", "--apply"
|
||||
}
|
||||
}
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("CREATEINFOBASE")
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting
|
||||
# the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch.
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "File=`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
# --- Template ---
|
||||
if ($UseTemplate) {
|
||||
$arguments += "/UseTemplate", "`"$UseTemplate`""
|
||||
}
|
||||
|
||||
# --- Add to list ---
|
||||
if ($AddToList) {
|
||||
if ($ListName) {
|
||||
$arguments += "/AddToList", "`"$ListName`""
|
||||
} else {
|
||||
$arguments += "/AddToList"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "create_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
}
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,557 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.14 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def file_ib_created(ib_path):
|
||||
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
f = os.path.join(ib_path, "1Cv8.1CD")
|
||||
return os.path.isfile(f) and os.path.getsize(f) > 0
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create 1C information base",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UseTemplate", default="")
|
||||
parser.add_argument("-AddToList", action="store_true")
|
||||
parser.add_argument("-ListName", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/UseTemplate": "-UseTemplate",
|
||||
"/AddToList": "-AddToList",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--load": "-UseTemplate",
|
||||
"--restore": "-UseTemplate",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate template ---
|
||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
||||
print(f"Error: template file not found: {args.UseTemplate}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
|
||||
if args.UseTemplate:
|
||||
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
|
||||
arguments.append(f"--restore={args.UseTemplate}")
|
||||
else:
|
||||
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})")
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["CREATEINFOBASE"]
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them.
|
||||
# Quoting the whole token instead breaks a path with spaces — on both OSes.
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||
else:
|
||||
arguments.append(f'File="{args.InfoBasePath}"')
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", f'"{args.ListName}"'])
|
||||
else:
|
||||
arguments.append("/AddToList")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
is_server = bool(args.InfoBaseServer and args.InfoBaseRef)
|
||||
ib_missing = exit_code == 0 and not is_server and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
|
||||
if exit_code == 0:
|
||||
if is_server:
|
||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
||||
else:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,515 +0,0 @@
|
||||
# db-dump-cf v1.16 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в CF-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает конфигурацию информационной базы в бинарный CF-файл.
|
||||
Поддерживает выгрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному CF-файлу
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config save does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "save", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$OutputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpCfg", "`"$OutputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,571 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.16 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
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)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: db-dump-dt
|
||||
description: Выгрузка информационной базы 1С в DT-файл (вся база — конфигурация + данные). Используй когда нужно выгрузить информационную базу, выгрузить архив базы, сделать бэкап, выгрузить dt
|
||||
argument-hint: "[database] [output.dt]"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-dump-dt — Выгрузка информационной базы в DT-файл
|
||||
|
||||
Выгружает информационную базу целиком (конфигурация **+ данные**) в DT-файл — полный снимок ИБ.
|
||||
|
||||
> В отличие от `/db-dump-cf` (только конфигурация), `.dt` содержит **всю базу**: данные,
|
||||
> настройки, пользователей. Это бэкап/точка отката, а не выгрузка метаданных.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-dump-dt [database] [output.dt]
|
||||
/db-dump-dt dev backup.dt
|
||||
/db-dump-dt — база по умолчанию, имя файла по базе и дате
|
||||
```
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Выгрузка ИБ (файловая база)
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-dump-dt/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-load-dt` — загрузка ИБ из DT (обратная операция)
|
||||
- `/db-dump-cf` — выгрузка только конфигурации (без данных)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -1,489 +0,0 @@
|
||||
# db-dump-dt v1.15 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка информационной базы 1С в DT-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному DT-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$OutputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpIB", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.15 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C information base to DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,713 +0,0 @@
|
||||
# db-dump-xml v1.21 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в XML-файлы
|
||||
|
||||
.DESCRIPTION
|
||||
Выполняет выгрузку конфигурации 1С в файлы в четырёх режимах:
|
||||
- Full: полная выгрузка всей конфигурации
|
||||
- Changes: инкрементальная выгрузка изменённых объектов
|
||||
- Partial: выгрузка конкретных объектов из списка
|
||||
- UpdateInfo: обновление только ConfigDumpInfo.xml
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог для выгрузки конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим выгрузки: Full, Changes, Partial, UpdateInfo (по умолчанию Changes)
|
||||
|
||||
.PARAMETER Objects
|
||||
Имена объектов метаданных через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для выгрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
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)]
|
||||
# Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
|
||||
# того как станет видно, перечислены ли объекты.
|
||||
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
|
||||
[string]$Mode = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Objects,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ObjectsFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||
if ($ObjectsFile) {
|
||||
if (-not (Test-Path $ObjectsFile)) {
|
||||
Write-Host "Error: -ObjectsFile not found: $ObjectsFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fromFile = @([System.IO.File]::ReadAllLines($ObjectsFile, [System.Text.Encoding]::UTF8) |
|
||||
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
|
||||
$Objects = (@(@($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $fromFile) -join ',')
|
||||
}
|
||||
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||
if ($Objects) {
|
||||
if ($Mode -eq "UpdateInfo") {
|
||||
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||
Write-Host "Error: -Mode UpdateInfo does not take an object list — it only refreshes ConfigDumpInfo.xml" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Mode -eq "Full" -or $Mode -eq "Changes") {
|
||||
Write-Host "[note] перечислены объекты — выгружаются только они; -Mode $Mode не применён" -ForegroundColor Yellow
|
||||
}
|
||||
$Mode = "Partial"
|
||||
}
|
||||
if (-not $Mode) { $Mode = "Changes" }
|
||||
if ($Mode -eq "Partial" -and -not $Objects) {
|
||||
Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null
|
||||
Write-Host "Created output directory: $ConfigDir"
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "export", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "UpdateInfo") {
|
||||
Write-Host "Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8" -ForegroundColor Red
|
||||
exit 1
|
||||
} elseif ($Mode -eq "Partial") {
|
||||
$objList = @($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$arguments = @("infobase", "config", "export", "objects") + $objList
|
||||
$arguments += "--out=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "export", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$arguments += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
switch ($Mode) {
|
||||
"Full" {
|
||||
Write-Host "Executing full configuration dump..."
|
||||
}
|
||||
"Changes" {
|
||||
Write-Host "Executing incremental configuration dump..."
|
||||
$arguments += "-update"
|
||||
$arguments += "-force"
|
||||
}
|
||||
"Partial" {
|
||||
Write-Host "Executing partial configuration dump..."
|
||||
$objectList = $Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
|
||||
$listFile = Join-Path $tempDir "dump_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $objectList, $utf8Bom)
|
||||
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
Write-Host "Objects to dump: $($objectList.Count)"
|
||||
foreach ($obj in $objectList) { Write-Host " $obj" }
|
||||
}
|
||||
"UpdateInfo" {
|
||||
Write-Host "Updating ConfigDumpInfo.xml..."
|
||||
$arguments += "-configDumpInfoOnly"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully" -ForegroundColor Green
|
||||
Write-Host "Configuration dumped to: $ConfigDir"
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,745 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.21 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C configuration to XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="",
|
||||
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("-ObjectsFile", default="")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||
if args.ObjectsFile:
|
||||
if not os.path.exists(args.ObjectsFile):
|
||||
print("Error: -ObjectsFile not found: %s" % args.ObjectsFile)
|
||||
sys.exit(1)
|
||||
with open(args.ObjectsFile, encoding="utf-8-sig") as f:
|
||||
from_file = [s.strip() for s in f.read().splitlines()
|
||||
if s.strip() and not s.strip().startswith("#")]
|
||||
inline = [s.strip() for s in args.Objects.split(",") if s.strip()]
|
||||
args.Objects = ",".join(inline + from_file)
|
||||
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||
if args.Objects:
|
||||
if args.Mode == "UpdateInfo":
|
||||
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||
print("Error: -Mode UpdateInfo does not take an object list — it only refreshes "
|
||||
"ConfigDumpInfo.xml")
|
||||
sys.exit(1)
|
||||
if args.Mode in ("Full", "Changes"):
|
||||
print("[note] перечислены объекты — выгружаются только они; -Mode %s не применён"
|
||||
% args.Mode)
|
||||
args.Mode = "Partial"
|
||||
if not args.Mode:
|
||||
args.Mode = "Changes"
|
||||
if args.Mode == "Partial" and not args.Objects:
|
||||
print("Error: -Objects or -ObjectsFile required for Partial mode")
|
||||
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)")
|
||||
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")
|
||||
sys.exit(1)
|
||||
elif args.Mode == "Partial":
|
||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||
arguments = ["infobase", "config", "export", "objects"] + obj_list
|
||||
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
arguments.extend(repository_args(repo))
|
||||
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration dump...")
|
||||
elif args.Mode == "Changes":
|
||||
print("Executing incremental configuration dump...")
|
||||
arguments.append("-update")
|
||||
arguments.append("-force")
|
||||
elif args.Mode == "Partial":
|
||||
print("Executing partial configuration dump...")
|
||||
object_list = [obj.strip() for obj in args.Objects.split(",") if obj.strip()]
|
||||
|
||||
list_file = os.path.join(temp_dir, "dump_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(object_list))
|
||||
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
print(f"Objects to dump: {len(object_list)}")
|
||||
for obj in object_list:
|
||||
print(f" {obj}")
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Updating ConfigDumpInfo.xml...")
|
||||
arguments.append("-configDumpInfoOnly")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print("Dump completed successfully")
|
||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,620 +0,0 @@
|
||||
# db-load-cf v1.17 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из CF-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию из бинарного CF-файла в информационную базу.
|
||||
Поддерживает загрузку расширений.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к CF-файлу для загрузки
|
||||
|
||||
.PARAMETER Extension
|
||||
Загрузить как расширение
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но расширение при этом неприменимо.
|
||||
[switch]$StrictLog,
|
||||
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$NoApplyCheck,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# Постусловие применимости расширения: платформа отчитывается успехом и о расширении, которое
|
||||
# не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал регистрации.
|
||||
#
|
||||
# Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной командной
|
||||
# строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча отбрасывает —
|
||||
# проверено на 8.3.24, /LoadConfigFromFiles вместе с /CheckCanApplyConfigurationExtensions
|
||||
# завершились кодом 0 с пустым логом, и загрузка не состоялась.
|
||||
#
|
||||
# Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний исполняемый файл.
|
||||
function Invoke-ApplyCheck {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$exeDir = Split-Path $Exe -Parent
|
||||
$exeLeaf = Split-Path $Exe -Leaf
|
||||
$v8 = if ($exeLeaf -match '^ibcmd') { Join-Path $exeDir ("1cv8" + [System.IO.Path]::GetExtension($Exe)) } else { $Exe }
|
||||
if (-not (Test-Path $v8)) { return @{ Skipped = $true; Reason = "1cv8 not found at $v8"; ExitCode = 0; Lines = @() } }
|
||||
$dir = Join-Path $env:TEMP "apply_check_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
try {
|
||||
$a = @("DESIGNER") + $ConnArgs + @("/CheckCanApplyConfigurationExtensions")
|
||||
if ($Extension) { $a += "-Extension", "`"$Extension`"" }
|
||||
$outFile = Join-Path $dir "check_log.txt"
|
||||
$a += "/Out", "`"$outFile`"", "/DisableStartupDialogs"
|
||||
$a += $ExtraArgs
|
||||
$res = Invoke-PlatformProcess $v8 $a -PreQuoted
|
||||
$lines = @()
|
||||
if (Test-Path $outFile) {
|
||||
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $lines = @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) }
|
||||
}
|
||||
return @{ Skipped = $false; Reason = ''; ExitCode = $res.ExitCode; Lines = $lines }
|
||||
} finally {
|
||||
if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Проверить и напечатать. $true, если платформа расширение не применит — вызывающий решает,
|
||||
# поднимать ли код возврата (строгий режим).
|
||||
function Invoke-ApplyCheckReport {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$ac = Invoke-ApplyCheck $Exe $ConnArgs $Extension $ExtraArgs
|
||||
if ($ac.Skipped) {
|
||||
Write-Host "[note] applicability check skipped: $($ac.Reason)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
if ($ac.ExitCode -ne 0 -or $ac.Lines.Count -gt 0) {
|
||||
Write-Host "[warning] the extension is loaded, but the platform will not apply it:" -ForegroundColor Yellow
|
||||
foreach ($l in $ac.Lines) { Write-Host " $l" -ForegroundColor Yellow }
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Проверять ли применимость: -NoApplyCheck сильнее настройки проекта.
|
||||
function Get-ApplyCheckEnabled {
|
||||
param([switch]$Disabled)
|
||||
if ($Disabled) { return $false }
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if ($pf) {
|
||||
try {
|
||||
$proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($null -ne $proj.extensionApplyCheck) { return [bool]$proj.extensionApplyCheck }
|
||||
} catch {}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config load does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "load", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$InputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
$acConn = @("/F", "`"$InfoBasePath`"")
|
||||
if ($UserName) { $acConn += "/N`"$UserName`"" }
|
||||
if ($Password) { $acConn += "/P`"$Password`"" }
|
||||
if ((Invoke-ApplyCheckReport $V8Path $acConn $Extension @()) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт запуск проверки применимости.
|
||||
$connArgs = @()
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$connArgs += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$connArgs += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $connArgs += "/N`"$UserName`"" }
|
||||
if ($Password) { $connArgs += "/P`"$Password`"" }
|
||||
|
||||
$arguments = @("DESIGNER") + $connArgs
|
||||
|
||||
$arguments += "/LoadCfg", "`"$InputFile`""
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
if ((Invoke-ApplyCheckReport $V8Path $connArgs $Extension $extraArgs) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,689 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.17 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def _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 run_apply_check(exe, conn_args, extension, extra_args):
|
||||
"""Постусловие применимости расширения: платформа отчитывается успехом и о расширении,
|
||||
которое не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал
|
||||
регистрации.
|
||||
|
||||
Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной
|
||||
командной строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча
|
||||
отбрасывает — проверено на 8.3.24, /LoadConfigFromFiles вместе с
|
||||
/CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка не
|
||||
состоялась.
|
||||
|
||||
Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний файл.
|
||||
"""
|
||||
exe_dir = os.path.dirname(exe)
|
||||
leaf = os.path.basename(exe)
|
||||
if leaf.lower().startswith("ibcmd"):
|
||||
v8 = os.path.join(exe_dir, "1cv8" + os.path.splitext(leaf)[1])
|
||||
else:
|
||||
v8 = exe
|
||||
if not os.path.isfile(v8):
|
||||
return {"skipped": True, "reason": f"1cv8 not found at {v8}", "exit": 0, "lines": []}
|
||||
temp_dir = tempfile.mkdtemp(prefix="apply_check_")
|
||||
try:
|
||||
a = ["DESIGNER"] + list(conn_args) + ["/CheckCanApplyConfigurationExtensions"]
|
||||
if extension:
|
||||
a += ["-Extension", f'"{extension}"']
|
||||
out_file = os.path.join(temp_dir, "check_log.txt")
|
||||
a += ["/Out", f'"{out_file}"', "/DisableStartupDialogs"]
|
||||
a += list(extra_args)
|
||||
r = run_v8(v8, a)
|
||||
lines = []
|
||||
if os.path.isfile(out_file):
|
||||
with open(out_file, encoding="utf-8-sig", errors="replace") as f:
|
||||
lines = [x.strip() for x in f.read().splitlines() if x.strip()]
|
||||
return {"skipped": False, "reason": "", "exit": r.returncode, "lines": lines}
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def apply_check_report(exe, conn_args, extension, extra_args):
|
||||
"""Проверить и напечатать. True, если платформа расширение не применит — вызывающий решает,
|
||||
поднимать ли код возврата (строгий режим)."""
|
||||
ac = run_apply_check(exe, conn_args, extension, extra_args)
|
||||
if ac["skipped"]:
|
||||
print(f"[note] applicability check skipped: {ac['reason']}")
|
||||
return False
|
||||
if ac["exit"] != 0 or ac["lines"]:
|
||||
print("[warning] the extension is loaded, but the platform will not apply it:")
|
||||
for line in ac["lines"]:
|
||||
print(f" {line}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_check_enabled(disabled):
|
||||
"""Проверять ли применимость: -NoApplyCheck сильнее настройки проекта."""
|
||||
if disabled:
|
||||
return False
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if pf:
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
if proj.get("extensionApplyCheck") is not None:
|
||||
return bool(proj.get("extensionApplyCheck"))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from CF file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но расширение при этом неприменимо.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
parser.add_argument("-NoApplyCheck", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
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)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
exit_code = result.returncode
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
ac_conn = ["/F", f'"{args.InfoBasePath}"']
|
||||
if args.UserName:
|
||||
ac_conn.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
ac_conn.append(f'/P"{args.Password}"')
|
||||
if apply_check_report(v8path, ac_conn, args.Extension, []) and args.StrictLog:
|
||||
exit_code = 1
|
||||
|
||||
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 ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт проверка применимости.
|
||||
conn_args = []
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
conn_args.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
conn_args.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
conn_args.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
conn_args.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments = ["DESIGNER"] + conn_args
|
||||
|
||||
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
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
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
if apply_check_report(v8path, conn_args, args.Extension, extra_args) and args.StrictLog:
|
||||
exit_code = 1
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: db-load-dt
|
||||
description: Загрузка информационной базы 1С из DT-файла — полная перезапись базы (конфигурация + данные). Используй когда нужно загрузить архив информационной базы, восстановить базу, загрузить dt
|
||||
disable-model-invocation: true
|
||||
argument-hint: <input.dt> [database]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-load-dt — Загрузка информационной базы из DT-файла
|
||||
|
||||
Восстанавливает информационную базу целиком (конфигурация **+ данные**) из DT-файла.
|
||||
|
||||
> ⚠️ **Необратимая операция.** Загрузка `.dt` **полностью перезаписывает базу** — и
|
||||
> конфигурацию, и все данные. Текущее содержимое базы будет потеряно. После загрузки
|
||||
> `/db-update` **не нужен** — конфигурация БД уже синхронна внутри снимка.
|
||||
|
||||
## Когда НЕ использовать
|
||||
|
||||
- Нужно создать **новую** базу из `.dt` → используй `/db-create` (из DT-шаблона), а не загрузку
|
||||
в существующую.
|
||||
- Нужно обновить только конфигурацию (без данных) → `/db-load-cf` или `/db-load-xml`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-load-dt <input.dt> [database]
|
||||
/db-load-dt backup.dt dev
|
||||
```
|
||||
|
||||
## Порядок действий перед загрузкой
|
||||
|
||||
1. Предложи пользователю сначала сделать `/db-dump-dt` текущего состояния базы — это точка
|
||||
отката (восстановиться будет нечем, если не сохранить).
|
||||
2. Запроси **явное подтверждение**: вся база (данные + конфигурация) будет перезаписана.
|
||||
3. Только после подтверждения выполняй загрузку.
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## После выполнения
|
||||
|
||||
Если база занята (активные сеансы), загрузка не выполнится — для серверной базы можно
|
||||
передать `-UnlockCode`; иначе освободи базу и повтори.
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Файловая база
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база с ускорением загрузки
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-load-dt/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-dump-dt` — выгрузка ИБ в DT (обратная операция, точка отката перед загрузкой)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -1,506 +0,0 @@
|
||||
# db-load-dt v1.16 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка информационной базы 1С из DT-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
|
||||
ВНИМАНИЕ: операция полностью перезаписывает базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к DT-файлу для загрузки
|
||||
|
||||
.PARAMETER JobsCount
|
||||
Количество фоновых заданий для загрузки (0 = по числу процессоров)
|
||||
|
||||
.PARAMETER UnlockCode
|
||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$JobsCount = 0,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UnlockCode,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
|
||||
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$InputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
|
||||
|
||||
$arguments += "/RestoreIB", "`"$InputFile`""
|
||||
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.16 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C information base from DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-JobsCount", type=int, default=0)
|
||||
parser.add_argument("-UnlockCode", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
||||
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
||||
arguments.append("--create-database")
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
if args.UnlockCode:
|
||||
arguments.append(f'/UC"{args.UnlockCode}"')
|
||||
|
||||
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
|
||||
if args.JobsCount > 0:
|
||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,967 +0,0 @@
|
||||
# db-load-git v1.26 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка изменений из Git в базу 1С
|
||||
|
||||
.DESCRIPTION
|
||||
Определяет изменённые файлы конфигурации по данным Git и выполняет
|
||||
частичную загрузку в информационную базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-выгрузки конфигурации (git-репозиторий)
|
||||
|
||||
.PARAMETER Source
|
||||
Источник изменений: All, Staged, Unstaged, Commit (по умолчанию All)
|
||||
|
||||
.PARAMETER CommitRange
|
||||
Диапазон коммитов (для Source=Commit), напр. HEAD~3..HEAD
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER DryRun
|
||||
Только показать что будет загружено (без загрузки)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source Commit -CommitRange "HEAD~3..HEAD"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("All", "Staged", "Unstaged", "Commit")]
|
||||
[string]$Source = "All",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CommitRange,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$DryRun,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$NoApplyCheck,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
function Write-RepositoryHints {
|
||||
param([string]$LogText)
|
||||
if (-not $LogText) { return }
|
||||
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||
}
|
||||
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||
$obj = $m.Groups[1].Value
|
||||
if ($obj -eq 'Configuration') {
|
||||
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
||||
function Get-ObjectXmlFromSubFile {
|
||||
param([string]$RelativePath)
|
||||
|
||||
$parts = $RelativePath -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
return "$($parts[0])/$($parts[1]).xml"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
if (-not $DryRun) {
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
$engine = "1cv8"
|
||||
if (-not $DryRun) {
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
# Постусловие применимости расширения: платформа отчитывается успехом и о расширении, которое
|
||||
# не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал регистрации.
|
||||
#
|
||||
# Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной командной
|
||||
# строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча отбрасывает —
|
||||
# проверено на 8.3.24, /LoadConfigFromFiles вместе с /CheckCanApplyConfigurationExtensions
|
||||
# завершились кодом 0 с пустым логом, и загрузка не состоялась.
|
||||
#
|
||||
# Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний исполняемый файл.
|
||||
function Invoke-ApplyCheck {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$exeDir = Split-Path $Exe -Parent
|
||||
$exeLeaf = Split-Path $Exe -Leaf
|
||||
$v8 = if ($exeLeaf -match '^ibcmd') { Join-Path $exeDir ("1cv8" + [System.IO.Path]::GetExtension($Exe)) } else { $Exe }
|
||||
if (-not (Test-Path $v8)) { return @{ Skipped = $true; Reason = "1cv8 not found at $v8"; ExitCode = 0; Lines = @() } }
|
||||
$dir = Join-Path $env:TEMP "apply_check_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
try {
|
||||
$a = @("DESIGNER") + $ConnArgs + @("/CheckCanApplyConfigurationExtensions")
|
||||
if ($Extension) { $a += "-Extension", "`"$Extension`"" }
|
||||
$outFile = Join-Path $dir "check_log.txt"
|
||||
$a += "/Out", "`"$outFile`"", "/DisableStartupDialogs"
|
||||
$a += $ExtraArgs
|
||||
$res = Invoke-PlatformProcess $v8 $a -PreQuoted
|
||||
$lines = @()
|
||||
if (Test-Path $outFile) {
|
||||
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $lines = @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) }
|
||||
}
|
||||
return @{ Skipped = $false; Reason = ''; ExitCode = $res.ExitCode; Lines = $lines }
|
||||
} finally {
|
||||
if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Проверить и напечатать. $true, если платформа расширение не применит — вызывающий решает,
|
||||
# поднимать ли код возврата (строгий режим).
|
||||
function Invoke-ApplyCheckReport {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$ac = Invoke-ApplyCheck $Exe $ConnArgs $Extension $ExtraArgs
|
||||
if ($ac.Skipped) {
|
||||
Write-Host "[note] applicability check skipped: $($ac.Reason)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
if ($ac.ExitCode -ne 0 -or $ac.Lines.Count -gt 0) {
|
||||
Write-Host "[warning] the extension is loaded, but the platform will not apply it:" -ForegroundColor Yellow
|
||||
foreach ($l in $ac.Lines) { Write-Host " $l" -ForegroundColor Yellow }
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Проверять ли применимость: -NoApplyCheck сильнее настройки проекта.
|
||||
function Get-ApplyCheckEnabled {
|
||||
param([switch]$Disabled)
|
||||
if ($Disabled) { return $false }
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if ($pf) {
|
||||
try {
|
||||
$proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($null -ne $proj.extensionApplyCheck) { return [bool]$proj.extensionApplyCheck }
|
||||
} catch {}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if ($Source -eq "Commit" -and -not $CommitRange) {
|
||||
Write-Host "Error: -CommitRange required for Source=Commit" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Check git ---
|
||||
try {
|
||||
$null = git --version 2>&1
|
||||
} catch {
|
||||
Write-Host "Error: git not found in PATH" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
# Все git-вызовы для сбора путей идут через один хелпер с -c core.quotePath=false,
|
||||
# иначе кириллические пути возвращаются в octal-виде и не распознаются (зеркало run_git в .py).
|
||||
function Invoke-GitLines {
|
||||
param([string[]]$GitArgs)
|
||||
$out = git -c core.quotePath=false @GitArgs 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { return $out }
|
||||
return @()
|
||||
}
|
||||
|
||||
$changedFiles = @()
|
||||
$ConfigDir = (Resolve-Path $ConfigDir).Path.TrimEnd('\')
|
||||
$configDirNormalized = $ConfigDir.Replace('\', '/')
|
||||
|
||||
Push-Location $ConfigDir
|
||||
try {
|
||||
switch ($Source) {
|
||||
"Staged" {
|
||||
Write-Host "Getting staged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
}
|
||||
"Unstaged" {
|
||||
Write-Host "Getting unstaged changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
"Commit" {
|
||||
Write-Host "Getting changes from $CommitRange..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative', $CommitRange)
|
||||
}
|
||||
"All" {
|
||||
Write-Host "Getting all uncommitted changes..."
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$changedFiles = $changedFiles | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||
|
||||
if ($changedFiles.Count -eq 0) {
|
||||
Write-Host "No changes found"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Git changes detected: $($changedFiles.Count) files"
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
$configFiles = @()
|
||||
$supportSkipped = @()
|
||||
|
||||
foreach ($file in $changedFiles) {
|
||||
$file = $file.Trim().Replace('\', '/')
|
||||
if ([string]::IsNullOrWhiteSpace($file)) { continue }
|
||||
|
||||
# Skip service files (not partially loadable). Support-state files are tracked
|
||||
# to warn the user: support changes apply only via a full load.
|
||||
if ($file -match 'ParentConfigurations\.bin$') { $supportSkipped += $file; continue }
|
||||
if ($file -eq "ConfigDumpInfo.xml" -or $file -match '(^|/)ConfigDumpInfo\.xml$') { continue }
|
||||
|
||||
$fullPath = Join-Path $ConfigDir $file
|
||||
|
||||
if ($file -match '\.xml$') {
|
||||
# XML file — add directly if exists
|
||||
if (Test-Path $fullPath) {
|
||||
if ($configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Non-XML (BSL, HTML, etc.) — map to parent object XML + include all Ext/ files
|
||||
$objectXml = Get-ObjectXmlFromSubFile -RelativePath $file
|
||||
if ($objectXml) {
|
||||
$fullXmlPath = Join-Path $ConfigDir $objectXml
|
||||
if (Test-Path $fullXmlPath) {
|
||||
if ($configFiles -notcontains $objectXml) {
|
||||
$configFiles += $objectXml
|
||||
}
|
||||
if ((Test-Path $fullPath) -and $configFiles -notcontains $file) {
|
||||
$configFiles += $file
|
||||
}
|
||||
|
||||
# Add all files from Ext/ directory of the object
|
||||
$parts = $file -split '[\\/]'
|
||||
if ($parts.Count -ge 2) {
|
||||
$extDir = Join-Path (Join-Path $ConfigDir $parts[0]) "$($parts[1])\Ext"
|
||||
if (Test-Path $extDir) {
|
||||
Get-ChildItem -Path $extDir -Recurse -File | ForEach-Object {
|
||||
$extRelPath = $_.FullName.Replace("$ConfigDir\", '').Replace('\', '/')
|
||||
if ($configFiles -notcontains $extRelPath) {
|
||||
$configFiles += $extRelPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportSkipped.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportSkipped) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($configFiles.Count -eq 0) {
|
||||
Write-Host "No configuration files found in changes"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host "Files for loading: $($configFiles.Count)"
|
||||
foreach ($f in $configFiles) { Write-Host " $f" }
|
||||
|
||||
# --- DryRun: stop here ---
|
||||
if ($DryRun) {
|
||||
Write-Host ""
|
||||
Write-Host "DryRun mode - no changes applied"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_git_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $configFiles
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
$acConn = @("/F", "`"$InfoBasePath`"")
|
||||
if ($UserName) { $acConn += "/N`"$UserName`"" }
|
||||
if ($Password) { $acConn += "/P`"$Password`"" }
|
||||
if ((Invoke-ApplyCheckReport $V8Path $acConn $Extension @()) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
$listFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($listFile, $configFiles, $utf8Bom)
|
||||
|
||||
# --- Build arguments ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт запуск проверки применимости.
|
||||
$connArgs = @()
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$connArgs += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$connArgs += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $connArgs += "/N`"$UserName`"" }
|
||||
if ($Password) { $connArgs += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$connArgs += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments = @("DESIGNER") + $connArgs
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
$arguments += "-Format", $Format
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host ""
|
||||
Write-Host "Executing partial configuration load..."
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
Write-Host ""
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
Write-RepositoryHints $logContent
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
if ((Invoke-ApplyCheckReport $V8Path $connArgs $Extension $extraArgs) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,920 +0,0 @@
|
||||
# db-load-xml v1.28 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из XML-файлов
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает конфигурацию в информационную базу из XML-файлов.
|
||||
Поддерживает полную и частичную загрузку.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER ConfigDir
|
||||
Каталог XML-исходников конфигурации
|
||||
|
||||
.PARAMETER Mode
|
||||
Режим загрузки: Full или Partial (по умолчанию Full)
|
||||
|
||||
.PARAMETER Files
|
||||
Относительные пути файлов через запятую (для режима Partial)
|
||||
|
||||
.PARAMETER ListFile
|
||||
Путь к файлу со списком файлов (альтернатива -Files, для режима Partial)
|
||||
|
||||
.PARAMETER Extension
|
||||
Имя расширения для загрузки
|
||||
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения
|
||||
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
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)]
|
||||
# Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
|
||||
# как станет видно, перечислены ли файлы.
|
||||
[ValidateSet("", "Full", "Partial")]
|
||||
[string]$Mode = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Files,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog,
|
||||
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$NoApplyCheck,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
function Write-RepositoryHints {
|
||||
param([string]$LogText)
|
||||
if (-not $LogText) { return }
|
||||
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||
}
|
||||
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||
$obj = $m.Groups[1].Value
|
||||
if ($obj -eq 'Configuration') {
|
||||
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
# Постусловие применимости расширения: платформа отчитывается успехом и о расширении, которое
|
||||
# не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал регистрации.
|
||||
#
|
||||
# Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной командной
|
||||
# строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча отбрасывает —
|
||||
# проверено на 8.3.24, /LoadConfigFromFiles вместе с /CheckCanApplyConfigurationExtensions
|
||||
# завершились кодом 0 с пустым логом, и загрузка не состоялась.
|
||||
#
|
||||
# Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний исполняемый файл.
|
||||
function Invoke-ApplyCheck {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$exeDir = Split-Path $Exe -Parent
|
||||
$exeLeaf = Split-Path $Exe -Leaf
|
||||
$v8 = if ($exeLeaf -match '^ibcmd') { Join-Path $exeDir ("1cv8" + [System.IO.Path]::GetExtension($Exe)) } else { $Exe }
|
||||
if (-not (Test-Path $v8)) { return @{ Skipped = $true; Reason = "1cv8 not found at $v8"; ExitCode = 0; Lines = @() } }
|
||||
$dir = Join-Path $env:TEMP "apply_check_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
try {
|
||||
$a = @("DESIGNER") + $ConnArgs + @("/CheckCanApplyConfigurationExtensions")
|
||||
if ($Extension) { $a += "-Extension", "`"$Extension`"" }
|
||||
$outFile = Join-Path $dir "check_log.txt"
|
||||
$a += "/Out", "`"$outFile`"", "/DisableStartupDialogs"
|
||||
$a += $ExtraArgs
|
||||
$res = Invoke-PlatformProcess $v8 $a -PreQuoted
|
||||
$lines = @()
|
||||
if (Test-Path $outFile) {
|
||||
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $lines = @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) }
|
||||
}
|
||||
return @{ Skipped = $false; Reason = ''; ExitCode = $res.ExitCode; Lines = $lines }
|
||||
} finally {
|
||||
if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Проверить и напечатать. $true, если платформа расширение не применит — вызывающий решает,
|
||||
# поднимать ли код возврата (строгий режим).
|
||||
function Invoke-ApplyCheckReport {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$ac = Invoke-ApplyCheck $Exe $ConnArgs $Extension $ExtraArgs
|
||||
if ($ac.Skipped) {
|
||||
Write-Host "[note] applicability check skipped: $($ac.Reason)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
if ($ac.ExitCode -ne 0 -or $ac.Lines.Count -gt 0) {
|
||||
Write-Host "[warning] the extension is loaded, but the platform will not apply it:" -ForegroundColor Yellow
|
||||
foreach ($l in $ac.Lines) { Write-Host " $l" -ForegroundColor Yellow }
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Проверять ли применимость: -NoApplyCheck сильнее настройки проекта.
|
||||
function Get-ApplyCheckEnabled {
|
||||
param([switch]$Disabled)
|
||||
if ($Disabled) { return $false }
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if ($pf) {
|
||||
try {
|
||||
$proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($null -ne $proj.extensionApplyCheck) { return [bool]$proj.extensionApplyCheck }
|
||||
} catch {}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание Full
|
||||
# заменило бы всю конфигурацию базы.
|
||||
if ($Files -or $ListFile) {
|
||||
if ($Mode -eq "Full") {
|
||||
Write-Host "[note] перечислены файлы — загружаются только они; -Mode Full не применён" -ForegroundColor Yellow
|
||||
}
|
||||
$Mode = "Partial"
|
||||
}
|
||||
if (-not $Mode) { $Mode = "Full" }
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
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") {
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
$fileList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} elseif ($Files) {
|
||||
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $fileList
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
$acConn = @("/F", "`"$InfoBasePath`"")
|
||||
if ($UserName) { $acConn += "/N`"$UserName`"" }
|
||||
if ($Password) { $acConn += "/P`"$Password`"" }
|
||||
if ((Invoke-ApplyCheckReport $V8Path $acConn $Extension @()) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт запуск проверки применимости.
|
||||
$connArgs = @()
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$connArgs += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$connArgs += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $connArgs += "/N`"$UserName`"" }
|
||||
if ($Password) { $connArgs += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$connArgs += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments = @("DESIGNER") + $connArgs
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
|
||||
if ($Mode -eq "Full") {
|
||||
Write-Host "Executing full configuration load..."
|
||||
} else {
|
||||
Write-Host "Executing partial configuration load..."
|
||||
|
||||
# Build list file
|
||||
$rawList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} else {
|
||||
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
|
||||
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
|
||||
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
|
||||
if ($supportFiles.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$generatedListFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
|
||||
Write-Host "Files to load: $($fileList.Count)"
|
||||
foreach ($f in $fileList) { Write-Host " $f" }
|
||||
|
||||
$arguments += "-listFile", "`"$generatedListFile`""
|
||||
$arguments += "-partial"
|
||||
$arguments += "-updateConfigDumpInfo"
|
||||
}
|
||||
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- UpdateDB ---
|
||||
if ($UpdateDB) {
|
||||
$arguments += "/UpdateDBCfg"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Read log ---
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
# rejection warnings) is always printed to stdout for visibility. With -StrictLog,
|
||||
# elevate exit code to 1 when rejection patterns are found even if platform said 0.
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
Write-RepositoryHints $logContent
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
# Расширение могло загрузиться «успешно» и при этом остаться неприменимым — спрашиваем платформу.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
if ((Invoke-ApplyCheckReport $V8Path $connArgs $Extension $extraArgs) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,962 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.28 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
def write_repository_hints(log_text):
|
||||
if not log_text:
|
||||
return
|
||||
if "текущая конфигурация помещена в хранилище" in log_text:
|
||||
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
|
||||
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
|
||||
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
|
||||
obj = m.group(1)
|
||||
if obj == "Configuration":
|
||||
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
|
||||
print(' /db-repo lock <база> -Objects "Конфигурация"')
|
||||
else:
|
||||
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
|
||||
if "Соединение с хранилищем конфигурации не установлено" in log_text:
|
||||
print("[hint] соединение с хранилищем не установлено. Две причины:")
|
||||
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
|
||||
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_apply_check(exe, conn_args, extension, extra_args):
|
||||
"""Постусловие применимости расширения: платформа отчитывается успехом и о расширении,
|
||||
которое не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал
|
||||
регистрации.
|
||||
|
||||
Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной
|
||||
командной строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча
|
||||
отбрасывает — проверено на 8.3.24, /LoadConfigFromFiles вместе с
|
||||
/CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка не
|
||||
состоялась.
|
||||
|
||||
Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний файл.
|
||||
"""
|
||||
exe_dir = os.path.dirname(exe)
|
||||
leaf = os.path.basename(exe)
|
||||
if leaf.lower().startswith("ibcmd"):
|
||||
v8 = os.path.join(exe_dir, "1cv8" + os.path.splitext(leaf)[1])
|
||||
else:
|
||||
v8 = exe
|
||||
if not os.path.isfile(v8):
|
||||
return {"skipped": True, "reason": f"1cv8 not found at {v8}", "exit": 0, "lines": []}
|
||||
temp_dir = tempfile.mkdtemp(prefix="apply_check_")
|
||||
try:
|
||||
a = ["DESIGNER"] + list(conn_args) + ["/CheckCanApplyConfigurationExtensions"]
|
||||
if extension:
|
||||
a += ["-Extension", f'"{extension}"']
|
||||
out_file = os.path.join(temp_dir, "check_log.txt")
|
||||
a += ["/Out", f'"{out_file}"', "/DisableStartupDialogs"]
|
||||
a += list(extra_args)
|
||||
r = run_v8(v8, a)
|
||||
lines = []
|
||||
if os.path.isfile(out_file):
|
||||
with open(out_file, encoding="utf-8-sig", errors="replace") as f:
|
||||
lines = [x.strip() for x in f.read().splitlines() if x.strip()]
|
||||
return {"skipped": False, "reason": "", "exit": r.returncode, "lines": lines}
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def apply_check_report(exe, conn_args, extension, extra_args):
|
||||
"""Проверить и напечатать. True, если платформа расширение не применит — вызывающий решает,
|
||||
поднимать ли код возврата (строгий режим)."""
|
||||
ac = run_apply_check(exe, conn_args, extension, extra_args)
|
||||
if ac["skipped"]:
|
||||
print(f"[note] applicability check skipped: {ac['reason']}")
|
||||
return False
|
||||
if ac["exit"] != 0 or ac["lines"]:
|
||||
print("[warning] the extension is loaded, but the platform will not apply it:")
|
||||
for line in ac["lines"]:
|
||||
print(f" {line}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_check_enabled(disabled):
|
||||
"""Проверять ли применимость: -NoApplyCheck сильнее настройки проекта."""
|
||||
if disabled:
|
||||
return False
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if pf:
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
if proj.get("extensionApplyCheck") is not None:
|
||||
return bool(proj.get("extensionApplyCheck"))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C configuration from XML files",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="",
|
||||
choices=["", "Full", "Partial"],
|
||||
help="Load mode (default: Full)",
|
||||
)
|
||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
||||
parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to load")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="File format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
parser.add_argument(
|
||||
"-StrictLog",
|
||||
action="store_true",
|
||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
||||
)
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
parser.add_argument("-NoApplyCheck", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
args.ListFile = clean_path(args.ListFile, "-ListFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
|
||||
# Full заменило бы всю конфигурацию базы.
|
||||
if args.Files or args.ListFile:
|
||||
if args.Mode == "Full":
|
||||
print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
|
||||
args.Mode = "Partial"
|
||||
if not args.Mode:
|
||||
args.Mode = "Full"
|
||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
||||
print("Error: -Files or -ListFile required for Partial mode")
|
||||
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)")
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "Partial":
|
||||
# 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}")
|
||||
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")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
print_platform_output(ar)
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
ac_conn = ["/F", f'"{args.InfoBasePath}"']
|
||||
if args.UserName:
|
||||
ac_conn.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
ac_conn.append(f'/P"{args.Password}"')
|
||||
if apply_check_report(v8path, ac_conn, args.Extension, []) and args.StrictLog:
|
||||
exit_code = 1
|
||||
|
||||
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 ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт проверка применимости.
|
||||
conn_args = []
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
conn_args += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
conn_args += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
conn_args.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
conn_args.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
conn_args.extend(repository_args(repo))
|
||||
|
||||
arguments = ["DESIGNER"] + conn_args
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration load...")
|
||||
else:
|
||||
print("Executing partial configuration load...")
|
||||
|
||||
# Build list file
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}")
|
||||
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("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
|
||||
for sf in support_files:
|
||||
print(f" - {sf}")
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
|
||||
if not file_list:
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
|
||||
sys.exit(1)
|
||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(file_list))
|
||||
print(f"Files to load: {len(file_list)}")
|
||||
for fl in file_list:
|
||||
print(f" {fl}")
|
||||
|
||||
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- UpdateDB ---
|
||||
if args.UpdateDB:
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Read log ---
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
except Exception:
|
||||
log_content = ""
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
|
||||
# --- 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)}")
|
||||
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
write_repository_hints(log_content)
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for f in silent_failures:
|
||||
print(f" {f}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
if apply_check_report(v8path, conn_args, args.Extension, extra_args) and args.StrictLog:
|
||||
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,200 +0,0 @@
|
||||
---
|
||||
name: db-repo
|
||||
description: Работа с хранилищем конфигурации 1С. Используй когда нужно захватить объекты, поместить изменения в хранилище конфигурации, получить изменения из него, подключить базу к хранилищу
|
||||
argument-hint: <lock|unlock|commit|update> [database] -Objects "<объекты>"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-repo — Хранилище конфигурации 1С
|
||||
|
||||
Захват и помещение объектов, получение изменений, подключение базы, история версий,
|
||||
администрирование хранилища.
|
||||
|
||||
> Хранилище конфигурации 1С, а не Git-репозиторий.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-repo lock [database] -Objects "Справочник.Номенклатура"
|
||||
/db-repo commit [database] -Objects "Справочник.Номенклатура" -Comment "Добавлен Артикул"
|
||||
/db-repo unlock [database] -Objects "Справочник.Номенклатура"
|
||||
/db-repo update [database]
|
||||
```
|
||||
|
||||
## Порядок работы
|
||||
|
||||
В базу, подключённую к хранилищу, исходники грузятся **только частично** и **только по захваченным**
|
||||
объектам. Выполняй строго по шагам:
|
||||
|
||||
```
|
||||
0. /db-repo update <база> — начать с актуального состояния
|
||||
1. /db-repo lock <база> -Objects "Справочник.Номенклатура"
|
||||
2. если шаг 0 или 1 напечатал «локальная конфигурация изменена, получено объектов из хранилища: N» —
|
||||
выгрузи названные объекты: /db-dump-xml <база> <каталог> -Mode Partial -ObjectsFile "<файл из вывода>"
|
||||
3. правки в исходниках: /meta-edit, /form-edit, /skd-edit, /meta-compile и т. д.
|
||||
4. /db-load-xml <каталог> <база> -Mode Partial -Files "Catalogs/Номенклатура.xml,…" -UpdateDB
|
||||
5. /db-repo commit <база> -Objects "Справочник.Номенклатура" -Comment "…"
|
||||
```
|
||||
|
||||
Шаг 0 стоит делать всегда, когда работа не продолжается сразу после предыдущего цикла: правки
|
||||
должны опираться на актуальное состояние — в том числе тех объектов, которые ты не меняешь, но
|
||||
используешь.
|
||||
|
||||
Шаг 2 пропускать нельзя: захват и обновление подтягивают из хранилища свежие версии, и загрузка
|
||||
исходников, снятых раньше, откатит чужие изменения — молча, без ошибки.
|
||||
|
||||
**Что вообще захватывается.** Отдельные объекты хранилища — сам объект, а также его **формы,
|
||||
макеты и команды**. Реквизиты, табличные части, измерения и ресурсы отдельными объектами **не
|
||||
являются**: они правятся в составе владельца.
|
||||
|
||||
| Что правишь | Что захватывать |
|
||||
|-------------|-----------------|
|
||||
| Реквизит, табличную часть, измерение, ресурс, модуль объекта | сам объект: `Справочник.Контрагенты` |
|
||||
| Существующую форму, макет, команду | её саму: `Справочник.Контрагенты.Форма.ФормаЭлемента` |
|
||||
| Добавляешь новую форму, макет, команду | объект-владельца; при помещении назови и новый объект |
|
||||
| Добавляешь новый объект конфигурации | только корень: `Конфигурация`. Самого объекта ещё нет — захватить его нельзя; при помещении назови и его |
|
||||
|
||||
Захватывай минимум того, что правишь: чем шире захват, тем больше конфликтов с коллегами.
|
||||
Захват объекта его формы и макеты не захватывает — для этого есть `-WithChildren`.
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
|
||||
1. Если пользователь указал параметры подключения — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
|
||||
Реквизиты хранилища передавать не нужно: запись базы находится по переданным параметрам
|
||||
соединения (`-InfoBasePath` либо `-InfoBaseServer` + `-InfoBaseRef`), реквизиты берутся из её
|
||||
`repository`. Задать их явно можно параметрами `-Repository*`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command <подкоманда> <параметры>
|
||||
```
|
||||
|
||||
### Рабочий цикл
|
||||
|
||||
| Подкоманда | Что делает |
|
||||
|------------|------------|
|
||||
| `lock` | Захватить объекты |
|
||||
| `unlock` | Отменить захват |
|
||||
| `commit` | Поместить изменения в хранилище |
|
||||
| `update` | Получить изменения из хранилища |
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Пользователь базы |
|
||||
| `-Password <пароль>` | нет | Пароль пользователя базы |
|
||||
| `-Objects <список>` | усл. | Объекты через запятую. Для `lock`, `unlock`, `commit` обязателен, если не задан `-All` |
|
||||
| `-ObjectsFile <путь>` | нет | Файл со списком объектов, одно имя на строку |
|
||||
| `-All` | нет | Операция над всей конфигурацией — вместо `-Objects`, а не вместе с ним |
|
||||
| `-WithChildren` | нет | Вместе с подчинёнными объектами на полную глубину |
|
||||
| `-Comment <текст>` | нет | Комментарий к помещению (`commit`). Многострочный — как есть, с переводами строк |
|
||||
| `-KeepLocked` | нет | Оставить объекты захваченными после помещения |
|
||||
| `-Revised` | нет | Получать захваченные объекты, если потребуется |
|
||||
| `-Force` | нет | Разное по подкомандам — см. ниже |
|
||||
| `-Extension <имя>` | нет | Работать с хранилищем расширения |
|
||||
| `-RepositoryPath <путь>` | нет | Хранилище явно, вместо реестра |
|
||||
| `-RepositoryUser <имя>` | нет | Пользователь хранилища явно |
|
||||
| `-RepositoryPassword <пароль>` | нет | Пароль пользователя хранилища явно |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
### `-Force`
|
||||
|
||||
| Подкоманда | Что делает |
|
||||
|------------|------------|
|
||||
| `unlock` | **Теряет локальные правки**: объекты перезаписываются версией из хранилища |
|
||||
| `commit` | Пытается очистить ссылки на удалённые объекты вместо ошибки |
|
||||
| `update` | Подтверждает добавление и удаление объектов конфигурации |
|
||||
|
||||
### Имена объектов
|
||||
|
||||
Объект — `Справочник.Номенклатура`. Форма, макет, команда — полным путём:
|
||||
`Документ.ЗаказПокупателя.Форма.ФормаДокумента`, `Справочник.Номенклатура.Макет.Печать`.
|
||||
Корень конфигурации — `Конфигурация`.
|
||||
|
||||
Если объект «не найден», это не всегда опечатка: он мог появиться в хранилище позже, чем
|
||||
обновлялась база (`/db-repo update`), либо это вовсе не объект хранилища — реквизит или
|
||||
табличная часть.
|
||||
|
||||
## Результат
|
||||
|
||||
Нулевой код не означает, что что-то изменилось. Под нулём приходят «уже захвачено», «обновлять
|
||||
нечего», «помещать нечего» и частичный захват — когда часть объектов занята другими, а остальное
|
||||
захвачено и его можно править.
|
||||
|
||||
**Читай текст вывода, а не только код.** Там же приходит список полученных из хранилища объектов,
|
||||
который требует перевыгрузки перед правкой.
|
||||
|
||||
## Требуют подтверждения пользователя
|
||||
|
||||
Перед этими операциями **спроси подтверждение**:
|
||||
|
||||
| Операция | Почему |
|
||||
|----------|--------|
|
||||
| `lock -All` | Захватывает **всю конфигурацию**: на большой базе идёт долго и блокирует работу всей команде |
|
||||
| `unlock -Force` | Теряются локальные правки захваченных объектов |
|
||||
| `disconnect` | Теряется подключение базы к хранилищу, в том числе на стороне хранилища |
|
||||
| `connect -ForceReplaceCfg` | Конфигурация базы заменяется конфигурацией из хранилища |
|
||||
|
||||
`update` не выполнится, если у базы в реестре не объявлено `repository`, а реквизиты не заданы
|
||||
явно: на неподключённой к хранилищу базе эта команда заменяет всю конфигурацию его содержимым и
|
||||
рапортует успех.
|
||||
|
||||
## Расширения
|
||||
|
||||
У расширения своё хранилище со своим путём. Укажи `-Extension "<Имя>"` — реквизиты возьмутся из
|
||||
`extensions[].repository` записи базы. Подкоманды работают одинаково для основной конфигурации и
|
||||
для расширения.
|
||||
|
||||
## Остальные задачи
|
||||
|
||||
| Файл | Про что |
|
||||
|------|---------|
|
||||
| [connect.md](references/connect.md) | Подключение и отключение базы от хранилища |
|
||||
| [history.md](references/history.md) | История версий, отчёт, выгрузка версии в CF |
|
||||
| [admin.md](references/admin.md) | Создание хранилища, пользователи и права |
|
||||
| [service.md](references/service.md) | Метки версий, оптимизация, очистка кеша |
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Захватить справочник вместе с подчинёнными объектами
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
|
||||
|
||||
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
|
||||
|
||||
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
|
||||
|
||||
# Поместить с комментарием, оставив захват
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
|
||||
|
||||
# Получить изменения из хранилища
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
# Серверная база, расширение
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command lock -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Extension "МоёРасширение" -Objects "Справочник.Номенклатура"
|
||||
```
|
||||
|
||||
## После выполнения
|
||||
|
||||
- `lock` или `update` сообщил о полученных объектах — выполни `/db-dump-xml -Mode Partial` с
|
||||
указанным в выводе файлом, и только потом правь исходники
|
||||
- после `lock` правки идут через `/db-load-xml -Mode Partial` и `/db-update`
|
||||
- изменения готовы — предложи `/db-repo commit` с комментарием
|
||||
@@ -1,45 +0,0 @@
|
||||
# Администрирование хранилища
|
||||
|
||||
## create — создать хранилище
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command create -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "C:\Repo\MyApp" -RepositoryUser "Admin" -RepositoryPassword "…"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-NoBind` | Не подключать базу к созданному хранилищу |
|
||||
| `-AllowConfigurationChanges` | Включить возможность изменения, если конфигурация на поддержке без неё |
|
||||
| `-ChangesAllowedRule <правило>` | Правило для объектов, изменения которых разрешены поставщиком |
|
||||
| `-ChangesNotRecommendedRule <правило>` | То же для «изменения не рекомендуются» |
|
||||
|
||||
Правила: `ObjectNotEditable`, `ObjectIsEditableSupportEnabled`, `ObjectNotSupported`.
|
||||
|
||||
Без `-NoBind` база сразу подключается к созданному хранилищу. Создание — это версия 1.
|
||||
|
||||
Для расширения: `-Extension "<Имя>"` и отдельный путь — у расширения своё хранилище.
|
||||
|
||||
## add-user — создать пользователя
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command add-user -InfoBasePath "C:\Bases\MyDB" -NewUser "Ivanov" -NewUserPassword "…" -Rights LockObjects
|
||||
```
|
||||
|
||||
| Право | Что даёт |
|
||||
|-------|----------|
|
||||
| `ReadOnly` | Просмотр |
|
||||
| `LockObjects` | Захват объектов |
|
||||
| `ManageConfigurationVersions` | Изменение состава версий |
|
||||
| `Administration` | Административные функции |
|
||||
|
||||
`-RestoreDeletedUser` — восстановить одноимённого удалённого. Если пользователь с таким именем
|
||||
существует, он **не** будет добавлен. Выполняющий должен иметь административные права.
|
||||
|
||||
## copy-users — скопировать пользователей из другого хранилища
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command copy-users -InfoBasePath "C:\Bases\MyDB" -SourcePath "\\srv01\repo\Other" -SourceUser "Admin" -SourcePassword "…"
|
||||
```
|
||||
|
||||
`-SourcePath`, `-SourceUser`, `-SourcePassword` описывают хранилище-**источник**. Удалённые пользователи
|
||||
не копируются; существующие не перезаписываются.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Подключение базы к хранилищу
|
||||
|
||||
## connect — подключить
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command connect -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "\\srv01\repo\MyApp" -RepositoryUser "Ivanov" -RepositoryPassword "…"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-ForceReplaceCfg` | Конфигурация базы непустая — подтвердить замену её конфигурацией из хранилища. **Спроси подтверждение у пользователя** |
|
||||
| `-ForceBindAlreadyBindedUser` | Подключить, даже если у этого пользователя уже есть конфигурация, связанная с хранилищем |
|
||||
|
||||
На пустой базе `-ForceReplaceCfg` не нужен.
|
||||
|
||||
**Переподключение** базы, которая уже была подключена, требует обоих флагов: конфигурация в базе
|
||||
не пустая (`-ForceReplaceCfg`), а за пользователем хранилища всё ещё числится эта база
|
||||
(`-ForceBindAlreadyBindedUser`).
|
||||
|
||||
После подключения добавь `repository` в запись базы в `.v8-project.json` — иначе остальные
|
||||
подкоманды придётся каждый раз звать с явными реквизитами, а `update` откажется работать.
|
||||
|
||||
## disconnect — отключить
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command disconnect -InfoBasePath "C:\Bases\MyDB"
|
||||
```
|
||||
|
||||
**Спроси подтверждение у пользователя.** Отключение снимает связь и на стороне самого хранилища:
|
||||
запись о подключении удаляется. Подключить базу обратно можно, но это уже не рядовая операция —
|
||||
понадобятся оба флага `connect` из раздела выше.
|
||||
|
||||
Если в базе есть захваченные и изменённые объекты, операция не выполнится. `-Force` выполняет её
|
||||
всё равно, и эти изменения теряются.
|
||||
|
||||
## Расширения
|
||||
|
||||
У расширения своё хранилище: `-Extension "<Имя>"` указывай вместе с путём именно к нему, а не
|
||||
к хранилищу основной конфигурации.
|
||||
@@ -1,31 +0,0 @@
|
||||
# История версий хранилища
|
||||
|
||||
## report — отчёт по версиям
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command report -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\repo.txt"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-OutputFile <путь>` | Куда сохранить отчёт. Необязателен |
|
||||
| `-NBegin <номер>` | С какой версии. `-1` — только последняя |
|
||||
| `-NEnd <номер>` | По какую версию |
|
||||
| `-DateBegin` / `-DateEnd` | Границы по датам |
|
||||
| `-GroupByObject` | Группировать по объектам |
|
||||
| `-GroupByComment` | Группировать по комментарию |
|
||||
| `-ReportFormat <txt\|mxl>` | По умолчанию `txt` |
|
||||
|
||||
`txt` — с разделителем-табуляцией, разбирается построчно.
|
||||
|
||||
> На боевом хранилище полный отчёт строить не надо — тысячи версий. Нужна головная
|
||||
> версия — `-NBegin -1`. Длинный отчёт в вывод не печатается: сузьте выборку
|
||||
> параметрами ниже.
|
||||
|
||||
## dump-cfg — выгрузить версию в CF
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command dump-cfg -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\v120.cf" -Version 120
|
||||
```
|
||||
|
||||
Без `-Version` (или при `-1`) выгружается последняя версия.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Сервисные операции
|
||||
|
||||
## set-label — метка на версию
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command set-label -InfoBasePath "C:\Bases\MyDB" -Label "Релиз 1.2" -Version 120 -Comment "Передано в тест"
|
||||
```
|
||||
|
||||
Без `-Version` метка ставится на последнюю версию. Несуществующая версия — ошибка.
|
||||
|
||||
## optimize — оптимизация хранения
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command optimize -InfoBasePath "C:\Bases\MyDB"
|
||||
```
|
||||
|
||||
Оптимизирует хранение данных в хранилище. Операция долгая.
|
||||
|
||||
## clear-cache — очистка кеша
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/db-repo/scripts/db-repo.ps1" -Command clear-cache -InfoBasePath "C:\Bases\MyDB" -CacheScope local
|
||||
```
|
||||
|
||||
| `-CacheScope` | Что чистит |
|
||||
|---------------|------------|
|
||||
| `local` (по умолчанию) | Локальный кеш версий конфигурации |
|
||||
| `global` | Глобальный кеш версий |
|
||||
| `db` | Локальную базу данных хранилища |
|
||||
|
||||
Пригождается, когда хранилище ведёт себя странно после сбоя сети или отката версии.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,388 +0,0 @@
|
||||
# db-run v1.10 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Запуск 1С:Предприятие
|
||||
|
||||
.DESCRIPTION
|
||||
Запускает информационную базу в режиме 1С:Предприятие (пользовательский режим).
|
||||
Запуск в фоне — не ждёт завершения процесса.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER Execute
|
||||
Путь к внешней обработке для запуска
|
||||
|
||||
.PARAMETER CParam
|
||||
Параметр запуска (/C)
|
||||
|
||||
.PARAMETER URL
|
||||
Навигационная ссылка (e1cib/...)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -Execute "C:\epf\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Execute,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$CParam,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$URL,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
$engine = "1cv8"
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '/Execute' = '-Execute'; '/C' = '-CParam'; '/URL' = '-URL' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# ShellExecute re-joins the argument string, so quote each extra token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Build arguments as single string ---
|
||||
# Note: Start-Process without -NoNewWindow uses ShellExecute.
|
||||
# Passing ArgumentList as array can corrupt Cyrillic when ShellExecute
|
||||
# re-joins elements. Single string avoids this.
|
||||
$argString = "ENTERPRISE"
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$argString += " /S `"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$argString += " /F `"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $argString += " /N`"$UserName`"" }
|
||||
if ($Password) { $argString += " /P`"$Password`"" }
|
||||
|
||||
# --- Optional params ---
|
||||
if ($Execute) {
|
||||
$ext = [System.IO.Path]::GetExtension($Execute).ToLower()
|
||||
if ($ext -eq ".erf") {
|
||||
Write-Host "[WARN] /Execute не поддерживает ERF-файлы (внешние отчёты)." -ForegroundColor Yellow
|
||||
Write-Host " Откройте отчёт через «Файл -> Открыть»: $Execute" -ForegroundColor Yellow
|
||||
Write-Host " Запускаю базу без /Execute." -ForegroundColor Yellow
|
||||
$Execute = ""
|
||||
}
|
||||
}
|
||||
if ($Execute) {
|
||||
$argString += " /Execute `"$Execute`""
|
||||
}
|
||||
if ($CParam) {
|
||||
$argString += " /C `"$CParam`""
|
||||
}
|
||||
if ($URL) {
|
||||
$argString += " /URL `"$URL`""
|
||||
}
|
||||
|
||||
$argString += " /DisableStartupDialogs"
|
||||
|
||||
# The display string is built from the same tokens with secret-prone values redacted.
|
||||
$displayString = $argString
|
||||
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
|
||||
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||
Write-Host "Running: 1cv8.exe $displayArg"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ($proc.HasExited) {
|
||||
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
|
||||
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
|
||||
}
|
||||
Write-Host "PID: $($proc.Id)"
|
||||
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
||||
@@ -1,402 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.10 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Launch 1C:Enterprise",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-Execute", default="")
|
||||
parser.add_argument("-CParam", default="")
|
||||
parser.add_argument("-URL", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.Execute = clean_path(args.Execute, "-Execute")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
engine = "1cv8"
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"/Execute": "-Execute",
|
||||
"/C": "-CParam",
|
||||
"/URL": "-URL",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["ENTERPRISE"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
|
||||
# --- Optional params ---
|
||||
execute = args.Execute
|
||||
if execute:
|
||||
ext = os.path.splitext(execute)[1].lower()
|
||||
if ext == ".erf":
|
||||
print("[WARN] /Execute does not support ERF files (external reports).")
|
||||
print(f" Open the report via File -> Open: {execute}")
|
||||
print(" Launching database without /Execute.")
|
||||
execute = ""
|
||||
|
||||
if execute:
|
||||
arguments.extend(["/Execute", execute])
|
||||
if args.CParam:
|
||||
arguments.extend(["/C", args.CParam])
|
||||
if args.URL:
|
||||
arguments.extend(["/URL", args.URL])
|
||||
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(extra_args)
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
proc = subprocess.Popen([v8path] + arguments)
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline and proc.poll() is None:
|
||||
time.sleep(0.2)
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})")
|
||||
sys.exit(rc if rc and rc > 0 else 1)
|
||||
print(f"PID: {proc.pid}")
|
||||
print("1C:Enterprise launched")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,774 +0,0 @@
|
||||
# db-update v1.20 — 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
|
||||
Динамическое обновление: on включить, off отключить
|
||||
|
||||
.PARAMETER Server
|
||||
Обновление на стороне сервера
|
||||
|
||||
.PARAMETER WarningsAsErrors
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
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,
|
||||
|
||||
# on/off, а не +/-: значение "-" через powershell.exe -File парсер не связывает и молча
|
||||
# выходит с кодом 2, без единого сообщения. "+"/"-" принимаются, но в инструкции не значатся.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("on", "off", "yes", "no", "+", "-")]
|
||||
[string]$Dynamic,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$Server,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$NoApplyCheck,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
if ($Dynamic) { $Dynamic = if (@('on', 'yes', '+') -contains $Dynamic.ToLower()) { '+' } else { '-' } }
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
# Постусловие применимости расширения: платформа отчитывается успехом и о расширении, которое
|
||||
# не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал регистрации.
|
||||
#
|
||||
# Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной командной
|
||||
# строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча отбрасывает —
|
||||
# проверено на 8.3.24, /LoadConfigFromFiles вместе с /CheckCanApplyConfigurationExtensions
|
||||
# завершились кодом 0 с пустым логом, и загрузка не состоялась.
|
||||
#
|
||||
# Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний исполняемый файл.
|
||||
function Invoke-ApplyCheck {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$exeDir = Split-Path $Exe -Parent
|
||||
$exeLeaf = Split-Path $Exe -Leaf
|
||||
$v8 = if ($exeLeaf -match '^ibcmd') { Join-Path $exeDir ("1cv8" + [System.IO.Path]::GetExtension($Exe)) } else { $Exe }
|
||||
if (-not (Test-Path $v8)) { return @{ Skipped = $true; Reason = "1cv8 not found at $v8"; ExitCode = 0; Lines = @() } }
|
||||
$dir = Join-Path $env:TEMP "apply_check_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
try {
|
||||
$a = @("DESIGNER") + $ConnArgs + @("/CheckCanApplyConfigurationExtensions")
|
||||
if ($Extension) { $a += "-Extension", "`"$Extension`"" }
|
||||
$outFile = Join-Path $dir "check_log.txt"
|
||||
$a += "/Out", "`"$outFile`"", "/DisableStartupDialogs"
|
||||
$a += $ExtraArgs
|
||||
$res = Invoke-PlatformProcess $v8 $a -PreQuoted
|
||||
$lines = @()
|
||||
if (Test-Path $outFile) {
|
||||
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $lines = @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) }
|
||||
}
|
||||
return @{ Skipped = $false; Reason = ''; ExitCode = $res.ExitCode; Lines = $lines }
|
||||
} finally {
|
||||
if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Проверить и напечатать. $true, если платформа расширение не применит — вызывающий решает,
|
||||
# поднимать ли код возврата (строгий режим).
|
||||
function Invoke-ApplyCheckReport {
|
||||
param([string]$Exe, [string[]]$ConnArgs, [string]$Extension, [string[]]$ExtraArgs)
|
||||
$ac = Invoke-ApplyCheck $Exe $ConnArgs $Extension $ExtraArgs
|
||||
if ($ac.Skipped) {
|
||||
Write-Host "[note] applicability check skipped: $($ac.Reason)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
if ($ac.ExitCode -ne 0 -or $ac.Lines.Count -gt 0) {
|
||||
Write-Host "[warning] the extension is loaded, but the platform will not apply it:" -ForegroundColor Yellow
|
||||
foreach ($l in $ac.Lines) { Write-Host " $l" -ForegroundColor Yellow }
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Проверять ли применимость: -NoApplyCheck сильнее настройки проекта.
|
||||
function Get-ApplyCheckEnabled {
|
||||
param([switch]$Disabled)
|
||||
if ($Disabled) { return $false }
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if ($pf) {
|
||||
try {
|
||||
$proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($null -ne $proj.extensionApplyCheck) { return [bool]$proj.extensionApplyCheck }
|
||||
} catch {}
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_update_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config apply does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($Dynamic -eq "+") { $arguments += "--dynamic=auto" }
|
||||
elseif ($Dynamic -eq "-") { $arguments += "--dynamic=disable" }
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
$acConn = @("/F", "`"$InfoBasePath`"")
|
||||
if ($UserName) { $acConn += "/N`"$UserName`"" }
|
||||
if ($Password) { $acConn += "/P`"$Password`"" }
|
||||
if ((Invoke-ApplyCheckReport $V8Path $acConn $Extension @()) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт запуск проверки применимости.
|
||||
$connArgs = @()
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$connArgs += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$connArgs += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $connArgs += "/N`"$UserName`"" }
|
||||
if ($Password) { $connArgs += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$connArgs += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments = @("DESIGNER") + $connArgs
|
||||
|
||||
$arguments += "/UpdateDBCfg"
|
||||
|
||||
# --- Options ---
|
||||
if ($Dynamic) {
|
||||
$arguments += "-Dynamic$Dynamic"
|
||||
}
|
||||
if ($Server) {
|
||||
$arguments += "-Server"
|
||||
}
|
||||
if ($WarningsAsErrors) {
|
||||
$arguments += "-WarningsAsErrors"
|
||||
}
|
||||
|
||||
# --- Extensions ---
|
||||
if ($Extension) {
|
||||
$arguments += "-Extension", "`"$Extension`""
|
||||
} elseif ($AllExtensions) {
|
||||
$arguments += "-AllExtensions"
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "update_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if ($exitCode -eq 0 -and ($Extension -or $AllExtensions) -and (Get-ApplyCheckEnabled -Disabled:$NoApplyCheck)) {
|
||||
if ((Invoke-ApplyCheckReport $V8Path $connArgs $Extension $extraArgs) -and $StrictLog) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,828 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.20 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_apply_check(exe, conn_args, extension, extra_args):
|
||||
"""Постусловие применимости расширения: платформа отчитывается успехом и о расширении,
|
||||
которое не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал
|
||||
регистрации.
|
||||
|
||||
Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной
|
||||
командной строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча
|
||||
отбрасывает — проверено на 8.3.24, /LoadConfigFromFiles вместе с
|
||||
/CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка не
|
||||
состоялась.
|
||||
|
||||
Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний файл.
|
||||
"""
|
||||
exe_dir = os.path.dirname(exe)
|
||||
leaf = os.path.basename(exe)
|
||||
if leaf.lower().startswith("ibcmd"):
|
||||
v8 = os.path.join(exe_dir, "1cv8" + os.path.splitext(leaf)[1])
|
||||
else:
|
||||
v8 = exe
|
||||
if not os.path.isfile(v8):
|
||||
return {"skipped": True, "reason": f"1cv8 not found at {v8}", "exit": 0, "lines": []}
|
||||
temp_dir = tempfile.mkdtemp(prefix="apply_check_")
|
||||
try:
|
||||
a = ["DESIGNER"] + list(conn_args) + ["/CheckCanApplyConfigurationExtensions"]
|
||||
if extension:
|
||||
a += ["-Extension", f'"{extension}"']
|
||||
out_file = os.path.join(temp_dir, "check_log.txt")
|
||||
a += ["/Out", f'"{out_file}"', "/DisableStartupDialogs"]
|
||||
a += list(extra_args)
|
||||
r = run_v8(v8, a)
|
||||
lines = []
|
||||
if os.path.isfile(out_file):
|
||||
with open(out_file, encoding="utf-8-sig", errors="replace") as f:
|
||||
lines = [x.strip() for x in f.read().splitlines() if x.strip()]
|
||||
return {"skipped": False, "reason": "", "exit": r.returncode, "lines": lines}
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def apply_check_report(exe, conn_args, extension, extra_args):
|
||||
"""Проверить и напечатать. True, если платформа расширение не применит — вызывающий решает,
|
||||
поднимать ли код возврата (строгий режим)."""
|
||||
ac = run_apply_check(exe, conn_args, extension, extra_args)
|
||||
if ac["skipped"]:
|
||||
print(f"[note] applicability check skipped: {ac['reason']}")
|
||||
return False
|
||||
if ac["exit"] != 0 or ac["lines"]:
|
||||
print("[warning] the extension is loaded, but the platform will not apply it:")
|
||||
for line in ac["lines"]:
|
||||
print(f" {line}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def apply_check_enabled(disabled):
|
||||
"""Проверять ли применимость: -NoApplyCheck сильнее настройки проекта."""
|
||||
if disabled:
|
||||
return False
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if pf:
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
if proj.get("extensionApplyCheck") is not None:
|
||||
return bool(proj.get("extensionApplyCheck"))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Update 1C database configuration",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
# on/off, а не +/-: значение "-" через powershell.exe -File парсер PS не связывает и молча
|
||||
# выходит с кодом 2. "+"/"-" принимаются, но в инструкции не значатся.
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "on", "off", "yes", "no", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
# Пропустить проверку применимости расширения после загрузки.
|
||||
parser.add_argument("-NoApplyCheck", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
if args.Dynamic:
|
||||
args.Dynamic = "+" if args.Dynamic.lower() in ("on", "yes", "+") else "-"
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
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)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.Dynamic == "+":
|
||||
arguments.append("--dynamic=auto")
|
||||
elif args.Dynamic == "-":
|
||||
arguments.append("--dynamic=disable")
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
# Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
|
||||
exit_code = result.returncode
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
ac_conn = ["/F", f'"{args.InfoBasePath}"']
|
||||
if args.UserName:
|
||||
ac_conn.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
ac_conn.append(f'/P"{args.Password}"')
|
||||
if apply_check_report(v8path, ac_conn, args.Extension, []) and args.StrictLog:
|
||||
exit_code = 1
|
||||
|
||||
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 ---
|
||||
# Аргументы соединения собираем отдельно: тем же набором пойдёт проверка применимости.
|
||||
conn_args = []
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
conn_args.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
conn_args.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
conn_args.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
conn_args.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
conn_args.extend(repository_args(repo))
|
||||
|
||||
arguments = ["DESIGNER"] + conn_args
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Options ---
|
||||
if args.Dynamic:
|
||||
arguments.append(f"-Dynamic{args.Dynamic}")
|
||||
if args.Server:
|
||||
arguments.append("-Server")
|
||||
if args.WarningsAsErrors:
|
||||
arguments.append("-WarningsAsErrors")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for line in silent_failures:
|
||||
print(f" {line}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
# Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
|
||||
if (exit_code == 0 and (args.Extension or args.AllExtensions)
|
||||
and apply_check_enabled(args.NoApplyCheck)):
|
||||
if apply_check_report(v8path, conn_args, args.Extension, extra_args) and args.StrictLog:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,705 +0,0 @@
|
||||
# epf-build v1.17 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Сборка внешней обработки/отчёта 1С из XML-исходников
|
||||
|
||||
.DESCRIPTION
|
||||
Собирает EPF/ERF-файл из XML-исходников с помощью платформы 1С.
|
||||
Общий скрипт для epf-build и erf-build.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER SourceFile
|
||||
Путь к корневому XML-файлу исходников
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному EPF/ERF-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
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,
|
||||
|
||||
# Что проверить в исходниках перед сборкой: modules (синтаксис в контекстах), handlers,
|
||||
# unreferenced, empty-handlers, config; off — не проверять. По умолчанию modules,handlers.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Checks,
|
||||
|
||||
# Контексты синтаксической проверки. По умолчанию ThinClient,Server.
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Context,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
# --- Проверка исходников платформой ---
|
||||
# Сборка .epf/.erf ничего не проверяет: /LoadExternalDataProcessorOrReportFromFiles упаковывает XML
|
||||
# и модули не компилирует, поэтому сломанный модуль доезжает до пользователя и падает при открытии
|
||||
# обработки. Прямой команды «проверь внешнюю обработку» у платформы нет, но объект КОНФИГУРАЦИИ она
|
||||
# проверяет — поэтому обработка кладётся в конфигурацию временной базы (stub-db-create
|
||||
# -EmbedSourceFile) и спрашивается штатной /CheckConfig.
|
||||
#
|
||||
# Запуск отдельный и только через 1cv8: у ibcmd такой команды нет, а в одной командной строке
|
||||
# DESIGNER выполняет лишь последнюю пакетную команду.
|
||||
function Get-CheckFlags {
|
||||
param([string[]]$Checks, [string[]]$Contexts)
|
||||
$flags = @()
|
||||
if ($Checks -contains 'modules') { foreach ($c in $Contexts) { $flags += "-$c" } }
|
||||
if ($Checks -contains 'handlers') { $flags += '-HandlersExistence' }
|
||||
if ($Checks -contains 'unreferenced') { $flags += '-UnreferenceProcedures' }
|
||||
if ($Checks -contains 'empty-handlers') { $flags += '-EmptyHandlers' }
|
||||
if ($Checks -contains 'config') { $flags += '-ConfigLogIntegrity', '-IncorrectReferences' }
|
||||
return $flags
|
||||
}
|
||||
|
||||
# Платформа называет объект своим именем внутри конфигурации; модели нужен путь к исходнику.
|
||||
# Путь СКЛЕИВАЕТСЯ по конвенции выгрузки, поэтому возвращается только существующий файл:
|
||||
# выдуманный путь хуже отсутствующего — модель пойдёт открывать файл, которого нет.
|
||||
function Resolve-SourcePath {
|
||||
param([string]$Line, [string]$SourceDir)
|
||||
$candidate = $null
|
||||
$m = [regex]::Match($Line, '(?:Обработка|Отчет|DataProcessor|Report)\.([^.]+)\.(?:Форма|Form)\.([^.]+)\.')
|
||||
if ($m.Success) { $candidate = (Join-Path $SourceDir (Join-Path $m.Groups[1].Value (Join-Path "Forms" (Join-Path $m.Groups[2].Value "Ext\Form\Module.bsl")))) }
|
||||
if (-not $candidate) {
|
||||
$m = [regex]::Match($Line, '(?:Обработка|Отчет|DataProcessor|Report)\.([^.]+)\.(МодульОбъекта|ObjectModule)')
|
||||
if ($m.Success) { $candidate = (Join-Path $SourceDir (Join-Path $m.Groups[1].Value "Ext\ObjectModule.bsl")) }
|
||||
}
|
||||
if (-not $candidate) {
|
||||
$m = [regex]::Match($Line, '(?:Обработка|Отчет|DataProcessor|Report)\.([^.]+)\.(МодульМенеджера|ManagerModule)')
|
||||
if ($m.Success) { $candidate = (Join-Path $SourceDir (Join-Path $m.Groups[1].Value "Ext\ManagerModule.bsl")) }
|
||||
}
|
||||
if ($candidate -and (Test-Path $candidate -PathType Leaf)) { return $candidate }
|
||||
return $null
|
||||
}
|
||||
|
||||
# $true, если платформа нашла проблемы — вызывающий не собирает артефакт.
|
||||
function Invoke-SourceCheck {
|
||||
param([string]$Exe, [string]$BasePath, [string[]]$Flags, [string]$SourceDir, [string[]]$ExtraArgs)
|
||||
$exeDir = Split-Path $Exe -Parent
|
||||
$exeLeaf = Split-Path $Exe -Leaf
|
||||
$v8 = if ($exeLeaf -match '^ibcmd') { Join-Path $exeDir ("1cv8" + [System.IO.Path]::GetExtension($Exe)) } else { $Exe }
|
||||
if (-not (Test-Path $v8)) {
|
||||
Write-Host "[note] source check skipped: 1cv8 not found at $v8" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
$dir = Join-Path $env:TEMP "epf_check_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
try {
|
||||
$outFile = Join-Path $dir "check_log.txt"
|
||||
$a = @("DESIGNER", "/F", "`"$BasePath`"", "/CheckConfig") + $Flags + @("/Out", "`"$outFile`"", "/DisableStartupDialogs") + $ExtraArgs
|
||||
Write-Host "Running: 1cv8.exe $($a -join ' ')"
|
||||
$res = Invoke-PlatformProcess $v8 $a -PreQuoted
|
||||
$lines = @()
|
||||
if (Test-Path $outFile) {
|
||||
$raw = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $lines = @($raw -split "`r?`n" | Where-Object { $_.Trim() -ne '' }) }
|
||||
}
|
||||
# Платформа отвечает 101 на найденные проблемы; «Ошибок не обнаружено» приходит с кодом 0.
|
||||
if ($res.ExitCode -eq 0) { return $false }
|
||||
Write-Host "Error: платформа нашла проблемы в исходниках — сборка отменена" -ForegroundColor Red
|
||||
# Пустой лог при ненулевом коде — отказ не по находкам (база занята, нет лицензии); молчать нельзя.
|
||||
if ($lines.Count -eq 0) { Write-Host " платформа вернула код $($res.ExitCode) без сообщений" -ForegroundColor Red }
|
||||
foreach ($l in $lines) {
|
||||
Write-Host " $($l.TrimEnd())" -ForegroundColor Red
|
||||
$srcPath = Resolve-SourcePath $l $SourceDir
|
||||
if ($srcPath) { Write-Host " -> $srcPath" -ForegroundColor Red }
|
||||
}
|
||||
return $true
|
||||
} finally {
|
||||
if (Test-Path $dir) { Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
# Проверять ли исходники: -Checks off сильнее настройки проекта externalCheck.
|
||||
function Get-SourceCheckList {
|
||||
param([string]$Checks)
|
||||
$known = @('modules', 'handlers', 'unreferenced', 'empty-handlers', 'config')
|
||||
if ($Checks) {
|
||||
$list = @($Checks -split ',' | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ })
|
||||
if ($list -contains 'off') { return @() }
|
||||
foreach ($c in $list) {
|
||||
if ($known -notcontains $c) {
|
||||
Write-Host "Error: unknown check '$c' (expected: $($known -join ', ') or off)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
return $list
|
||||
}
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if ($pf) {
|
||||
try {
|
||||
$proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($null -ne $proj.externalCheck -and -not [bool]$proj.externalCheck) { return @() }
|
||||
} catch {}
|
||||
}
|
||||
return @('modules', 'handlers')
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Что проверяем в исходниках перед сборкой ---
|
||||
$checkList = @(Get-SourceCheckList $Checks)
|
||||
$contextList = @()
|
||||
if ($Context) { $contextList = @($Context -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) }
|
||||
if ($contextList.Count -eq 0) { $contextList = @('ThinClient', 'Server') }
|
||||
elseif ($checkList.Count -gt 0 -and $checkList -notcontains 'modules') {
|
||||
Write-Host "Error: -Context задан, но в -Checks нет modules — контексты относятся только к ней" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$sourceDir = Split-Path $SourceFile -Parent
|
||||
|
||||
function New-StubBase {
|
||||
# Стаб запускает свои процессы платформы (CREATEINFOBASE, LoadConfigFromFiles, UpdateDBCfg) —
|
||||
# им нужны те же дополнительные аргументы, что и сборке. Передаются только явные: файл проекта
|
||||
# стаб читает сам. Вызов через -Command, не -File: -File берёт хвост буквально, и массивный
|
||||
# параметр пришёл бы одним склеенным токеном.
|
||||
param([string]$BasePath, [switch]$Embed)
|
||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
|
||||
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $BasePath)"
|
||||
if ($Embed) { $stubCmd += " -EmbedSourceFile $(& $q $SourceFile)" }
|
||||
if ($AdditionalV8Arguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
if ($AdditionalIbcmdArguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
$p = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
|
||||
return $p.ExitCode
|
||||
}
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
$autoCreatedBase = $null
|
||||
$checkBase = $null
|
||||
$checkBasePath = $null
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||
Write-Host "No database specified. Creating temporary stub database..."
|
||||
if ((New-StubBase $autoBasePath -Embed:($checkList.Count -gt 0)) -ne 0) {
|
||||
# С внедрённой обработкой база падает прежде всего из-за самих исходников
|
||||
# (пример: DefaultForm на несуществующую форму) — говорить про базу значит увести не туда.
|
||||
if ($checkList.Count -gt 0) {
|
||||
Write-Host "Error: платформа не приняла исходники при подготовке проверки — сборка отменена" -ForegroundColor Red
|
||||
Write-Host " сообщение платформы выше; имя объекта в нём конфигурационное: DataProcessor/Report = проверяемая внешняя обработка/отчёт" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
$InfoBasePath = $autoBasePath
|
||||
$autoCreatedBase = $autoBasePath
|
||||
if ($checkList.Count -gt 0) { $checkBasePath = $autoBasePath }
|
||||
} elseif ($checkList.Count -gt 0) {
|
||||
# Базу указали снаружи: класть проверяемую обработку в чужую конфигурацию нельзя, поэтому под
|
||||
# проверку поднимается своя временная база, а сборка идёт на указанной.
|
||||
$checkBase = Join-Path $env:TEMP "epf_check_db_$(Get-Random)"
|
||||
Write-Host "Creating temporary database for the source check..."
|
||||
if ((New-StubBase $checkBase -Embed) -ne 0) {
|
||||
Write-Host "Error: платформа не приняла исходники при подготовке проверки — сборка отменена" -ForegroundColor Red
|
||||
Write-Host " сообщение платформы выше; имя объекта в нём конфигурационное: DataProcessor/Report = проверяемая внешняя обработка/отчёт" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$checkBasePath = $checkBase
|
||||
}
|
||||
|
||||
# --- Validate source file ---
|
||||
if (-not (Test-Path $SourceFile)) {
|
||||
Write-Host "Error: source file not found: $SourceFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Проверка исходников платформой: сломанный .epf до пользователя доезжать не должен ---
|
||||
if ($checkList.Count -gt 0 -and $checkBasePath) {
|
||||
# Проверку ведёт 1cv8, поэтому ibcmd-шные дополнительные аргументы ей не отдаём.
|
||||
$checkExtra = if ($engine -eq "ibcmd") { @() } else { $extraArgs }
|
||||
$found = Invoke-SourceCheck $V8Path $checkBasePath (Get-CheckFlags $checkList $contextList) $sourceDir $checkExtra
|
||||
if ($found) {
|
||||
if ($autoCreatedBase -and (Test-Path $autoCreatedBase)) { Remove-Item -Path $autoCreatedBase -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
if ($checkBase -and (Test-Path $checkBase)) { Remove-Item -Path $checkBase -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_build_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
$srcDir = Split-Path $SourceFile -Parent
|
||||
$arguments = @("infobase", "config", "import", "$srcDir", "--out=$OutputFile", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/LoadExternalDataProcessorOrReportFromFiles", "`"$SourceFile`"", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "build_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($autoCreatedBase -and (Test-Path $autoCreatedBase)) {
|
||||
Remove-Item -Path $autoCreatedBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($checkBase -and (Test-Path $checkBase)) {
|
||||
Remove-Item -Path $checkBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,769 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.17 — 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 io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def _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
|
||||
|
||||
|
||||
# --- Проверка исходников платформой ---
|
||||
# Сборка .epf/.erf ничего не проверяет: /LoadExternalDataProcessorOrReportFromFiles упаковывает XML
|
||||
# и модули не компилирует, поэтому сломанный модуль доезжает до пользователя и падает при открытии
|
||||
# обработки. Прямой команды «проверь внешнюю обработку» у платформы нет, но объект КОНФИГУРАЦИИ она
|
||||
# проверяет — поэтому обработка кладётся в конфигурацию временной базы (stub-db-create
|
||||
# -EmbedSourceFile) и спрашивается штатной /CheckConfig.
|
||||
#
|
||||
# Запуск отдельный и только через 1cv8: у ibcmd такой команды нет, а в одной командной строке
|
||||
# DESIGNER выполняет лишь последнюю пакетную команду.
|
||||
def get_check_flags(checks, contexts):
|
||||
flags = []
|
||||
if 'modules' in checks:
|
||||
for c in contexts:
|
||||
flags.append('-' + c)
|
||||
if 'handlers' in checks:
|
||||
flags.append('-HandlersExistence')
|
||||
if 'unreferenced' in checks:
|
||||
flags.append('-UnreferenceProcedures')
|
||||
if 'empty-handlers' in checks:
|
||||
flags.append('-EmptyHandlers')
|
||||
if 'config' in checks:
|
||||
flags += ['-ConfigLogIntegrity', '-IncorrectReferences']
|
||||
return flags
|
||||
|
||||
|
||||
# Платформа называет объект своим именем внутри конфигурации; модели нужен путь к исходнику.
|
||||
# Путь СКЛЕИВАЕТСЯ по конвенции выгрузки, поэтому возвращается только существующий файл:
|
||||
# выдуманный путь хуже отсутствующего — модель пойдёт открывать файл, которого нет.
|
||||
def resolve_source_path(line, source_dir):
|
||||
candidate = None
|
||||
m = re.search(r'(?:\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430|\u041e\u0442\u0447\u0435\u0442|DataProcessor|Report)\.([^.]+)\.(?:\u0424\u043e\u0440\u043c\u0430|Form)\.([^.]+)\.', line)
|
||||
if m:
|
||||
candidate = os.path.join(source_dir, m.group(1), 'Forms', m.group(2), 'Ext', 'Form', 'Module.bsl')
|
||||
if candidate is None:
|
||||
m = re.search(r'(?:\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430|\u041e\u0442\u0447\u0435\u0442|DataProcessor|Report)\.([^.]+)\.(?:\u041c\u043e\u0434\u0443\u043b\u044c\u041e\u0431\u044a\u0435\u043a\u0442\u0430|ObjectModule)', line)
|
||||
if m:
|
||||
candidate = os.path.join(source_dir, m.group(1), 'Ext', 'ObjectModule.bsl')
|
||||
if candidate is None:
|
||||
m = re.search(r'(?:\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430|\u041e\u0442\u0447\u0435\u0442|DataProcessor|Report)\.([^.]+)\.(?:\u041c\u043e\u0434\u0443\u043b\u044c\u041c\u0435\u043d\u0435\u0434\u0436\u0435\u0440\u0430|ManagerModule)', line)
|
||||
if m:
|
||||
candidate = os.path.join(source_dir, m.group(1), 'Ext', 'ManagerModule.bsl')
|
||||
if candidate and os.path.isfile(candidate):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
# True, если платформа нашла проблемы — вызывающий не собирает артефакт.
|
||||
def invoke_source_check(exe, base_path, flags, source_dir, extra_args):
|
||||
exe_dir = os.path.dirname(exe)
|
||||
exe_leaf = os.path.basename(exe)
|
||||
if exe_leaf.lower().startswith('ibcmd'):
|
||||
v8 = os.path.join(exe_dir, '1cv8' + os.path.splitext(exe)[1])
|
||||
else:
|
||||
v8 = exe
|
||||
if not os.path.exists(v8):
|
||||
print(f'[note] source check skipped: 1cv8 not found at {v8}')
|
||||
return False
|
||||
d = os.path.join(tempfile.gettempdir(), f'epf_check_{random.randint(0, 999999)}')
|
||||
os.makedirs(d, exist_ok=True)
|
||||
try:
|
||||
out_file = os.path.join(d, 'check_log.txt')
|
||||
a = (['DESIGNER', '/F', f'"{base_path}"', '/CheckConfig'] + flags
|
||||
+ ['/Out', f'"{out_file}"', '/DisableStartupDialogs']
|
||||
+ [quote_if_needed(x) for x in extra_args])
|
||||
print(f'Running: 1cv8.exe {" ".join(a)}')
|
||||
result = run_v8(v8, a)
|
||||
lines = []
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with io.open(out_file, encoding='utf-8-sig', errors='replace') as fh:
|
||||
raw = fh.read()
|
||||
lines = [l for l in raw.splitlines() if l.strip()]
|
||||
except Exception:
|
||||
lines = []
|
||||
# Платформа отвечает 101 на найденные проблемы; «Ошибок не обнаружено» приходит с кодом 0.
|
||||
if result.returncode == 0:
|
||||
return False
|
||||
print('Error: \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043d\u0430\u0448\u043b\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0432 \u0438\u0441\u0445\u043e\u0434\u043d\u0438\u043a\u0430\u0445 \u2014 \u0441\u0431\u043e\u0440\u043a\u0430 \u043e\u0442\u043c\u0435\u043d\u0435\u043d\u0430')
|
||||
# Пустой лог при ненулевом коде — отказ не по находкам (база занята, нет лицензии); молчать нельзя.
|
||||
if not lines:
|
||||
print(f' платформа вернула код {result.returncode} без сообщений')
|
||||
for l in lines:
|
||||
print(f' {l.rstrip()}')
|
||||
src_path = resolve_source_path(l, source_dir)
|
||||
if src_path:
|
||||
print(f' -> {src_path}')
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
# Проверять ли исходники: -Checks off сильнее настройки проекта externalCheck.
|
||||
def get_source_check_list(checks):
|
||||
known = ['modules', 'handlers', 'unreferenced', 'empty-handlers', 'config']
|
||||
if checks:
|
||||
lst = [c.strip().lower() for c in checks.split(',') if c.strip()]
|
||||
if 'off' in lst:
|
||||
return []
|
||||
for c in lst:
|
||||
if c not in known:
|
||||
print(f'Error: unknown check \'{c}\' (expected: {", ".join(known)} or off)')
|
||||
sys.exit(1)
|
||||
return lst
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if pf:
|
||||
try:
|
||||
with open(pf, encoding='utf-8-sig') as fh:
|
||||
proj = json.load(fh)
|
||||
if 'externalCheck' in proj and not proj['externalCheck']:
|
||||
return []
|
||||
except Exception:
|
||||
pass
|
||||
return ['modules', 'handlers']
|
||||
|
||||
|
||||
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")
|
||||
# Что проверить в исходниках перед сборкой: modules (синтаксис в контекстах), handlers,
|
||||
# unreferenced, empty-handlers, config; off — не проверять. По умолчанию modules,handlers.
|
||||
parser.add_argument("-Checks", default="")
|
||||
# Контексты синтаксической проверки. По умолчанию ThinClient,Server.
|
||||
parser.add_argument("-Context", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.SourceFile = clean_path(args.SourceFile, "-SourceFile")
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Что проверяем в исходниках перед сборкой ---
|
||||
check_list = get_source_check_list(args.Checks)
|
||||
context_list = [c.strip() for c in args.Context.split(',') if c.strip()] if args.Context else []
|
||||
if not context_list:
|
||||
context_list = ['ThinClient', 'Server']
|
||||
elif check_list and 'modules' not in check_list:
|
||||
print('Error: -Context задан, но в -Checks нет modules — контексты относятся только к ней')
|
||||
sys.exit(1)
|
||||
source_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
|
||||
def new_stub_base(base_path, embed):
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||
"-TempBasePath", base_path]
|
||||
if embed:
|
||||
stub_cmd += ["-EmbedSourceFile", args.SourceFile]
|
||||
if v8_extra:
|
||||
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
|
||||
if ibcmd_extra:
|
||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||
return subprocess.run(stub_cmd, capture_output=False).returncode
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
auto_created_base = None
|
||||
check_base = None
|
||||
check_base_path = None
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
||||
print("No database specified. Creating temporary stub database...")
|
||||
if new_stub_base(auto_base_path, bool(check_list)) != 0:
|
||||
# С внедрённой обработкой база падает прежде всего из-за самих исходников
|
||||
# (пример: DefaultForm на несуществующую форму) — говорить про базу значит увести не туда.
|
||||
if check_list:
|
||||
print('Error: платформа не приняла исходники при подготовке проверки — сборка отменена')
|
||||
print(' сообщение платформы выше; имя объекта в нём конфигурационное: DataProcessor/Report = проверяемая внешняя обработка/отчёт')
|
||||
else:
|
||||
print("Error: failed to create stub database")
|
||||
sys.exit(1)
|
||||
args.InfoBasePath = auto_base_path
|
||||
auto_created_base = auto_base_path
|
||||
if check_list:
|
||||
check_base_path = auto_base_path
|
||||
elif check_list:
|
||||
# Базу указали снаружи: класть проверяемую обработку в чужую конфигурацию нельзя, поэтому под
|
||||
# проверку поднимается своя временная база, а сборка идёт на указанной.
|
||||
check_base = os.path.join(tempfile.gettempdir(), f"epf_check_db_{random.randint(0, 999999)}")
|
||||
print("Creating temporary database for the source check...")
|
||||
if new_stub_base(check_base, True) != 0:
|
||||
print('Error: платформа не приняла исходники при подготовке проверки — сборка отменена')
|
||||
print(' сообщение платформы выше; имя объекта в нём конфигурационное: DataProcessor/Report = проверяемая внешняя обработка/отчёт')
|
||||
sys.exit(1)
|
||||
check_base_path = check_base
|
||||
|
||||
# --- Validate source file ---
|
||||
if not os.path.isfile(args.SourceFile):
|
||||
print(f"Error: source file not found: {args.SourceFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Проверка исходников платформой: сломанный .epf до пользователя доезжать не должен ---
|
||||
if check_list and check_base_path:
|
||||
# Проверку ведёт 1cv8, поэтому ibcmd-шные дополнительные аргументы ей не отдаём.
|
||||
check_extra = [] if engine == "ibcmd" else extra_args
|
||||
found = invoke_source_check(v8path, check_base_path,
|
||||
get_check_flags(check_list, context_list), source_dir, check_extra)
|
||||
if found:
|
||||
if auto_created_base and os.path.exists(auto_created_base):
|
||||
shutil.rmtree(auto_created_base, ignore_errors=True)
|
||||
if check_base and os.path.exists(check_base):
|
||||
shutil.rmtree(check_base, ignore_errors=True)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.exists(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_build_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
src_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
arguments = ["infobase", "config", "import", src_dir, f"--out={args.OutputFile}", f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Build completed successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||
else:
|
||||
print(f"Error building (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if auto_created_base and os.path.exists(auto_created_base):
|
||||
shutil.rmtree(auto_created_base, ignore_errors=True)
|
||||
if check_base and os.path.exists(check_base):
|
||||
shutil.rmtree(check_base, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,516 +0,0 @@
|
||||
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Разборка внешней обработки/отчёта 1С в XML-исходники
|
||||
|
||||
.DESCRIPTION
|
||||
Разбирает EPF/ERF-файл во XML-исходники с помощью платформы 1С.
|
||||
Общий скрипт для epf-dump и erf-dump.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к EPF/ERF-файлу
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Каталог для выгрузки исходников
|
||||
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
||||
#>
|
||||
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
$script:V8BatchKeys = @(
|
||||
'/CheckConfig', '/CheckModules', '/CheckCanApplyConfigurationExtensions',
|
||||
'/DumpDBCfgList', '/DeleteCfg', '/UpdateCfg', '/CompareCfg', '/MergeCfg',
|
||||
'/ManageCfgSupport', '/RollbackCfg', '/ConvertFiles'
|
||||
)
|
||||
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd') {
|
||||
foreach ($b in $script:V8BatchKeys) {
|
||||
if (Test-ArgKeyMatch $tok $b) {
|
||||
Write-Host "Error: $b is a batch command; passed via $paramName it would replace the skill's own operation (a command line runs only its last batch command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate database connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef" -ForegroundColor Red
|
||||
Write-Host "Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "epf_dump_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
$arguments = @("infobase", "config", "export", "--file=$InputFile", "$OutputDir", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpExternalDataProcessorOrReportToFiles", "`"$OutputDir`"", "`"$InputFile`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.15 — 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
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
|
||||
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
|
||||
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
|
||||
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
|
||||
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
|
||||
V8_BATCH_KEYS = [
|
||||
"/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
|
||||
"/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
|
||||
"/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
|
||||
]
|
||||
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd":
|
||||
for b in V8_BATCH_KEYS:
|
||||
if arg_key_match(tok, b):
|
||||
print(
|
||||
f"Error: {b} is a batch command; passed via {param} it would replace "
|
||||
f"the skill's own operation (a command line runs only its last batch command)",
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
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}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump external data processor or report (EPF/ERF) to XML sources",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
|
||||
parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
|
||||
parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-InputFile", required=True, help="Path to EPF/ERF file")
|
||||
parser.add_argument("-OutputDir", required=True, help="Directory for dumped XML sources")
|
||||
parser.add_argument(
|
||||
"-Format",
|
||||
default="Hierarchical",
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
args.OutputDir = clean_path(args.OutputDir, "-OutputDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate database connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
|
||||
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)")
|
||||
sys.exit(1)
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
if not os.path.exists(args.OutputDir):
|
||||
os.makedirs(args.OutputDir, exist_ok=True)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"epf_dump_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||
else:
|
||||
print(f"Error dumping (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: epf-init
|
||||
description: Создать пустую внешнюю обработку 1С (scaffold XML-исходников). Используй когда нужно создать новую внешнюю обработку с нуля
|
||||
argument-hint: <Name> [Synonym]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /epf-init — Создание новой обработки
|
||||
|
||||
Генерирует минимальный набор XML-исходников для внешней обработки 1С: корневой файл метаданных и каталог обработки.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|------------------------------------------------|
|
||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/epf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
- Добавить форму: `/form-add`
|
||||
- Добавить макет: `/template-add`
|
||||
- Добавить справку: `/help-add`
|
||||
- Собрать EPF: `/epf-build`
|
||||
@@ -1,148 +0,0 @@
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalDataProcessor uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
|
||||
<xr:ObjectId>$uuid2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:GeneratedType name="ExternalDataProcessorObject.$Name" category="Object">
|
||||
<xr:TypeId>$uuid3</xr:TypeId>
|
||||
<xr:ValueId>$uuid4</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<DefaultForm/>
|
||||
<AuxiliaryForm/>
|
||||
</Properties>
|
||||
<ChildObjects/>
|
||||
</ExternalDataProcessor>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
$rootFile = Join-Path $SrcDir "$Name.xml"
|
||||
$processorDir = Join-Path $SrcDir $Name
|
||||
|
||||
if (Test-Path $rootFile) {
|
||||
Write-Error "Файл уже существует: $rootFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SrcDir)) {
|
||||
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $processorDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
$moduleBsl = @"
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создана обработка: $rootFile"
|
||||
Write-Host " Каталог: $processorDir"
|
||||
Write-Host " Модуль: $modulePath"
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external data processor."""
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Init 1C external data processor scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
src_dir = args.SrcDir
|
||||
|
||||
uuid1 = new_uuid()
|
||||
uuid2 = new_uuid()
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>c3831ec8-d8d5-4f93-8a22-f9bfae07327f</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>
|
||||
\t\t\t<xr:GeneratedType name="ExternalDataProcessorObject.{name}" category="Object">
|
||||
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
|
||||
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<DefaultForm/>
|
||||
\t\t\t<AuxiliaryForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects/>
|
||||
\t</ExternalDataProcessor>
|
||||
</MetaDataObject>'''
|
||||
|
||||
root_file = os.path.join(src_dir, f"{name}.xml")
|
||||
processor_dir = os.path.join(src_dir, name)
|
||||
|
||||
if os.path.exists(root_file):
|
||||
print(f"Файл уже существует: {root_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(processor_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создана обработка: {root_file}")
|
||||
print(f" Каталог: {processor_dir}")
|
||||
print(f" Модуль: {module_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: erf-init
|
||||
description: Создать пустой внешний отчёт 1С (scaffold XML-исходников). Используй когда нужно создать новый внешний отчёт с нуля
|
||||
argument-hint: <Name> [Synonym] [--with-skd]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /erf-init — Создание нового отчёта
|
||||
|
||||
Генерирует минимальный набор XML-исходников для внешнего отчёта 1С: корневой файл метаданных и каталог отчёта.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|---------------------------------------|
|
||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/erf-init/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
- Добавить форму: `/form-add`
|
||||
- Добавить макет: `/template-add`
|
||||
- Добавить справку: `/help-add`
|
||||
- Собрать ERF: `/erf-build`
|
||||
@@ -1,238 +0,0 @@
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$WithSKD,
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
# --- Формируем Properties ---
|
||||
|
||||
$mainDCSValue = ""
|
||||
$childObjectsContent = ""
|
||||
|
||||
if ($WithSKD) {
|
||||
$mainDCSValue = "ExternalReport.$Name.Template.ОсновнаяСхемаКомпоновкиДанных"
|
||||
$childObjectsContent = @"
|
||||
|
||||
<Template>ОсновнаяСхемаКомпоновкиДанных</Template>
|
||||
|
||||
"@
|
||||
}
|
||||
|
||||
$mainDCSElement = if ($mainDCSValue) {
|
||||
"<MainDataCompositionSchema>$mainDCSValue</MainDataCompositionSchema>"
|
||||
} else {
|
||||
"<MainDataCompositionSchema/>"
|
||||
}
|
||||
|
||||
$childObjectsXml = if ($childObjectsContent) {
|
||||
"<ChildObjects>$childObjectsContent</ChildObjects>"
|
||||
} else {
|
||||
"<ChildObjects/>"
|
||||
}
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalReport uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
|
||||
<xr:ObjectId>$uuid2</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:GeneratedType name="ExternalReportObject.$Name" category="Object">
|
||||
<xr:TypeId>$uuid3</xr:TypeId>
|
||||
<xr:ValueId>$uuid4</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<DefaultForm/>
|
||||
<AuxiliaryForm/>
|
||||
$mainDCSElement
|
||||
<DefaultSettingsForm/>
|
||||
<AuxiliarySettingsForm/>
|
||||
<DefaultVariantForm/>
|
||||
<VariantsStorage/>
|
||||
<SettingsStorage/>
|
||||
</Properties>
|
||||
$childObjectsXml
|
||||
</ExternalReport>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
$rootFile = Join-Path $SrcDir "$Name.xml"
|
||||
$reportDir = Join-Path $SrcDir $Name
|
||||
|
||||
if (Test-Path $rootFile) {
|
||||
Write-Error "Файл уже существует: $rootFile"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SrcDir)) {
|
||||
New-Item -ItemType Directory -Path $SrcDir -Force | Out-Null
|
||||
}
|
||||
$extDir = Join-Path $reportDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
$moduleBsl = @"
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создан отчёт: $rootFile"
|
||||
Write-Host " Каталог: $reportDir"
|
||||
Write-Host " Модуль: $modulePath"
|
||||
|
||||
# --- СКД-макет (если --WithSKD) ---
|
||||
|
||||
if ($WithSKD) {
|
||||
$templatesDir = Join-Path $reportDir "Templates"
|
||||
$skdName = "ОсновнаяСхемаКомпоновкиДанных"
|
||||
$skdMetaPath = Join-Path $templatesDir "$skdName.xml"
|
||||
$skdExtDir = Join-Path (Join-Path $templatesDir $skdName) "Ext"
|
||||
New-Item -ItemType Directory -Path $skdExtDir -Force | Out-Null
|
||||
|
||||
$skdUuid = [guid]::NewGuid().ToString()
|
||||
|
||||
$skdMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<Template uuid="$skdUuid">
|
||||
<Properties>
|
||||
<Name>$skdName</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основная схема компоновки данных</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<TemplateType>DataCompositionSchema</TemplateType>
|
||||
</Properties>
|
||||
</Template>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||
|
||||
$skdContent = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
|
||||
xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
|
||||
xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
|
||||
xmlns:v8="http://v8.1c.ru/8.1/data/core"
|
||||
xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
|
||||
xmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
</DataCompositionSchema>
|
||||
"@
|
||||
|
||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||
Write-XmlFile $skdFilePath $skdContent $enc
|
||||
|
||||
Write-Host " СКД: $skdMetaPath"
|
||||
Write-Host " Тело: $skdFilePath"
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external report."""
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description='Init 1C external report scaffold', allow_abbrev=False)
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
src_dir = args.SrcDir
|
||||
|
||||
uuid1 = new_uuid()
|
||||
uuid2 = new_uuid()
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
# --- Properties ---
|
||||
main_dcs_value = ""
|
||||
child_objects_content = ""
|
||||
|
||||
if args.WithSKD:
|
||||
main_dcs_value = f"ExternalReport.{name}.Template.ОсновнаяСхемаКомпоновкиДанных"
|
||||
child_objects_content = f"\n\t\t\t<Template>ОсновнаяСхемаКомпоновкиДанных</Template>\n"
|
||||
|
||||
main_dcs_element = f"<MainDataCompositionSchema>{main_dcs_value}</MainDataCompositionSchema>" if main_dcs_value else "<MainDataCompositionSchema/>"
|
||||
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalReport uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>e41aff26-25cf-4bb6-b6c1-3f478a75f374</xr:ClassId>
|
||||
\t\t\t\t<xr:ObjectId>{uuid2}</xr:ObjectId>
|
||||
\t\t\t</xr:ContainedObject>
|
||||
\t\t\t<xr:GeneratedType name="ExternalReportObject.{name}" category="Object">
|
||||
\t\t\t\t<xr:TypeId>{uuid3}</xr:TypeId>
|
||||
\t\t\t\t<xr:ValueId>{uuid4}</xr:ValueId>
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<DefaultForm/>
|
||||
\t\t\t<AuxiliaryForm/>
|
||||
\t\t\t{main_dcs_element}
|
||||
\t\t\t<DefaultSettingsForm/>
|
||||
\t\t\t<AuxiliarySettingsForm/>
|
||||
\t\t\t<DefaultVariantForm/>
|
||||
\t\t\t<VariantsStorage/>
|
||||
\t\t\t<SettingsStorage/>
|
||||
\t\t</Properties>
|
||||
\t\t{child_objects_xml}
|
||||
\t</ExternalReport>
|
||||
</MetaDataObject>'''
|
||||
|
||||
root_file = os.path.join(src_dir, f"{name}.xml")
|
||||
report_dir = os.path.join(src_dir, name)
|
||||
|
||||
if os.path.exists(root_file):
|
||||
print(f"Файл уже существует: {root_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
os.makedirs(src_dir, exist_ok=True)
|
||||
ext_dir = os.path.join(report_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
#Область ОписаниеПеременных
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ПрограммныйИнтерфейс
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создан отчёт: {root_file}")
|
||||
print(f" Каталог: {report_dir}")
|
||||
print(f" Модуль: {module_path}")
|
||||
|
||||
# --- СКД-макет ---
|
||||
if args.WithSKD:
|
||||
templates_dir = os.path.join(report_dir, "Templates")
|
||||
skd_name = "ОсновнаяСхемаКомпоновкиДанных"
|
||||
skd_meta_path = os.path.join(templates_dir, f"{skd_name}.xml")
|
||||
skd_ext_dir = os.path.join(templates_dir, skd_name, "Ext")
|
||||
os.makedirs(skd_ext_dir, exist_ok=True)
|
||||
|
||||
skd_uuid = new_uuid()
|
||||
|
||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Template uuid="{skd_uuid}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{skd_name}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>Основная схема компоновки данных</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<TemplateType>DataCompositionSchema</TemplateType>
|
||||
\t\t</Properties>
|
||||
\t</Template>
|
||||
</MetaDataObject>'''
|
||||
|
||||
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||
|
||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
\t\txmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common"
|
||||
\t\txmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"
|
||||
\t\txmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"
|
||||
\t\txmlns:v8="http://v8.1c.ru/8.1/data/core"
|
||||
\t\txmlns:v8ui="http://v8.1c.ru/8.1/data/ui"
|
||||
\t\txmlns:xs="http://www.w3.org/2001/XMLSchema"
|
||||
\t\txmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
\t<dataSource>
|
||||
\t\t<name>ИсточникДанных1</name>
|
||||
\t\t<dataSourceType>Local</dataSourceType>
|
||||
\t</dataSource>
|
||||
</DataCompositionSchema>'''
|
||||
|
||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||
write_xml_file(skd_file_path, skd_content)
|
||||
|
||||
print(f" СКД: {skd_meta_path}")
|
||||
print(f" Тело: {skd_file_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: form-add
|
||||
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
|
||||
argument-hint: <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Glob
|
||||
- Grep
|
||||
---
|
||||
|
||||
# /form-add — Добавление формы к объекту конфигурации
|
||||
|
||||
Создаёт управляемую форму (metadata XML + Form.xml + Module.bsl) и регистрирует её в корневом XML объекта конфигурации (Document, Catalog, InformationRegister и др.).
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/form-add <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-------------|:------------:|--------------|----------------------------------------------|
|
||||
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
|
||||
| FormName | да | — | Имя формы (ФормаДокумента) |
|
||||
| Purpose | нет | основная форма вида | Назначение формы — см. таблицу ниже: у справочника это форма объекта, у регистра сведений — форма записи, у журнала — форма списка |
|
||||
| Synonym | нет | = FormName | Синоним формы |
|
||||
| -SetDefault | нет | авто | Сделать основной. Без флага основной становится первая форма каждого назначения |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/form-add/scripts/form-add.ps1" -ObjectPath "<ObjectPath>" -FormName "<FormName>" [-Purpose "<Purpose>"] [-Synonym "<Synonym>"] [-SetDefault]
|
||||
```
|
||||
|
||||
## Purpose — назначение формы
|
||||
|
||||
| Purpose | Какая форма | Становится основной |
|
||||
|---------|-------------|---------------------|
|
||||
| Object | форма объекта (элемента, документа, обработки) | да |
|
||||
| List | форма списка | да |
|
||||
| Choice | форма выбора | да |
|
||||
| Folder | форма группы | да |
|
||||
| FolderChoice | форма выбора группы | да |
|
||||
| Record | форма записи | да |
|
||||
| RecordSet | форма набора записей | нет — в платформе нет такого свойства |
|
||||
| Save | форма сохранения настроек | да |
|
||||
| Load | форма загрузки настроек | да |
|
||||
| Custom | произвольная форма, без привязки к объекту | нет |
|
||||
|
||||
### Что доступно типу объекта
|
||||
|
||||
| Тип объекта | Назначения |
|
||||
|-------------|------------|
|
||||
| Catalog, ChartOfCharacteristicTypes | Object, Folder, List, Choice, FolderChoice, Custom |
|
||||
| Document, ChartOfAccounts, ChartOfCalculationTypes, ExchangePlan, BusinessProcess, Task | Object, List, Choice, Custom |
|
||||
| DataProcessor, Report, ExternalDataProcessor, ExternalReport | Object, Custom |
|
||||
| InformationRegister | Record, List, RecordSet, Custom |
|
||||
| AccumulationRegister, AccountingRegister, CalculationRegister | List, RecordSet, Custom |
|
||||
| DocumentJournal, FilterCriterion | List, Custom |
|
||||
| Enum | List, Choice, Custom |
|
||||
| SettingsStorage | Save, Load, Custom |
|
||||
| Table (таблица внешнего источника) | `tableDataType=ObjectData` — Object, List, Choice, Custom; `NonobjectData` — Record, List, Choice, Custom |
|
||||
|
||||
Недопустимое сочетание отклоняется со списком доступных для этого типа. У константы собственных
|
||||
форм нет — для неё используется общая форма (`CommonForm`).
|
||||
|
||||
Таблица внешнего источника адресуется файлом таблицы:
|
||||
`ExternalDataSources/<Источник>/Tables/<Таблица>.xml`. Ссылки в такой форме трёхчастные;
|
||||
без `-Purpose` берётся форма объекта, а у таблицы с составным ключом — форма записи.
|
||||
|
||||
## Примеры
|
||||
|
||||
```
|
||||
# Форма документа
|
||||
/form-add Documents/АвансовыйОтчет.xml ФормаДокумента -Purpose Object
|
||||
|
||||
# Форма списка каталога
|
||||
/form-add Catalogs/Контрагенты.xml ФормаСписка -Purpose List
|
||||
|
||||
# Форма записи регистра сведений
|
||||
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи -Purpose Record
|
||||
|
||||
# Форма выбора с синонимом
|
||||
/form-add Catalogs/Номенклатура.xml ФормаВыбора -Purpose Choice -Synonym "Выбор номенклатуры"
|
||||
|
||||
# Установить как форму по умолчанию
|
||||
/form-add Documents/Заказ.xml ФормаДокументаНовая -Purpose Object -SetDefault
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. `/form-add` — создать каркас формы
|
||||
2. `/form-compile` или `/form-edit` — наполнить Form.xml элементами
|
||||
3. `/form-validate` — проверить корректность
|
||||
4. `/form-info` — проанализировать результат
|
||||
@@ -1,843 +0,0 @@
|
||||
# form-add v1.29 — Add managed form to 1C config object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ObjectPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FormName,
|
||||
|
||||
[string]$Synonym = $FormName,
|
||||
|
||||
# Пусто = основная форма вида (Primary в таблице): у справочника это форма объекта,
|
||||
# у регистра сведений — форма записи, у журнала — форма списка. Жёсткое "Object"
|
||||
# по умолчанию было бы неверным для видов, у которых формы объекта не бывает.
|
||||
[string]$Purpose = "",
|
||||
|
||||
# Алиас с дефисом внутри имени: вызов вида --set-default PowerShell разбирает как имя
|
||||
# параметра "set-default" и без алиаса отвечает отказом биндинга. Написания -SetDefault,
|
||||
# --SetDefault и --setdefault совпадают с именем параметра и так.
|
||||
[Alias('set-default')]
|
||||
[switch]$SetDefault
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
# --- Detect XML format version ---
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Фаза 1: Определение типа объекта ---
|
||||
|
||||
# Resolve ObjectPath (directory → .xml)
|
||||
if (-not [System.IO.Path]::IsPathRooted($ObjectPath)) {
|
||||
$ObjectPath = Join-Path (Get-Location).Path $ObjectPath
|
||||
}
|
||||
if (Test-Path $ObjectPath -PathType Container) {
|
||||
$dirName = Split-Path $ObjectPath -Leaf
|
||||
$candidate = Join-Path $ObjectPath "$dirName.xml"
|
||||
$sibling = Join-Path (Split-Path $ObjectPath) "$dirName.xml"
|
||||
if (Test-Path $candidate) { $ObjectPath = $candidate }
|
||||
elseif (Test-Path $sibling) { $ObjectPath = $sibling }
|
||||
}
|
||||
|
||||
if (-not (Test-Path $ObjectPath)) {
|
||||
Write-Error "Файл объекта не найден: $ObjectPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$objectXmlFull = Resolve-Path $ObjectPath
|
||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
$script:formatVersion = $null
|
||||
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
|
||||
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
|
||||
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
|
||||
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.Load($objectXmlFull.Path)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||
|
||||
# Определяем тип объекта по корневому тегу внутри MetaDataObject
|
||||
$metaDataObject = $xmlDoc.SelectSingleNode("//md:MetaDataObject", $nsMgr)
|
||||
if (-not $metaDataObject) {
|
||||
# Пробуем без namespace (fallback)
|
||||
$metaDataObject = $xmlDoc.DocumentElement
|
||||
}
|
||||
|
||||
# --- Таблица видов: вид → допустимые назначения ---
|
||||
#
|
||||
# Одна запись на вид вместо разрозненных списков «поддерживаемые типы», «объектные типы»,
|
||||
# «обработко-подобные» и «карта типов реквизита». Раньше они расходились молча: DocumentJournal
|
||||
# был среди поддерживаемых, но не в карте типов, и в форму уходило `cfg:.Журнал` — платформа
|
||||
# такую выгрузку не принимает, а навык рапортовал успех.
|
||||
#
|
||||
# MainAttr — тип главного реквизита; `{0}` подставляется именем объекта:
|
||||
# "DynamicList" — динамический список (добавляется Settings/MainTable);
|
||||
# $null — произвольная форма, блока Attributes нет вовсе.
|
||||
# Slot — свойство объекта под «основную форму»; $null — такого свойства у вида нет.
|
||||
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
|
||||
|
||||
$formKinds = @{
|
||||
"Catalog" = @{
|
||||
"Object" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"Folder" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfCharacteristicTypes" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"Folder" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Document" = @{
|
||||
"Object" = @{ MainAttr = "DocumentObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfAccounts" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfAccountsObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfCalculationTypes" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfCalculationTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExchangePlan" = @{
|
||||
"Object" = @{ MainAttr = "ExchangePlanObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"BusinessProcess" = @{
|
||||
"Object" = @{ MainAttr = "BusinessProcessObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Task" = @{
|
||||
"Object" = @{ MainAttr = "TaskObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"DataProcessor" = @{
|
||||
"Object" = @{ MainAttr = "DataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Report" = @{
|
||||
"Object" = @{ MainAttr = "ReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExternalDataProcessor" = @{
|
||||
"Object" = @{ MainAttr = "ExternalDataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExternalReport" = @{
|
||||
"Object" = @{ MainAttr = "ExternalReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"InformationRegister" = @{
|
||||
"Record" = @{ MainAttr = "InformationRegisterRecordManager.{1}"; AttrName = "Запись"; Slot = "DefaultRecordForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"RecordSet" = @{ MainAttr = "InformationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"AccumulationRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "AccumulationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"AccountingRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "AccountingRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"CalculationRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "CalculationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"DocumentJournal" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"FilterCriterion" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Enum" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"SettingsStorage" = @{
|
||||
"Save" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultSaveForm"; Primary = $true }
|
||||
"Load" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultLoadForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
# Таблица внешнего источника — единственный вид, чьё имя в ссылках трёхчастное
|
||||
# (Источник.Таблица): подставляется {2}, а не {1}.
|
||||
"Table" = @{
|
||||
"Object" = @{ MainAttr = "ExternalDataSourceTableObject.{2}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"Record" = @{ MainAttr = "ExternalDataSourceTableRecordManager.{2}"; AttrName = "Запись"; Slot = "DefaultRecordForm"; SavedData = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает — отказ с причиной,
|
||||
# а не «тип не поддерживается».
|
||||
$noOwnForms = @{
|
||||
"Constant" = "у константы нет собственных форм — используйте общую форму (CommonForm)"
|
||||
}
|
||||
|
||||
$supportedTypes = @($formKinds.Keys) + @($noOwnForms.Keys)
|
||||
|
||||
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в метаданных
|
||||
# формы есть <ExtendedPresentation>.
|
||||
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
|
||||
|
||||
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему документу
|
||||
# имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса есть свойство
|
||||
# <Task>, и он определялся как задача, после чего имя объекта не находилось вовсе.
|
||||
$objectType = $null
|
||||
$objectNode = $null
|
||||
foreach ($child in $metaDataObject.ChildNodes) {
|
||||
if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||
$objectType = $child.LocalName
|
||||
$objectNode = $child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($objectType -and -not ($formKinds.ContainsKey($objectType) -or $noOwnForms.ContainsKey($objectType))) {
|
||||
Write-Error "Тип объекта '$objectType' не поддерживается. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $objectType) {
|
||||
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($noOwnForms.ContainsKey($objectType)) {
|
||||
Write-Error "$objectType не поддерживается: $($noOwnForms[$objectType])"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Имя объекта из Properties/Name
|
||||
$objectName = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:Name", $nsMgr).InnerText
|
||||
if (-not $objectName) {
|
||||
Write-Error "Не удалось определить имя объекта из Properties/Name"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== form-add ==="
|
||||
Write-Host ""
|
||||
# Ссылка на объект и имя для типов формы. У всех видов это "Вид.Имя", и только
|
||||
# у таблицы внешнего источника — "ExternalDataSource.<Источник>.Table.<Таблица>", а в именах
|
||||
# типов — "<Источник>.<Таблица>". Имя источника в самом файле таблицы не хранится —
|
||||
# единственное место, где навык смотрит на путь: ExternalDataSources/<Источник>/Tables/<Таблица>.xml
|
||||
$objectQualifiedName = $objectName
|
||||
$objectRef = "$objectType.$objectName"
|
||||
if ($objectType -eq "Table") {
|
||||
$tablesDir = Split-Path -Parent $objectXmlFull.Path
|
||||
$edsSource = Split-Path -Leaf (Split-Path -Parent $tablesDir)
|
||||
if (-not $edsSource -or (Split-Path -Leaf $tablesDir) -ne "Tables") {
|
||||
Write-Error "Таблица внешнего источника ожидается по пути ExternalDataSources/<Источник>/Tables/<Таблица>.xml, а не '$($objectXmlFull.Path)'"
|
||||
exit 1
|
||||
}
|
||||
$objectQualifiedName = "$edsSource.$objectName"
|
||||
$objectRef = "ExternalDataSource.$edsSource.Table.$objectName"
|
||||
$tdtNode = $xmlDoc.SelectSingleNode("//md:Table/md:Properties/md:TableDataType", $nsMgr)
|
||||
$tableDataType = if ($tdtNode) { $tdtNode.InnerText.Trim() } else { "ObjectData" }
|
||||
}
|
||||
|
||||
Write-Host "Object: $objectType.$objectName"
|
||||
|
||||
# --- Фаза 2: Валидация Purpose ---
|
||||
|
||||
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell (в py-порту .lower()).
|
||||
$kindPurposes = $formKinds[$objectType]
|
||||
|
||||
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||
$purposeSynonyms = @{
|
||||
"формаобъекта"="Object"; "формаэлемента"="Object"; "формадокумента"="Object"
|
||||
"объект"="Object"; "элемент"="Object"; "документ"="Object"; "objectform"="Object"
|
||||
"формасписка"="List"; "список"="List"; "listform"="List"
|
||||
"формавыбора"="Choice"; "выбор"="Choice"; "choiceform"="Choice"
|
||||
"формагруппы"="Folder"; "группа"="Folder"; "folderform"="Folder"
|
||||
"формавыборагруппы"="FolderChoice"; "выборгруппы"="FolderChoice"; "folderchoiceform"="FolderChoice"
|
||||
"формазаписи"="Record"; "запись"="Record"; "recordform"="Record"
|
||||
"форманаборазаписей"="RecordSet"; "наборзаписей"="RecordSet"; "recordsetform"="RecordSet"
|
||||
"формасохранения"="Save"; "формасохранениянастроек"="Save"; "сохранение"="Save"; "saveform"="Save"
|
||||
"формазагрузки"="Load"; "формазагрузкинастроек"="Load"; "загрузка"="Load"; "loadform"="Load"
|
||||
"произвольная"="Custom"; "произвольнаяформа"="Custom"; "customform"="Custom"
|
||||
}
|
||||
if ($Purpose) {
|
||||
$purposeProbe = ($Purpose -replace '[\s_-]', '').ToLowerInvariant()
|
||||
$isKnownPurpose = $false
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $isKnownPurpose = $true; break }
|
||||
}
|
||||
if (-not $isKnownPurpose -and $purposeSynonyms.ContainsKey($purposeProbe)) {
|
||||
$Purpose = $purposeSynonyms[$purposeProbe]
|
||||
}
|
||||
}
|
||||
if (-not $Purpose) {
|
||||
if ($objectType -eq "Table" -and $tableDataType -eq "NonobjectData") {
|
||||
# Пометка Primary в таблице видов одна на вид, а у таблицы с составным ключом
|
||||
# формы объекта не бывает — основной становится форма записи.
|
||||
$Purpose = "Record"
|
||||
} else {
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($kindPurposes[$p].Primary) { $Purpose = $p; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
$purposeKey = $null
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $purposeKey = $p; break }
|
||||
}
|
||||
if (-not $purposeKey) {
|
||||
Write-Error "Назначение '$Purpose' недопустимо для $objectType. Допустимые: $(($kindPurposes.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
$Purpose = $purposeKey
|
||||
$purposeRule = $kindPurposes[$Purpose]
|
||||
|
||||
# У таблицы внешнего источника набор назначений зависит от вида данных (замерено на
|
||||
# 8.3.24.1691): ObjectData — Object/List/Choice, NonobjectData — Record/List/Choice. Неверная пара
|
||||
# не отвергается схемой формы, а валит загрузку всей конфигурации «Исключением XDTO» без причины.
|
||||
if ($objectType -eq "Table") {
|
||||
if ($tableDataType -eq "NonobjectData" -and $Purpose -eq "Object") {
|
||||
Write-Error "Таблица '$objectName' с составным ключом (TableDataType=NonobjectData): формы объекта у неё нет — используйте -Purpose Record."
|
||||
exit 1
|
||||
}
|
||||
if ($tableDataType -ne "NonobjectData" -and $Purpose -eq "Record") {
|
||||
Write-Error "Таблица '$objectName' с ключом из одного поля (TableDataType=ObjectData): формы записи у неё нет — используйте -Purpose Object."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой MainAttr — это
|
||||
# произвольная форма (законное состояние), а вот наполовину заполненная запись означала бы, что
|
||||
# таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||
if ($purposeRule.MainAttr -and -not $purposeRule.AttrName) {
|
||||
Write-Error "Внутренняя ошибка таблицы видов: у $objectType/$Purpose задан MainAttr без AttrName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Фаза 3: Создание файлов ---
|
||||
|
||||
$objectDir = [System.IO.Path]::ChangeExtension($objectXmlFull.Path, $null).TrimEnd('.')
|
||||
$formsDir = Join-Path $objectDir "Forms"
|
||||
$formMetaPath = Join-Path $formsDir "$FormName.xml"
|
||||
|
||||
if (Test-Path $formMetaPath) {
|
||||
Write-Error "Форма уже существует: $formMetaPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$formDir = Join-Path $formsDir $FormName
|
||||
$formExtDir = Join-Path $formDir "Ext"
|
||||
$formModuleDir = Join-Path $formExtDir "Form"
|
||||
|
||||
New-Item -ItemType Directory -Path $formModuleDir -Force | Out-Null
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# --- 3a. Метаданные формы ---
|
||||
|
||||
$formUuid = [guid]::NewGuid().ToString()
|
||||
|
||||
# ExtendedPresentation — only for DataProcessor, Report, ExternalDataProcessor, ExternalReport forms
|
||||
$extPresentationLine = ""
|
||||
if ($objectType -in $processorLikeTypes) {
|
||||
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
||||
}
|
||||
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
$useInIfcLine = ""
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
|
||||
}
|
||||
|
||||
$formMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||
<Form uuid="$formUuid">
|
||||
<Properties>
|
||||
<Name>$FormName</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$Synonym</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<FormType>Managed</FormType>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||
</Properties>
|
||||
</Form>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $formMetaPath $formMetaXml $encBom
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||
|
||||
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||
$attributesBlock = ""
|
||||
if ($purposeRule.MainAttr) {
|
||||
$mainAttrType = $purposeRule.MainAttr -f $objectType, $objectName, $objectQualifiedName
|
||||
$mainAttrName = $purposeRule.AttrName
|
||||
|
||||
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||
$tailLines = ""
|
||||
if ($mainAttrType -eq "DynamicList") {
|
||||
$mainTable = $objectRef
|
||||
$tailLines = "`n`t`t`t<Settings xsi:type=""DynamicList"">`n`t`t`t`t<MainTable>$mainTable</MainTable>`n`t`t`t</Settings>"
|
||||
} elseif ($purposeRule.SavedData) {
|
||||
$tailLines = "`n`t`t`t<SavedData>true</SavedData>"
|
||||
}
|
||||
|
||||
$attributesBlock = @"
|
||||
|
||||
<Attributes>
|
||||
<Attribute name="$mainAttrName" id="1">
|
||||
<Type>
|
||||
<v8:Type>cfg:$mainAttrType</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>$tailLines
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
"@
|
||||
}
|
||||
|
||||
# Произвольная форма (MainAttr = $null) — без блока Attributes вовсе. В типовых это самая
|
||||
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
<ChildItems/>$attributesBlock
|
||||
</Form>
|
||||
"@
|
||||
|
||||
if (Test-Path $formXmlPath) {
|
||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||
} else {
|
||||
Write-XmlFile $formXmlPath $formXml $encBom
|
||||
}
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
|
||||
$modulePath = Join-Path $formModuleDir "Module.bsl"
|
||||
|
||||
$moduleBsl = @"
|
||||
#Область ОбработчикиСобытийФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиСобытийЭлементовФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиКомандФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиОповещений
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
"@
|
||||
|
||||
if (Test-Path $modulePath) {
|
||||
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
||||
} else {
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
||||
}
|
||||
|
||||
# --- Фаза 4: Регистрация в родительском объекте ---
|
||||
|
||||
$childObjects = $xmlDoc.SelectSingleNode("//md:${objectType}/md:ChildObjects", $nsMgr)
|
||||
if (-not $childObjects) {
|
||||
Write-Error "Не найден элемент ChildObjects в $ObjectPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Добавить <Form>$FormName</Form> — идемпотентно (не дублировать уже зарегистрированную)
|
||||
$alreadyRegistered = [bool]$childObjects.SelectSingleNode("md:Form[text()='$FormName']", $nsMgr)
|
||||
|
||||
if (-not $alreadyRegistered) {
|
||||
$formElem = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$formElem.InnerText = $FormName
|
||||
|
||||
# Ищем первый <Template> для вставки перед ним
|
||||
$firstTemplate = $childObjects.SelectSingleNode("md:Template", $nsMgr)
|
||||
# Ищем первую <TabularSection> для вставки перед ней (если нет Template)
|
||||
$firstTabular = $childObjects.SelectSingleNode("md:TabularSection", $nsMgr)
|
||||
|
||||
# Определяем точку вставки: перед Template, перед TabularSection, или в конец
|
||||
$insertBefore = $null
|
||||
if ($firstTemplate) {
|
||||
$insertBefore = $firstTemplate
|
||||
} elseif ($firstTabular) {
|
||||
$insertBefore = $firstTabular
|
||||
}
|
||||
|
||||
if ($insertBefore) {
|
||||
# Вставить перед найденным элементом, с переносом строки
|
||||
$whitespace = $xmlDoc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
|
||||
$childObjects.InsertBefore($whitespace, $formElem) | Out-Null
|
||||
# Переставляем: whitespace перед formElem — неправильный порядок
|
||||
# Правильно: formElem, затем whitespace перед insertBefore
|
||||
# InsertBefore возвращает вставленный узел, порядок: ... formElem whitespace insertBefore ...
|
||||
# На самом деле нам нужно: ... \n\t\t\tformElem \n\t\t\tinsertBefore
|
||||
# Удалим и вставим правильно
|
||||
$childObjects.RemoveChild($whitespace) | Out-Null
|
||||
$childObjects.RemoveChild($formElem) | Out-Null
|
||||
|
||||
$childObjects.InsertBefore($formElem, $insertBefore) | Out-Null
|
||||
# Whitespace нужен ДО formElem (перенос строки + отступ)
|
||||
# Но перед insertBefore уже должен быть whitespace от предыдущего элемента
|
||||
# Нам нужно добавить whitespace ПОСЛЕ formElem (перед insertBefore)
|
||||
$ws = $xmlDoc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertBefore($ws, $insertBefore) | Out-Null
|
||||
} else {
|
||||
# Добавить в конец ChildObjects
|
||||
if ($childObjects.ChildNodes.Count -eq 0) {
|
||||
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
|
||||
$childObjects.AppendChild($formElem) | Out-Null
|
||||
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
|
||||
} else {
|
||||
$lastChild = $childObjects.LastChild
|
||||
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$childObjects.InsertBefore($xmlDoc.CreateWhitespace("`n`t`t`t"), $lastChild) | Out-Null
|
||||
$childObjects.InsertBefore($formElem, $lastChild) | Out-Null
|
||||
} else {
|
||||
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
|
||||
$childObjects.AppendChild($formElem) | Out-Null
|
||||
$childObjects.AppendChild($xmlDoc.CreateWhitespace("`n`t`t")) | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
$existingForms = $childObjects.SelectNodes("md:Form", $nsMgr)
|
||||
$isFirstFormForPurpose = $false
|
||||
$defaultPropName = $null
|
||||
$defaultValue = "$objectRef.Form.$FormName"
|
||||
|
||||
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному Purpose без учёта
|
||||
# вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не находился, навык
|
||||
# молча ничего не делал.
|
||||
$defaultPropName = $purposeRule.Slot
|
||||
|
||||
$defaultNode = $null
|
||||
if ($defaultPropName) {
|
||||
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
|
||||
if ($defaultNode) {
|
||||
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
|
||||
}
|
||||
}
|
||||
|
||||
$defaultUpdated = $false
|
||||
if ($SetDefault -or $isFirstFormForPurpose) {
|
||||
if ($defaultNode) {
|
||||
$defaultNode.InnerText = $defaultValue
|
||||
$defaultUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
# Сохранить с BOM
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
|
||||
|
||||
# --- Фаза 5: Вывод ---
|
||||
|
||||
# Относительные пути для вывода
|
||||
$basePath = Split-Path $objectXmlFull.Path -Parent
|
||||
# Определяем корень (ищем родительский каталог типа Documents, Catalogs и т.д.)
|
||||
$relFormMeta = $formMetaPath.Replace($basePath, "").TrimStart("\", "/")
|
||||
$relFormXml = $formXmlPath.Replace($basePath, "").TrimStart("\", "/")
|
||||
$relModule = $modulePath.Replace($basePath, "").TrimStart("\", "/")
|
||||
|
||||
$objFileName = [System.IO.Path]::GetFileName($ObjectPath)
|
||||
$objDirName = Split-Path $ObjectPath -Parent
|
||||
$objBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
|
||||
|
||||
Write-Host "Created:"
|
||||
Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
|
||||
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
|
||||
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
|
||||
Write-Host ""
|
||||
if ($alreadyRegistered) {
|
||||
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
|
||||
} else {
|
||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||
}
|
||||
if ($defaultUpdated) {
|
||||
Write-Host "${defaultPropName}: $defaultValue"
|
||||
} elseif (-not $defaultPropName) {
|
||||
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||
# у платформы нет (форма набора записей, произвольная форма).
|
||||
Write-Host "Основной не назначена: у $objectType нет свойства для формы с назначением $Purpose"
|
||||
}
|
||||
Write-Host ""
|
||||
@@ -1,955 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-add v1.29 — Add managed form to 1C config object (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
NSMAP = {
|
||||
"md": "http://v8.1c.ru/8.3/MDClasses",
|
||||
"v8": "http://v8.1c.ru/8.1/data/core",
|
||||
}
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
|
||||
Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Add managed form to 1C config object", allow_abbrev=False)
|
||||
parser.add_argument("-ObjectPath", required=True)
|
||||
parser.add_argument("-FormName", required=True)
|
||||
parser.add_argument("-Synonym", default=None)
|
||||
# Пусто = основная форма вида (primary в таблице): у справочника это форма объекта,
|
||||
# у регистра сведений — форма записи, у журнала — форма списка.
|
||||
parser.add_argument("-Purpose", default="")
|
||||
# Написания с дефисом внутри имени и с двойным дефисом: в PS-порте их принимает алиас
|
||||
# set-default, здесь — перечисление опций, чтобы порты принимали ровно одно и то же.
|
||||
parser.add_argument("-SetDefault", "--SetDefault", "--set-default", "-set-default",
|
||||
dest="SetDefault", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_path = args.ObjectPath
|
||||
form_name = args.FormName
|
||||
synonym = args.Synonym if args.Synonym is not None else form_name
|
||||
purpose = args.Purpose
|
||||
set_default = args.SetDefault
|
||||
|
||||
# --- Phase 1: Determine object type ---
|
||||
|
||||
# Resolve ObjectPath (directory → .xml)
|
||||
if not os.path.isabs(object_path):
|
||||
object_path = os.path.join(os.getcwd(), object_path)
|
||||
if os.path.isdir(object_path):
|
||||
dir_name = os.path.basename(object_path.rstrip("/\\"))
|
||||
candidate = os.path.join(object_path, dir_name + ".xml")
|
||||
sibling = os.path.join(os.path.dirname(object_path.rstrip("/\\")), dir_name + ".xml")
|
||||
if os.path.isfile(candidate):
|
||||
object_path = candidate
|
||||
elif os.path.isfile(sibling):
|
||||
object_path = sibling
|
||||
if not os.path.isfile(object_path):
|
||||
print(f"Файл объекта не найден: {object_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
object_xml_full = os.path.abspath(object_path)
|
||||
assert_edit_allowed(object_xml_full, "editable")
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
format_version = None
|
||||
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
|
||||
obj_head = f.read(2000)
|
||||
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
|
||||
if m_ver:
|
||||
format_version = m_ver.group(1)
|
||||
if not format_version:
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
|
||||
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
|
||||
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(object_xml_full, parser_xml)
|
||||
root = tree.getroot()
|
||||
|
||||
# --- Таблица видов: вид -> допустимые назначения ---
|
||||
#
|
||||
# Зеркало $formKinds из PS-порта. Одна запись на вид вместо разрозненных списков
|
||||
# «поддерживаемые типы», «объектные типы», «обработко-подобные» и «карта типов реквизита»:
|
||||
# раньше они расходились молча, и для DocumentJournal в форму уходило `cfg:.Журнал`.
|
||||
#
|
||||
# main_attr — тип главного реквизита, {0} = вид, {1} = имя объекта;
|
||||
# "DynamicList" — динамический список (добавляется Settings/MainTable);
|
||||
# None — произвольная форма, блока Attributes нет вовсе.
|
||||
# slot — свойство объекта под «основную форму»; None — такого свойства у вида нет.
|
||||
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
|
||||
|
||||
form_kinds = {
|
||||
"Catalog": {
|
||||
"Object": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"Folder": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultFolderForm", "saved_data": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfCharacteristicTypes": {
|
||||
"Object": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"Folder": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultFolderForm", "saved_data": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Document": {
|
||||
"Object": {"main_attr": "DocumentObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfAccounts": {
|
||||
"Object": {"main_attr": "ChartOfAccountsObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfCalculationTypes": {
|
||||
"Object": {"main_attr": "ChartOfCalculationTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExchangePlan": {
|
||||
"Object": {"main_attr": "ExchangePlanObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"BusinessProcess": {
|
||||
"Object": {"main_attr": "BusinessProcessObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Task": {
|
||||
"Object": {"main_attr": "TaskObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"DataProcessor": {
|
||||
"Object": {"main_attr": "DataProcessorObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Report": {
|
||||
"Object": {"main_attr": "ReportObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExternalDataProcessor": {
|
||||
"Object": {"main_attr": "ExternalDataProcessorObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExternalReport": {
|
||||
"Object": {"main_attr": "ExternalReportObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"InformationRegister": {
|
||||
"Record": {"main_attr": "InformationRegisterRecordManager.{1}", "attr_name": "Запись",
|
||||
"slot": "DefaultRecordForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"RecordSet": {"main_attr": "InformationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"AccumulationRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "AccumulationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"AccountingRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "AccountingRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"CalculationRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "CalculationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"DocumentJournal": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"FilterCriterion": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Enum": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"SettingsStorage": {
|
||||
"Save": {"main_attr": None, "attr_name": None, "slot": "DefaultSaveForm", "primary": True},
|
||||
"Load": {"main_attr": None, "attr_name": None, "slot": "DefaultLoadForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
# Таблица внешнего источника — единственный вид, чьё имя в ссылках трёхчастное
|
||||
# (Источник.Таблица): подставляется {2}, а не {1}.
|
||||
"Table": {
|
||||
"Object": {"main_attr": "ExternalDataSourceTableObject.{2}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"Record": {"main_attr": "ExternalDataSourceTableRecordManager.{2}", "attr_name": "Запись",
|
||||
"slot": "DefaultRecordForm", "saved_data": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
}
|
||||
|
||||
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает.
|
||||
no_own_forms = {
|
||||
"Constant": "у константы нет собственных форм — используйте общую форму (CommonForm)",
|
||||
}
|
||||
|
||||
supported_types = list(form_kinds) + list(no_own_forms)
|
||||
|
||||
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в
|
||||
# метаданных формы есть <ExtendedPresentation>.
|
||||
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
|
||||
|
||||
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему
|
||||
# документу имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса
|
||||
# есть свойство <Task>, и он определялся как задача, после чего имя объекта не находилось.
|
||||
object_type = None
|
||||
object_node = None
|
||||
for child in root:
|
||||
if isinstance(child.tag, str):
|
||||
object_type = etree.QName(child).localname
|
||||
object_node = child
|
||||
break
|
||||
|
||||
if object_type is not None and object_type not in form_kinds and object_type not in no_own_forms:
|
||||
print(f"Тип объекта '{object_type}' не поддерживается. "
|
||||
f"Поддерживаемые типы: {', '.join(sorted(form_kinds))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if object_type is None:
|
||||
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(sorted(form_kinds))}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if object_type in no_own_forms:
|
||||
print(f"{object_type} не поддерживается: {no_own_forms[object_type]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Object name from Properties/Name
|
||||
name_node = root.find(f".//md:{object_type}/md:Properties/md:Name", NSMAP)
|
||||
if name_node is None or not name_node.text:
|
||||
print("Не удалось определить имя объекта из Properties/Name", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
object_name = name_node.text
|
||||
|
||||
# Ссылка на объект и имя для типов формы. У всех видов это "Вид.Имя", и только
|
||||
# у таблицы внешнего источника — "ExternalDataSource.<Источник>.Table.<Таблица>", а в именах
|
||||
# типов — "<Источник>.<Таблица>". Имя источника в самом файле таблицы не хранится —
|
||||
# единственное место, где навык смотрит на путь: ExternalDataSources/<Источник>/Tables/<Таблица>.xml
|
||||
object_qualified_name = object_name
|
||||
object_ref = f"{object_type}.{object_name}"
|
||||
table_data_type = "ObjectData"
|
||||
if object_type == "Table":
|
||||
tables_dir = os.path.dirname(object_xml_full)
|
||||
eds_source = os.path.basename(os.path.dirname(tables_dir))
|
||||
if not eds_source or os.path.basename(tables_dir) != "Tables":
|
||||
print("Таблица внешнего источника ожидается по пути "
|
||||
f"ExternalDataSources/<Источник>/Tables/<Таблица>.xml, а не '{object_xml_full}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
object_qualified_name = f"{eds_source}.{object_name}"
|
||||
object_ref = f"ExternalDataSource.{eds_source}.Table.{object_name}"
|
||||
tdt_node = root.find(".//md:Table/md:Properties/md:TableDataType", NSMAP)
|
||||
if tdt_node is not None and tdt_node.text:
|
||||
table_data_type = tdt_node.text.strip()
|
||||
|
||||
print()
|
||||
print("=== form-add ===")
|
||||
print()
|
||||
print(f"Object: {object_type}.{object_name}")
|
||||
|
||||
# --- Phase 2: Validate Purpose ---
|
||||
|
||||
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell.
|
||||
kind_purposes = form_kinds[object_type]
|
||||
|
||||
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||
purpose_synonyms = {
|
||||
"формаобъекта": "Object", "формаэлемента": "Object", "формадокумента": "Object",
|
||||
"объект": "Object", "элемент": "Object", "документ": "Object", "objectform": "Object",
|
||||
"формасписка": "List", "список": "List", "listform": "List",
|
||||
"формавыбора": "Choice", "выбор": "Choice", "choiceform": "Choice",
|
||||
"формагруппы": "Folder", "группа": "Folder", "folderform": "Folder",
|
||||
"формавыборагруппы": "FolderChoice", "выборгруппы": "FolderChoice",
|
||||
"folderchoiceform": "FolderChoice",
|
||||
"формазаписи": "Record", "запись": "Record", "recordform": "Record",
|
||||
"форманаборазаписей": "RecordSet", "наборзаписей": "RecordSet", "recordsetform": "RecordSet",
|
||||
"формасохранения": "Save", "формасохранениянастроек": "Save", "сохранение": "Save",
|
||||
"saveform": "Save",
|
||||
"формазагрузки": "Load", "формазагрузкинастроек": "Load", "загрузка": "Load",
|
||||
"loadform": "Load",
|
||||
"произвольная": "Custom", "произвольнаяформа": "Custom", "customform": "Custom",
|
||||
}
|
||||
if purpose:
|
||||
purpose_probe = re.sub(r"[\s_-]", "", purpose).lower()
|
||||
is_known_purpose = any(k.lower() == purpose.lower() for k in kind_purposes)
|
||||
if not is_known_purpose and purpose_probe in purpose_synonyms:
|
||||
purpose = purpose_synonyms[purpose_probe]
|
||||
|
||||
if not purpose:
|
||||
if object_type == "Table" and table_data_type == "NonobjectData":
|
||||
# Пометка primary в таблице видов одна на вид, а у таблицы с составным ключом
|
||||
# формы объекта не бывает — основной становится форма записи.
|
||||
purpose = "Record"
|
||||
else:
|
||||
for k, rule in kind_purposes.items():
|
||||
if rule.get("primary"):
|
||||
purpose = k
|
||||
break
|
||||
purpose_key = None
|
||||
for k in kind_purposes:
|
||||
if k.lower() == purpose.lower():
|
||||
purpose_key = k
|
||||
break
|
||||
if purpose_key is None:
|
||||
print(f"Назначение '{purpose}' недопустимо для {object_type}. "
|
||||
f"Допустимые: {', '.join(sorted(kind_purposes))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
purpose = purpose_key
|
||||
purpose_rule = kind_purposes[purpose]
|
||||
|
||||
# У таблицы внешнего источника набор назначений зависит от вида данных (замерено на
|
||||
# 8.3.24.1691): ObjectData — Object/List/Choice, NonobjectData — Record/List/Choice. Неверная пара
|
||||
# не отвергается схемой формы, а валит загрузку всей конфигурации «Исключением XDTO» без причины.
|
||||
if object_type == "Table":
|
||||
if table_data_type == "NonobjectData" and purpose == "Object":
|
||||
print(f"Таблица '{object_name}' с составным ключом (TableDataType=NonobjectData): "
|
||||
"формы объекта у неё нет — используйте -Purpose Record.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if table_data_type != "NonobjectData" and purpose == "Record":
|
||||
print(f"Таблица '{object_name}' с ключом из одного поля (TableDataType=ObjectData): "
|
||||
"формы записи у неё нет — используйте -Purpose Object.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой main_attr —
|
||||
# это произвольная форма (законное состояние), а наполовину заполненная запись означала бы,
|
||||
# что таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||
if purpose_rule.get("main_attr") and not purpose_rule.get("attr_name"):
|
||||
print(f"Внутренняя ошибка таблицы видов: у {object_type}/{purpose} задан main_attr без attr_name",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Phase 3: Create files ---
|
||||
|
||||
object_dir = os.path.splitext(object_xml_full)[0]
|
||||
forms_dir = os.path.join(object_dir, "Forms")
|
||||
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
|
||||
|
||||
if os.path.exists(form_meta_path):
|
||||
print(f"Форма уже существует: {form_meta_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
form_dir = os.path.join(forms_dir, form_name)
|
||||
form_ext_dir = os.path.join(form_dir, "Ext")
|
||||
form_module_dir = os.path.join(form_ext_dir, "Form")
|
||||
|
||||
os.makedirs(form_module_dir, exist_ok=True)
|
||||
|
||||
# --- 3a. Form metadata ---
|
||||
|
||||
form_uuid = str(uuid.uuid4())
|
||||
|
||||
form_meta_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
|
||||
f'\t<Form uuid="{form_uuid}">\n'
|
||||
'\t\t<Properties>\n'
|
||||
f'\t\t\t<Name>{form_name}</Name>\n'
|
||||
'\t\t\t<Synonym>\n'
|
||||
'\t\t\t\t<v8:item>\n'
|
||||
'\t\t\t\t\t<v8:lang>ru</v8:lang>\n'
|
||||
f'\t\t\t\t\t<v8:content>{synonym}</v8:content>\n'
|
||||
'\t\t\t\t</v8:item>\n'
|
||||
'\t\t\t</Synonym>\n'
|
||||
'\t\t\t<Comment/>\n'
|
||||
'\t\t\t<FormType>Managed</FormType>\n'
|
||||
'\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>\n'
|
||||
'\t\t\t<UsePurposes>\n'
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
||||
'\t\t\t</UsePurposes>\n'
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
|
||||
if format_rank(format_version) >= 221 else '')
|
||||
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
||||
+ '\t\t</Properties>\n'
|
||||
'\t</Form>\n'
|
||||
'</MetaDataObject>'
|
||||
)
|
||||
|
||||
write_xml_file(form_meta_path, form_meta_xml)
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||
|
||||
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||
attributes_block = ''
|
||||
if purpose_rule.get("main_attr"):
|
||||
main_attr_type = purpose_rule["main_attr"].format(object_type, object_name, object_qualified_name)
|
||||
main_attr_name = purpose_rule["attr_name"]
|
||||
|
||||
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||
tail_lines = ''
|
||||
if main_attr_type == "DynamicList":
|
||||
main_table = object_ref
|
||||
tail_lines = ('\t\t\t<Settings xsi:type="DynamicList">\n'
|
||||
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
|
||||
'\t\t\t</Settings>\n')
|
||||
elif purpose_rule.get("saved_data"):
|
||||
tail_lines = '\t\t\t<SavedData>true</SavedData>\n'
|
||||
|
||||
attributes_block = (
|
||||
'\t<Attributes>\n'
|
||||
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
|
||||
'\t\t\t<Type>\n'
|
||||
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
|
||||
'\t\t\t</Type>\n'
|
||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
||||
f'{tail_lines}'
|
||||
'\t\t</Attribute>\n'
|
||||
'\t</Attributes>\n'
|
||||
)
|
||||
|
||||
# Произвольная форма (main_attr=None) — без блока Attributes вовсе. В типовых это самая
|
||||
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
|
||||
form_xml = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
|
||||
'\t\t<Autofill>true</Autofill>\n'
|
||||
'\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n'
|
||||
f'{attributes_block}'
|
||||
'</Form>'
|
||||
)
|
||||
|
||||
if os.path.exists(form_xml_path):
|
||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||
else:
|
||||
write_xml_file(form_xml_path, form_xml)
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
|
||||
module_path = os.path.join(form_module_dir, "Module.bsl")
|
||||
|
||||
module_bsl = (
|
||||
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u0424\u043e\u0440\u043c\u044b\n'
|
||||
'\n'
|
||||
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
|
||||
'\n'
|
||||
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u0421\u043e\u0431\u044b\u0442\u0438\u0439\u042d\u043b\u0435\u043c\u0435\u043d\u0442\u043e\u0432\u0424\u043e\u0440\u043c\u044b\n'
|
||||
'\n'
|
||||
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
|
||||
'\n'
|
||||
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041a\u043e\u043c\u0430\u043d\u0434\u0424\u043e\u0440\u043c\u044b\n'
|
||||
'\n'
|
||||
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
|
||||
'\n'
|
||||
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u041e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0438\u041e\u043f\u043e\u0432\u0435\u0449\u0435\u043d\u0438\u0439\n'
|
||||
'\n'
|
||||
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438\n'
|
||||
'\n'
|
||||
'#\u041e\u0431\u043b\u0430\u0441\u0442\u044c \u0421\u043b\u0443\u0436\u0435\u0431\u043d\u044b\u0435\u041f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\u0418\u0424\u0443\u043d\u043a\u0446\u0438\u0438\n'
|
||||
'\n'
|
||||
'#\u041a\u043e\u043d\u0435\u0446\u041e\u0431\u043b\u0430\u0441\u0442\u0438'
|
||||
)
|
||||
|
||||
if os.path.exists(module_path):
|
||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||
else:
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
# --- Phase 4: Register in parent object ---
|
||||
|
||||
ns = "http://v8.1c.ru/8.3/MDClasses"
|
||||
child_objects = root.find(f".//md:{object_type}/md:ChildObjects", NSMAP)
|
||||
if child_objects is None:
|
||||
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Add <Form>$FormName</Form> — idempotent (do not duplicate already-registered form)
|
||||
already_registered = child_objects.find(f"md:Form[.='{form_name}']", NSMAP) is not None
|
||||
|
||||
if not already_registered:
|
||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||
form_elem.text = form_name
|
||||
|
||||
# Find first <Template> to insert before it
|
||||
first_template = child_objects.find("md:Template", NSMAP)
|
||||
# Find first <TabularSection> to insert before it (if no Template)
|
||||
first_tabular = child_objects.find("md:TabularSection", NSMAP)
|
||||
|
||||
# Determine insertion point: before Template, before TabularSection, or at end
|
||||
insert_before = None
|
||||
if first_template is not None:
|
||||
insert_before = first_template
|
||||
elif first_tabular is not None:
|
||||
insert_before = first_tabular
|
||||
|
||||
if insert_before is not None:
|
||||
# Insert before the found element
|
||||
idx = list(child_objects).index(insert_before)
|
||||
child_objects.insert(idx, form_elem)
|
||||
# Whitespace: form_elem gets "\n\t\t\t" as tail (indent before insert_before)
|
||||
form_elem.tail = "\n\t\t\t"
|
||||
else:
|
||||
# Add to end of ChildObjects
|
||||
children = list(child_objects)
|
||||
if len(children) == 0 and (child_objects.text is None or child_objects.text.strip() == ""):
|
||||
# Empty ChildObjects (self-closing)
|
||||
child_objects.text = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = "\n\t\t"
|
||||
else:
|
||||
if len(children) > 0:
|
||||
last_child = children[-1]
|
||||
old_tail = last_child.tail
|
||||
last_child.tail = "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = old_tail if old_tail else "\n\t\t"
|
||||
else:
|
||||
child_objects.text = (child_objects.text or "") + "\n\t\t\t"
|
||||
child_objects.append(form_elem)
|
||||
form_elem.tail = "\n\t\t"
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
is_first_form_for_purpose = False
|
||||
default_value = f"{object_ref}.Form.{form_name}"
|
||||
|
||||
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному purpose без
|
||||
# учёта вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не
|
||||
# находился, навык молча ничего не делал.
|
||||
default_prop_name = purpose_rule.get("slot")
|
||||
|
||||
default_node = None
|
||||
if default_prop_name:
|
||||
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
|
||||
if default_node is not None:
|
||||
is_first_form_for_purpose = not (default_node.text or "").strip()
|
||||
|
||||
default_updated = False
|
||||
if set_default or is_first_form_for_purpose:
|
||||
if default_node is not None:
|
||||
default_node.text = default_value
|
||||
default_updated = True
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, object_xml_full)
|
||||
|
||||
# --- Phase 5: Output ---
|
||||
|
||||
obj_dir_name = os.path.dirname(object_path)
|
||||
obj_base_name = os.path.splitext(os.path.basename(object_path))[0]
|
||||
|
||||
print("Created:")
|
||||
print(f" Metadata: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}.xml")
|
||||
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
|
||||
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
|
||||
print()
|
||||
if already_registered:
|
||||
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
|
||||
else:
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
if default_updated:
|
||||
print(f"{default_prop_name}: {default_value}")
|
||||
elif not default_prop_name:
|
||||
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||
# у платформы нет (форма набора записей, произвольная форма).
|
||||
print(f"Основной не назначена: у {object_type} нет свойства для формы с назначением {purpose}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,126 +0,0 @@
|
||||
# Form Presets
|
||||
|
||||
Пресеты управляют раскладкой форм, генерируемых в режиме `--from-object`.
|
||||
|
||||
## Как работает
|
||||
|
||||
Цепочка merge (каждый следующий уровень перезаписывает предыдущий через deep merge):
|
||||
|
||||
1. **Hardcoded defaults** -- встроены в скрипт, ориентированы на ERP
|
||||
2. **Built-in preset** -- файл из этой папки (`erp-standard.json` по умолчанию)
|
||||
3. **Project-level preset** -- файл `presets/skills/form/<name>.json`, поиск вверх от OutputPath
|
||||
|
||||
Имя пресета задаётся параметром `--preset` (по умолчанию `erp-standard`).
|
||||
|
||||
## Project-level пресет
|
||||
|
||||
Чтобы переопределить стандартный пресет в своём проекте, создайте файл:
|
||||
|
||||
```
|
||||
<project-root>/presets/skills/form/erp-standard.json
|
||||
```
|
||||
|
||||
Скрипт ищет этот файл, поднимаясь от OutputPath к корню. Первый найденный файл применяется поверх built-in через deep merge -- не нужно копировать весь пресет, достаточно указать только переопределяемые ключи.
|
||||
|
||||
## Секции
|
||||
|
||||
Ключи верхнего уровня в JSON -- секции вида `{тип}.{назначение}`:
|
||||
|
||||
| Секция | Тип объекта | Назначение формы |
|
||||
|--------|-------------|------------------|
|
||||
| `document.item` | Document | Форма документа |
|
||||
| `document.list` | Document | Форма списка |
|
||||
| `document.choice` | Document | Форма выбора |
|
||||
| `catalog.item` | Catalog | Форма элемента |
|
||||
| `catalog.folder` | Catalog | Форма группы |
|
||||
| `catalog.list` | Catalog | Форма списка |
|
||||
| `catalog.choice` | Catalog | Форма выбора |
|
||||
| `informationRegister.record` | InformationRegister | Форма записи |
|
||||
| `informationRegister.list` | InformationRegister | Форма списка |
|
||||
| `accumulationRegister.list` | AccumulationRegister | Форма списка |
|
||||
| `chartOfCharacteristicTypes.*` | ChartOfCharacteristicTypes | item/folder/list/choice |
|
||||
| `exchangePlan.*` | ExchangePlan | item/list/choice |
|
||||
| `chartOfAccounts.*` | ChartOfAccounts | item/folder/list/choice |
|
||||
|
||||
### basedOn
|
||||
|
||||
Секция может наследовать от другой:
|
||||
|
||||
```json
|
||||
{
|
||||
"document.choice": {
|
||||
"basedOn": "document.list",
|
||||
"properties": { "windowOpeningMode": "LockOwnerWindow" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Ключи секций
|
||||
|
||||
### Форма объекта (Item/Record)
|
||||
|
||||
| Ключ | Описание | Допустимые значения |
|
||||
|------|----------|---------------------|
|
||||
| `header.position` | Где размещать шапку | `"insidePage"` -- на первой странице, `"abovePages"` -- над страницами |
|
||||
| `header.layout` | Колонки шапки | `"1col"`, `"2col"` |
|
||||
| `header.distribute` | Распределение в 2 колонках | `"even"`, `"left"`, `"right"` |
|
||||
| `header.dateTitle` | Заголовок даты (Document) | строка, напр. `"от"` |
|
||||
| `footer.fields` | Поля в подвале | массив имён реквизитов, напр. `["Комментарий"]` |
|
||||
| `footer.position` | Где размещать подвал | `"insidePage"`, `"belowPages"`, `"none"` |
|
||||
| `tabularSections.container` | Контейнер табчастей | `"pages"` -- на вкладках, `"inline"` -- в корне, `"single-no-pages"` -- одна ТЧ без страниц |
|
||||
| `tabularSections.exclude` | Исключить табчасти | массив имён, напр. `["ДополнительныеРеквизиты"]` |
|
||||
| `tabularSections.lineNumber` | Колонка НомерСтроки | `true` / `false` |
|
||||
| `additional.position` | Блок доп. реквизитов | `"page"` -- отдельная вкладка, `"below"` -- под табчастями, `"none"` -- не создавать |
|
||||
| `additional.layout` | Колонки доп. блока | `"1col"`, `"2col"` |
|
||||
| `additional.bspGroup` | Группа ДополнительныеРеквизиты | `true` / `false` |
|
||||
| `codeDescription.layout` | Код + Наименование | `"horizontal"`, `"vertical"` |
|
||||
| `codeDescription.order` | Порядок Код/Наименование | `"descriptionFirst"`, `"codeFirst"` |
|
||||
| `parent.title` | Заголовок поля Родитель | строка, напр. `"Входит в группу"` |
|
||||
| `parent.position` | Позиция поля Родитель | `"beforeCodeDescription"`, `"afterCodeDescription"`, `"inHeader"` |
|
||||
| `owner.readOnly` | Владелец только для чтения | `true` / `false` |
|
||||
| `owner.position` | Позиция поля Владелец | `"first"` |
|
||||
| `fieldDefaults.ref.choiceButton` | Кнопка выбора для ссылок | `true` / `false` |
|
||||
| `fieldDefaults.boolean.element` | Элемент для Boolean | `"check"` (флажок) |
|
||||
| `commandBar` | Командная панель формы | `"auto"`, `"none"` |
|
||||
| `properties` | Свойства формы | объект: `autoTitle`, `windowOpeningMode` и др. |
|
||||
|
||||
### Форма списка (List/Choice)
|
||||
|
||||
| Ключ | Описание | Допустимые значения |
|
||||
|------|----------|---------------------|
|
||||
| `columns` | Какие колонки показывать | `"all"` -- все реквизиты, или массив имён |
|
||||
| `columnType` | Тип элемента колонки | `"labelField"`, `"input"` |
|
||||
| `hiddenRef` | Скрытая колонка Ref | `true` / `false` |
|
||||
| `tableCommandBar` | Командная панель таблицы | `"auto"`, `"none"` |
|
||||
| `commandBar` | Командная панель формы | `"auto"`, `"none"` |
|
||||
| `choiceMode` | Режим выбора (ChoiceForm) | `true` / `false` |
|
||||
| `properties` | Свойства формы | объект: `windowOpeningMode` и др. |
|
||||
|
||||
## Пример project-level пресета
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-project",
|
||||
"description": "Стиль форм нашего проекта",
|
||||
|
||||
"document.item": {
|
||||
"header": {
|
||||
"layout": "1col"
|
||||
},
|
||||
"tabularSections": {
|
||||
"exclude": ["ДополнительныеРеквизиты", "СведенияОСертификатах"]
|
||||
},
|
||||
"additional": {
|
||||
"position": "none"
|
||||
}
|
||||
},
|
||||
|
||||
"catalog.item": {
|
||||
"codeDescription": {
|
||||
"order": "codeFirst"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Этот файл переопределяет только указанные ключи -- остальное наследуется из built-in пресета.
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"name": "erp-standard",
|
||||
"description": "ERP 8.3.24 standard form layout",
|
||||
|
||||
"document.item": {
|
||||
"header": {
|
||||
"position": "insidePage",
|
||||
"layout": "2col",
|
||||
"distribute": "even",
|
||||
"dateTitle": "от"
|
||||
},
|
||||
"footer": {
|
||||
"fields": ["Комментарий"],
|
||||
"position": "insidePage"
|
||||
},
|
||||
"tabularSections": {
|
||||
"container": "pages",
|
||||
"exclude": ["ДополнительныеРеквизиты"],
|
||||
"lineNumber": true
|
||||
},
|
||||
"additional": {
|
||||
"position": "page",
|
||||
"layout": "2col",
|
||||
"bspGroup": true
|
||||
},
|
||||
"properties": {
|
||||
"autoTitle": false
|
||||
}
|
||||
},
|
||||
|
||||
"catalog.item": {
|
||||
"codeDescription": {
|
||||
"layout": "horizontal",
|
||||
"order": "descriptionFirst"
|
||||
},
|
||||
"parent": {
|
||||
"title": "Входит в группу",
|
||||
"position": "afterCodeDescription"
|
||||
},
|
||||
"tabularSections": {
|
||||
"exclude": ["ДополнительныеРеквизиты", "Представления"]
|
||||
}
|
||||
},
|
||||
|
||||
"informationRegister.record": {
|
||||
"properties": {
|
||||
"windowOpeningMode": "LockOwnerWindow"
|
||||
}
|
||||
},
|
||||
|
||||
"informationRegister.list": {},
|
||||
|
||||
"accumulationRegister.list": {},
|
||||
|
||||
"chartOfCharacteristicTypes.item": {
|
||||
"basedOn": "catalog.item"
|
||||
},
|
||||
|
||||
"exchangePlan.item": {
|
||||
"basedOn": "catalog.item"
|
||||
},
|
||||
|
||||
"chartOfAccounts.item": {
|
||||
"parent": {
|
||||
"title": "Подчинен счету"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
powershell.exe -NoProfile -File ".agents/skills/form-decompile/scripts/form-decompile.ps1" -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,255 +0,0 @@
|
||||
# form-remove v1.10 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias("ProcessorName")]
|
||||
[string]$ObjectName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FormName,
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Проверки ---
|
||||
|
||||
$rootXmlPath = Join-Path $SrcDir "$ObjectName.xml"
|
||||
if (-not (Test-Path $rootXmlPath)) {
|
||||
Write-Error "Корневой файл обработки не найден: $rootXmlPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$processorDir = Join-Path $SrcDir $ObjectName
|
||||
$formsDir = Join-Path $processorDir "Forms"
|
||||
$formMetaPath = Join-Path $formsDir "$FormName.xml"
|
||||
$formDir = Join-Path $formsDir $FormName
|
||||
|
||||
if (-not (Test-Path $formMetaPath)) {
|
||||
Write-Error "Метаданные формы не найдены: $formMetaPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Загрузка корневого XML: вид и имя объекта ---
|
||||
|
||||
$rootXmlFull = Resolve-Path $rootXmlPath
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.Load($rootXmlFull.Path)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
$typeNode = $null
|
||||
foreach ($c in $xmlDoc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) { $typeNode = $c; break }
|
||||
}
|
||||
if (-not $typeNode) {
|
||||
Write-Error "Не удалось определить вид объекта в $rootXmlPath"
|
||||
exit 1
|
||||
}
|
||||
$mdType = $typeNode.LocalName
|
||||
$nameNode = $typeNode.SelectSingleNode("md:Properties/md:Name", $nsMgr)
|
||||
$objMetaName = if ($nameNode -and $nameNode.InnerText.Trim()) { $nameNode.InnerText.Trim() } else { [System.IO.Path]::GetFileNameWithoutExtension($rootXmlPath) }
|
||||
|
||||
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||
$formRef = "$mdType.$objMetaName.Form.$FormName"
|
||||
|
||||
# --- Чистка ссылок и сохранение в стиле файла-источника ---
|
||||
|
||||
# Каноничное «не задано» зависит от файла: в корневом XML объекта и в Configuration.xml
|
||||
# пустой слот штатен (164 508 пустых на корпус), а внутри Ext/Form.xml пустых <ChoiceForm/>
|
||||
# и <SettingsStorage/> нет ни одного — там свойство просто отсутствует.
|
||||
function Clear-FormRefs {
|
||||
param([System.Xml.XmlDocument]$doc, [string]$ref)
|
||||
|
||||
$isFormFile = $doc.DocumentElement -and $doc.DocumentElement.LocalName -eq "Form"
|
||||
$touched = @()
|
||||
foreach ($node in @($doc.SelectNodes("//*"))) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
if ($node.SelectNodes("*").Count -gt 0) { continue } # только листья
|
||||
# Сравнение регистронезависимое — как у платформы (в py-порту .lower()).
|
||||
if ($node.InnerText.Trim() -ne $ref) { continue }
|
||||
|
||||
$ln = $node.LocalName
|
||||
$parent = $node.ParentNode
|
||||
if ($ln -eq "Form" -and $parent -and $parent.LocalName -eq "Item") {
|
||||
$touched += "$($parent.LocalName)/$ln"
|
||||
Remove-NodeWithIndent $parent
|
||||
} elseif ($isFormFile) {
|
||||
$touched += $ln
|
||||
Remove-NodeWithIndent $node
|
||||
} else {
|
||||
# IsEmpty, а не InnerText="": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
$touched += $ln
|
||||
$node.IsEmpty = $true
|
||||
}
|
||||
}
|
||||
return $touched
|
||||
}
|
||||
|
||||
function Remove-NodeWithIndent {
|
||||
param([System.Xml.XmlNode]$node)
|
||||
$parent = $node.ParentNode
|
||||
if (-not $parent) { return }
|
||||
$prev = $node.PreviousSibling
|
||||
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$parent.RemoveChild($prev) | Out-Null
|
||||
}
|
||||
$parent.RemoveChild($node) | Out-Null
|
||||
# Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару
|
||||
# <ChildObjects>\n\t\t</ChildObjects>. Платформа пишет только <ChildObjects/>
|
||||
# (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0).
|
||||
if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true }
|
||||
}
|
||||
|
||||
function Save-XmlPreservingStyle {
|
||||
param([System.Xml.XmlDocument]$doc, [string]$path)
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$doc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $path) -and ([System.IO.File]::ReadAllText($path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($path, $xmlText, $encBom)
|
||||
}
|
||||
|
||||
# --- Поиск ссылок на форму по всей конфигурации ---
|
||||
|
||||
# Get-Item, а не Resolve-Path: последний оставляет короткое имя 8.3 (NSHIRO~1), а
|
||||
# Get-ChildItem отдаёт длинное (nshirokov) — сравнение путей молча не совпадало.
|
||||
function Get-LongPath {
|
||||
param([string]$path)
|
||||
if (-not (Test-Path -LiteralPath $path)) { return "" }
|
||||
return (Get-Item -LiteralPath $path -Force).FullName
|
||||
}
|
||||
|
||||
# Корень конфигурации: обычно это сам SrcDir, но объект могут передать и из глубины.
|
||||
$configDir = $null
|
||||
$probe = Get-LongPath $SrcDir
|
||||
for ($depth = 0; $depth -lt 4; $depth++) {
|
||||
if (-not $probe) { break }
|
||||
if (Test-Path (Join-Path $probe "Configuration.xml")) { $configDir = $probe; break }
|
||||
$probe = Split-Path $probe
|
||||
}
|
||||
|
||||
$rootXmlLong = Get-LongPath $rootXmlFull.Path
|
||||
$formMetaFull = Get-LongPath $formMetaPath
|
||||
$formDirFull = Get-LongPath $formDir
|
||||
|
||||
$references = @()
|
||||
if ($configDir) {
|
||||
# Полный обход, как в meta-remove: ссылки лежат и внутри Ext/Form.xml (ChoiceForm,
|
||||
# SettingsStorage), узкий скан по корневым XML их не видит.
|
||||
# EnumerateFiles, а не Get-ChildItem -Recurse: на ERP (73 904 XML) обход обёртками
|
||||
# занимает 180 с против 47 с — чтение файлов не узкое место, узкое место перечисление.
|
||||
$refPattern = '<([A-Za-z0-9_.]+)>' + [regex]::Escape($formRef) + '</'
|
||||
$scanSw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$scanned = 0
|
||||
foreach ($fp in [System.IO.Directory]::EnumerateFiles($configDir, "*.xml", [System.IO.SearchOption]::AllDirectories)) {
|
||||
if ($fp -eq $rootXmlLong) { continue } # свой файл чистится всегда
|
||||
if ($fp -eq $formMetaFull) { continue } # файлы удаляемой формы
|
||||
if ($formDirFull -and $fp.StartsWith($formDirFull)) { continue }
|
||||
$scanned++
|
||||
$content = [System.IO.File]::ReadAllText($fp, [System.Text.Encoding]::UTF8)
|
||||
if (-not $content.Contains($formRef)) { continue }
|
||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||
$references += @{ Path = $fp; Rel = $fp.Substring($configDir.Length + 1); Tag = $m.Groups[1].Value }
|
||||
}
|
||||
}
|
||||
$scanSw.Stop()
|
||||
if ($scanSw.Elapsed.TotalSeconds -ge 5) {
|
||||
Write-Host "[INFO] Проверено ссылок в $scanned файлах за $([math]::Round($scanSw.Elapsed.TotalSeconds, 1)) c"
|
||||
}
|
||||
}
|
||||
|
||||
if ($references.Count -gt 0) {
|
||||
Write-Host "[WARN] На форму $formRef ссылаются $($references.Count) раз(а):"
|
||||
foreach ($grp in ($references | Group-Object { "$($_.Rel)|$($_.Tag)" } | Sort-Object Name)) {
|
||||
$parts = $grp.Name.Split("|")
|
||||
$suffix = if ($grp.Count -gt 1) { " x$($grp.Count)" } else { "" }
|
||||
Write-Host " $($parts[0]) — <$($parts[1])>$suffix"
|
||||
}
|
||||
Write-Host ""
|
||||
if (-not $Force) {
|
||||
Write-Host "[ERROR] Удаление остановлено: форма используется."
|
||||
Write-Host " Решает пользователь: убрать ссылки, отказаться от удаления или"
|
||||
Write-Host " повторить с -Force — тогда ссылки будут очищены."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[WARN] -Force: ссылки будут очищены"
|
||||
Write-Host ""
|
||||
} elseif (-not $configDir) {
|
||||
Write-Host "[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены"
|
||||
}
|
||||
|
||||
# --- Удаление файлов ---
|
||||
|
||||
if (Test-Path $formDir) {
|
||||
Remove-Item -Path $formDir -Recurse -Force
|
||||
Write-Host "[OK] Удалён каталог: $formDir"
|
||||
}
|
||||
|
||||
Remove-Item -Path $formMetaPath -Force
|
||||
Write-Host "[OK] Удалён файл: $formMetaPath"
|
||||
|
||||
# --- Модификация корневого XML ---
|
||||
|
||||
# Удалить <Form>FormName</Form> из ChildObjects
|
||||
$formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr)
|
||||
foreach ($node in $formNodes) {
|
||||
if ($node.InnerText -eq $FormName) {
|
||||
Remove-NodeWithIndent $node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Очистить слоты своего объекта, указывавшие на удалённую форму: Default*/Auxiliary*Form
|
||||
# (form-add пишет свойство по назначению) и ChoiceForm у реквизитов.
|
||||
Clear-FormRefs $xmlDoc $formRef | Out-Null
|
||||
|
||||
Save-XmlPreservingStyle $xmlDoc $rootXmlFull.Path
|
||||
|
||||
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
||||
|
||||
# --- Чистка ссылок в других файлах (только с -Force) ---
|
||||
|
||||
if ($references.Count -gt 0) {
|
||||
foreach ($grp in ($references | Group-Object { $_.Path } | Sort-Object Name)) {
|
||||
$path = $grp.Name
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $true
|
||||
$doc.Load($path)
|
||||
$touched = @(Clear-FormRefs $doc $formRef)
|
||||
if ($touched.Count -eq 0) { continue }
|
||||
Save-XmlPreservingStyle $doc $path
|
||||
$rel = $path.Substring($configDir.Length + 1)
|
||||
Write-Host "[OK] Очищена ссылка в $rel — $(($touched | Sort-Object -Unique) -join ', ')"
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-remove v1.10 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def long_path(path):
|
||||
"""Полный путь в длинной форме. Зеркало Get-LongPath в PS: там Resolve-Path оставляет
|
||||
короткое имя 8.3 (NSHIRO~1), а перечисление отдаёт длинное — сравнение молча не совпадало."""
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
return os.path.realpath(path)
|
||||
|
||||
|
||||
def remove_node_with_indent(node):
|
||||
"""Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать
|
||||
самозакрывающимся. Зеркало Remove-NodeWithIndent в PS."""
|
||||
parent = node.getparent()
|
||||
if parent is None:
|
||||
return
|
||||
# В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а
|
||||
# whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с
|
||||
# узлом, поэтому его надо передать предшественнику — иначе `</Attributes></Form>`.
|
||||
prev = node.getprevious()
|
||||
if prev is not None:
|
||||
prev.tail = node.tail
|
||||
else:
|
||||
parent.text = node.tail
|
||||
parent.remove(node)
|
||||
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||||
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||||
if len(parent) == 0 and not (parent.text or "").strip():
|
||||
parent.text = None
|
||||
|
||||
|
||||
def clear_form_refs(tree, ref):
|
||||
"""Очистить ссылки на форму. Каноничное «не задано» зависит от файла: в корневом XML
|
||||
объекта и в Configuration.xml пустой слот штатен (164 508 пустых на корпус), а внутри
|
||||
Ext/Form.xml пустых <ChoiceForm/> и <SettingsStorage/> нет ни одного — там свойство
|
||||
просто отсутствует. Зеркало Clear-FormRefs в PS."""
|
||||
root = tree.getroot()
|
||||
is_form_file = etree.QName(root).localname == "Form"
|
||||
touched = []
|
||||
ref_lc = ref.lower()
|
||||
for el in list(root.iter()):
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if len(el) > 0: # только листья
|
||||
continue
|
||||
# Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает).
|
||||
if (el.text or "").strip().lower() != ref_lc:
|
||||
continue
|
||||
|
||||
ln = etree.QName(el).localname
|
||||
parent = el.getparent()
|
||||
if ln == "Form" and parent is not None and etree.QName(parent).localname == "Item":
|
||||
touched.append(f"{etree.QName(parent).localname}/{ln}")
|
||||
remove_node_with_indent(parent)
|
||||
elif is_form_file:
|
||||
touched.append(ln)
|
||||
remove_node_with_indent(el)
|
||||
else:
|
||||
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
touched.append(ln)
|
||||
el.text = None
|
||||
return touched
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Remove form from 1C object", allow_abbrev=False)
|
||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||
parser.add_argument("-FormName", required=True)
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
parser.add_argument("-Force", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_name = args.ObjectName
|
||||
form_name = args.FormName
|
||||
src_dir = args.SrcDir
|
||||
force = args.Force
|
||||
|
||||
# --- Checks ---
|
||||
|
||||
root_xml_path = os.path.join(src_dir, f"{object_name}.xml")
|
||||
if not os.path.exists(root_xml_path):
|
||||
print(f"Корневой файл обработки не найден: {root_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
processor_dir = os.path.join(src_dir, object_name)
|
||||
forms_dir = os.path.join(processor_dir, "Forms")
|
||||
form_meta_path = os.path.join(forms_dir, f"{form_name}.xml")
|
||||
form_dir = os.path.join(forms_dir, form_name)
|
||||
|
||||
if not os.path.exists(form_meta_path):
|
||||
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Load root XML: kind and object name ---
|
||||
|
||||
root_xml_full = long_path(root_xml_path) or os.path.abspath(root_xml_path)
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(root_xml_full, parser_xml)
|
||||
root = tree.getroot()
|
||||
|
||||
type_node = None
|
||||
for c in root:
|
||||
if isinstance(c.tag, str):
|
||||
type_node = c
|
||||
break
|
||||
if type_node is None:
|
||||
print(f"Не удалось определить вид объекта в {root_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
md_type = etree.QName(type_node).localname
|
||||
name_node = type_node.find("md:Properties/md:Name", NSMAP)
|
||||
obj_meta_name = (name_node.text or "").strip() if name_node is not None else ""
|
||||
if not obj_meta_name:
|
||||
obj_meta_name = os.path.splitext(os.path.basename(root_xml_path))[0]
|
||||
|
||||
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||
form_ref = f"{md_type}.{obj_meta_name}.Form.{form_name}"
|
||||
|
||||
# --- Find references across the configuration ---
|
||||
|
||||
config_dir = None
|
||||
probe = long_path(src_dir) or os.path.abspath(src_dir)
|
||||
for _ in range(4):
|
||||
if not probe:
|
||||
break
|
||||
if os.path.exists(os.path.join(probe, "Configuration.xml")):
|
||||
config_dir = probe
|
||||
break
|
||||
parent_probe = os.path.dirname(probe)
|
||||
if parent_probe == probe:
|
||||
break
|
||||
probe = parent_probe
|
||||
|
||||
form_meta_full = long_path(form_meta_path)
|
||||
form_dir_full = long_path(form_dir)
|
||||
|
||||
references = []
|
||||
if config_dir:
|
||||
ref_pattern = re.compile(r"<([A-Za-z0-9_.]+)>" + re.escape(form_ref) + r"</")
|
||||
for dirpath, _dirnames, filenames in os.walk(config_dir):
|
||||
for fn in filenames:
|
||||
if not fn.lower().endswith(".xml"):
|
||||
continue
|
||||
fp = os.path.join(dirpath, fn)
|
||||
if fp == root_xml_full or fp == form_meta_full:
|
||||
continue # свой файл и файлы удаляемой формы
|
||||
if form_dir_full and fp.startswith(form_dir_full):
|
||||
continue
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8-sig") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
if form_ref not in content:
|
||||
continue
|
||||
for m in ref_pattern.finditer(content):
|
||||
references.append({"path": fp, "rel": os.path.relpath(fp, config_dir),
|
||||
"tag": m.group(1)})
|
||||
|
||||
if references:
|
||||
print(f"[WARN] На форму {form_ref} ссылаются {len(references)} раз(а):")
|
||||
grouped = {}
|
||||
for r in references:
|
||||
grouped[(r["rel"], r["tag"])] = grouped.get((r["rel"], r["tag"]), 0) + 1
|
||||
for (rel, tag) in sorted(grouped):
|
||||
suffix = f" x{grouped[(rel, tag)]}" if grouped[(rel, tag)] > 1 else ""
|
||||
print(f" {rel} — <{tag}>{suffix}")
|
||||
print()
|
||||
if not force:
|
||||
print("[ERROR] Удаление остановлено: форма используется.")
|
||||
print(" Решает пользователь: убрать ссылки, отказаться от удаления или")
|
||||
print(" повторить с -Force — тогда ссылки будут очищены.")
|
||||
sys.exit(1)
|
||||
print("[WARN] -Force: ссылки будут очищены")
|
||||
print()
|
||||
elif not config_dir:
|
||||
print("[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены")
|
||||
|
||||
# --- Delete files ---
|
||||
|
||||
if os.path.isdir(form_dir):
|
||||
shutil.rmtree(form_dir)
|
||||
print(f"[OK] Удалён каталог: {form_dir}")
|
||||
|
||||
os.remove(form_meta_path)
|
||||
print(f"[OK] Удалён файл: {form_meta_path}")
|
||||
|
||||
# --- Modify root XML ---
|
||||
|
||||
# Remove <Form>FormName</Form> from ChildObjects
|
||||
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
|
||||
if node.text and node.text.strip() == form_name:
|
||||
remove_node_with_indent(node)
|
||||
break
|
||||
|
||||
# Очистить слоты своего объекта: Default*/Auxiliary*Form и ChoiceForm у реквизитов.
|
||||
clear_form_refs(tree, form_ref)
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, root_xml_full)
|
||||
|
||||
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
|
||||
|
||||
# --- Clean references in other files (only with -Force) ---
|
||||
|
||||
for fp in sorted({r["path"] for r in references}):
|
||||
other_tree = etree.parse(fp, parser_xml)
|
||||
touched = clear_form_refs(other_tree, form_ref)
|
||||
if not touched:
|
||||
continue
|
||||
save_xml_with_bom(other_tree, fp)
|
||||
rel = os.path.relpath(fp, config_dir)
|
||||
print(f"[OK] Очищена ссылка в {rel} — {', '.join(sorted(set(touched)))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,308 +0,0 @@
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
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) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
$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>
|
||||
"@
|
||||
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $helpXmlPath $helpXml $encBom
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
$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
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $formMeta.FullName) -and ([System.IO.File]::ReadAllText($formMeta.FullName) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($formMeta.FullName, $xmlText, $encBom)
|
||||
|
||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[OK] Создана справка: $ObjectName"
|
||||
Write-Host " Метаданные: $helpXmlPath"
|
||||
Write-Host " Страница: $helpHtmlPath"
|
||||
@@ -1,428 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 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:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
|
||||
HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Add 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 = ci_parse_args(parser)
|
||||
|
||||
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_xml_file(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_utf8_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,883 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-edit v1.22 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# 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:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
CI_NS = "http://v8.1c.ru/8.3/xcf/extrnprops"
|
||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
|
||||
SECTION_ORDER = ["CommandsVisibility", "CommandsPlacement", "CommandsOrder", "SubsystemsOrder", "GroupsOrder"]
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
|
||||
def info(msg):
|
||||
print(f"[INFO] {msg}")
|
||||
|
||||
|
||||
def warn(msg):
|
||||
print(f"[WARN] {msg}")
|
||||
|
||||
|
||||
def get_child_indent(container):
|
||||
if container.text and "\n" in container.text:
|
||||
after_nl = container.text.rsplit("\n", 1)[-1]
|
||||
if after_nl and not after_nl.strip():
|
||||
return after_nl
|
||||
for child in container:
|
||||
if child.tail and "\n" in child.tail:
|
||||
after_nl = child.tail.rsplit("\n", 1)[-1]
|
||||
if after_nl and not after_nl.strip():
|
||||
return after_nl
|
||||
depth = 0
|
||||
current = container
|
||||
while current is not None:
|
||||
depth += 1
|
||||
current = current.getparent()
|
||||
return "\t" * depth
|
||||
|
||||
|
||||
def insert_before_closing(container, new_el, child_indent):
|
||||
children = list(container)
|
||||
if len(children) == 0:
|
||||
parent_indent = child_indent[:-1] if len(child_indent) > 0 else ""
|
||||
container.text = "\r\n" + child_indent
|
||||
new_el.tail = "\r\n" + parent_indent
|
||||
container.append(new_el)
|
||||
else:
|
||||
last = children[-1]
|
||||
new_el.tail = last.tail
|
||||
last.tail = "\r\n" + child_indent
|
||||
container.append(new_el)
|
||||
|
||||
|
||||
def remove_with_indent(el):
|
||||
parent = el.getparent()
|
||||
prev = el.getprevious()
|
||||
if prev is not None:
|
||||
if el.tail:
|
||||
prev.tail = el.tail
|
||||
else:
|
||||
if el.tail:
|
||||
parent.text = el.tail
|
||||
parent.remove(el)
|
||||
|
||||
|
||||
def import_ci_fragment(xml_string):
|
||||
wrapper = (
|
||||
f'<_W xmlns="{CI_NS}" xmlns:xr="{XR_NS}" '
|
||||
f'xmlns:xsi="{XSI_NS}" xmlns:xs="{XS_NS}">{xml_string}</_W>'
|
||||
)
|
||||
frag = etree.fromstring(wrapper.encode("utf-8"))
|
||||
nodes = []
|
||||
for child in frag:
|
||||
nodes.append(child)
|
||||
return nodes
|
||||
|
||||
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def parse_value_list(val, op_name):
|
||||
val = val.strip()
|
||||
if val.startswith("["):
|
||||
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of command names", inline=True))
|
||||
return [str(item) for item in arr]
|
||||
return [val]
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
try:
|
||||
raw = open(path, "rb").read()
|
||||
except OSError:
|
||||
return None
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
body = raw[3:] if bom else raw
|
||||
crlf = b"\r\n" in body
|
||||
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||
final_nl = body.endswith(b"\n")
|
||||
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
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)
|
||||
|
||||
|
||||
TYPE_NORM_MAP = {
|
||||
'Catalogs': 'Catalog', 'Documents': 'Document', 'Enums': 'Enum',
|
||||
'Constants': 'Constant', 'Reports': 'Report', 'DataProcessors': 'DataProcessor',
|
||||
'InformationRegisters': 'InformationRegister', 'AccumulationRegisters': 'AccumulationRegister',
|
||||
'AccountingRegisters': 'AccountingRegister', 'CalculationRegisters': 'CalculationRegister',
|
||||
'ChartsOfAccounts': 'ChartOfAccounts', 'ChartsOfCharacteristicTypes': 'ChartOfCharacteristicTypes',
|
||||
'ChartsOfCalculationTypes': 'ChartOfCalculationTypes',
|
||||
'BusinessProcesses': 'BusinessProcess', 'Tasks': 'Task',
|
||||
'ExchangePlans': 'ExchangePlan', 'DocumentJournals': 'DocumentJournal',
|
||||
'CommonModules': 'CommonModule', 'CommonCommands': 'CommonCommand',
|
||||
'CommonForms': 'CommonForm', 'CommonPictures': 'CommonPicture',
|
||||
'CommonTemplates': 'CommonTemplate', 'CommonAttributes': 'CommonAttribute',
|
||||
'CommandGroups': 'CommandGroup', 'Roles': 'Role',
|
||||
'Subsystems': 'Subsystem', 'StyleItems': 'StyleItem',
|
||||
# Russian singular
|
||||
'Справочник': 'Catalog', 'Документ': 'Document', 'Перечисление': 'Enum',
|
||||
'Константа': 'Constant', 'Отчёт': 'Report', 'Отчет': 'Report', 'Обработка': 'DataProcessor',
|
||||
'РегистрСведений': 'InformationRegister', 'РегистрНакопления': 'AccumulationRegister',
|
||||
'РегистрБухгалтерии': 'AccountingRegister',
|
||||
'ПланСчетов': 'ChartOfAccounts', 'ПланВидовХарактеристик': 'ChartOfCharacteristicTypes',
|
||||
'БизнесПроцесс': 'BusinessProcess', 'Задача': 'Task',
|
||||
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
||||
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
||||
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
||||
'РегистрРасчёта': 'CalculationRegister', 'РегистрРасчета': 'CalculationRegister',
|
||||
'ПланВидовРасчёта': 'ChartOfCalculationTypes', 'ПланВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роль': 'Role', 'ОбщийМакет': 'CommonTemplate', 'ЭлементСтиля': 'StyleItem',
|
||||
'ОбщийРеквизит': 'CommonAttribute', 'ГруппаКоманд': 'CommandGroup',
|
||||
# Russian plural
|
||||
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
||||
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
||||
'РегистрыСведений': 'InformationRegister', 'РегистрыНакопления': 'AccumulationRegister',
|
||||
'РегистрыБухгалтерии': 'AccountingRegister',
|
||||
'ПланыСчетов': 'ChartOfAccounts', 'ПланыВидовХарактеристик': 'ChartOfCharacteristicTypes',
|
||||
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
||||
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
||||
'Подсистемы': 'Subsystem',
|
||||
'РегистрыРасчёта': 'CalculationRegister', 'РегистрыРасчета': 'CalculationRegister',
|
||||
'ПланыВидовРасчёта': 'ChartOfCalculationTypes', 'ПланыВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роли': 'Role', 'ОбщиеМакеты': 'CommonTemplate', 'ЭлементыСтиля': 'StyleItem',
|
||||
'ОбщиеРеквизиты': 'CommonAttribute', 'ГруппыКоманд': 'CommandGroup',
|
||||
}
|
||||
|
||||
|
||||
def normalize_cmd_name(name):
|
||||
if not name or '.' not in name:
|
||||
return name
|
||||
dot_idx = name.index('.')
|
||||
first = name[:dot_idx]
|
||||
rest = name[dot_idx:]
|
||||
if first in TYPE_NORM_MAP:
|
||||
normalized = TYPE_NORM_MAP[first] + rest
|
||||
if normalized != name:
|
||||
print(f'[NORM] Command: {name} -> {normalized}')
|
||||
return normalized
|
||||
return name
|
||||
|
||||
|
||||
def find_command_by_name(section, cmd_name):
|
||||
for child in section:
|
||||
if isinstance(child.tag, str) and localname(child) == "Command":
|
||||
if child.get("name") == cmd_name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="Edit 1C CommandInterface.xml", allow_abbrev=False)
|
||||
parser.add_argument("-CIPath", "-Path", required=True)
|
||||
parser.add_argument("-DefinitionFile", default=None)
|
||||
parser.add_argument("-Operation", default=None, choices=["hide", "show", "place", "order", "subsystem-order", "group-order"])
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-CreateIfMissing", action="store_true")
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Mode validation ---
|
||||
if args.DefinitionFile and args.Operation:
|
||||
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not args.DefinitionFile and not args.Operation:
|
||||
print("Either -DefinitionFile or -Operation is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Detect format version ---
|
||||
ci_dir = os.path.dirname(os.path.abspath(args.CIPath))
|
||||
format_version = detect_format_version(ci_dir)
|
||||
|
||||
# --- Resolve path ---
|
||||
ci_path = args.CIPath
|
||||
if not os.path.isabs(ci_path):
|
||||
ci_path = os.path.join(os.getcwd(), ci_path)
|
||||
resolved_path = ci_path
|
||||
|
||||
assert_edit_allowed(ci_path, "editable")
|
||||
|
||||
# --- Create if missing ---
|
||||
if not os.path.isfile(ci_path):
|
||||
if args.CreateIfMissing:
|
||||
parent_dir = os.path.dirname(ci_path)
|
||||
if parent_dir and not os.path.isdir(parent_dir):
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
empty_ci = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<CommandInterface xmlns="{CI_NS}"\n'
|
||||
f'\txmlns:xr="{XR_NS}"\n'
|
||||
f'\txmlns:xs="{XS_NS}"\n'
|
||||
f'\txmlns:xsi="{XSI_NS}"\n'
|
||||
f'\tversion="{format_version}">\n'
|
||||
f'</CommandInterface>'
|
||||
)
|
||||
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF в разделителях. (Правка
|
||||
# существующего файла, наоборот, наследует его стиль — это делает
|
||||
# save_xml_bom через _detect_xml_style.) newline="" обязателен: без него
|
||||
# текстовый режим дал бы CRLF на Windows и LF на macOS.
|
||||
empty_ci = empty_ci.replace("\r\n", "\n").replace("\n", "\r\n").rstrip("\r\n")
|
||||
with open(ci_path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||
fh.write(empty_ci)
|
||||
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
||||
else:
|
||||
print(f"File not found: {ci_path} (use -CreateIfMissing to create)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
resolved_path = os.path.abspath(ci_path)
|
||||
|
||||
# --- Load XML ---
|
||||
xml_parser = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(resolved_path, xml_parser)
|
||||
root = tree.getroot()
|
||||
|
||||
add_count = 0
|
||||
remove_count = 0
|
||||
modify_count = 0
|
||||
|
||||
if localname(root) != "CommandInterface":
|
||||
print(f"Expected <CommandInterface> root element, got <{localname(root)}>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def ensure_section(section_name):
|
||||
# Find existing
|
||||
for child in root:
|
||||
if isinstance(child.tag, str) and localname(child) == section_name:
|
||||
return child
|
||||
|
||||
# Create new section
|
||||
new_section = etree.Element(f"{{{CI_NS}}}{section_name}")
|
||||
|
||||
my_idx = SECTION_ORDER.index(section_name) if section_name in SECTION_ORDER else -1
|
||||
ref_node = None
|
||||
for child in root:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
child_idx = SECTION_ORDER.index(localname(child)) if localname(child) in SECTION_ORDER else -1
|
||||
if child_idx > my_idx:
|
||||
ref_node = child
|
||||
break
|
||||
|
||||
root_indent = get_child_indent(root)
|
||||
new_section.text = "\r\n" + root_indent
|
||||
|
||||
if ref_node is not None:
|
||||
# Insert before ref_node
|
||||
idx = list(root).index(ref_node)
|
||||
new_section.tail = "\r\n" + root_indent
|
||||
root.insert(idx, new_section)
|
||||
else:
|
||||
insert_before_closing(root, new_section, root_indent)
|
||||
|
||||
return new_section
|
||||
|
||||
def do_hide(commands):
|
||||
nonlocal add_count, modify_count
|
||||
commands = [normalize_cmd_name(c) for c in commands]
|
||||
section = ensure_section("CommandsVisibility")
|
||||
section_indent = get_child_indent(section)
|
||||
|
||||
for cmd in commands:
|
||||
existing = find_command_by_name(section, cmd)
|
||||
if existing is not None:
|
||||
common_el = None
|
||||
for vis in existing:
|
||||
if isinstance(vis.tag, str) and localname(vis) == "Visibility":
|
||||
for c in vis:
|
||||
if isinstance(c.tag, str) and localname(c) == "Common":
|
||||
common_el = c
|
||||
break
|
||||
if common_el is not None and (common_el.text or "").strip() == "false":
|
||||
warn(f"Already hidden: {cmd}")
|
||||
continue
|
||||
if common_el is not None:
|
||||
common_el.text = "false"
|
||||
modify_count += 1
|
||||
info(f"Changed to hidden: {cmd}")
|
||||
continue
|
||||
|
||||
frag_xml = f'<Command name="{cmd}"><Visibility><xr:Common>false</xr:Common></Visibility></Command>'
|
||||
nodes = import_ci_fragment(frag_xml)
|
||||
if nodes:
|
||||
insert_before_closing(section, nodes[0], section_indent)
|
||||
add_count += 1
|
||||
info(f"Hidden: {cmd}")
|
||||
|
||||
def do_show(commands):
|
||||
nonlocal add_count, modify_count
|
||||
commands = [normalize_cmd_name(c) for c in commands]
|
||||
section = None
|
||||
for child in root:
|
||||
if isinstance(child.tag, str) and localname(child) == "CommandsVisibility":
|
||||
section = child
|
||||
break
|
||||
|
||||
for cmd in commands:
|
||||
if section is None:
|
||||
section = ensure_section("CommandsVisibility")
|
||||
|
||||
existing = find_command_by_name(section, cmd)
|
||||
if existing is not None:
|
||||
common_el = None
|
||||
for vis in existing:
|
||||
if isinstance(vis.tag, str) and localname(vis) == "Visibility":
|
||||
for c in vis:
|
||||
if isinstance(c.tag, str) and localname(c) == "Common":
|
||||
common_el = c
|
||||
break
|
||||
if common_el is not None and (common_el.text or "").strip() == "true":
|
||||
warn(f"Already shown: {cmd}")
|
||||
continue
|
||||
if common_el is not None and (common_el.text or "").strip() == "false":
|
||||
common_el.text = "true"
|
||||
modify_count += 1
|
||||
info(f"Changed to shown: {cmd}")
|
||||
continue
|
||||
|
||||
section_indent = get_child_indent(section)
|
||||
frag_xml = f'<Command name="{cmd}"><Visibility><xr:Common>true</xr:Common></Visibility></Command>'
|
||||
nodes = import_ci_fragment(frag_xml)
|
||||
if nodes:
|
||||
insert_before_closing(section, nodes[0], section_indent)
|
||||
add_count += 1
|
||||
info(f"Shown: {cmd}")
|
||||
|
||||
def do_place(json_val):
|
||||
nonlocal add_count, modify_count
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||
json_val, "-Value for operation 'place'", "a JSON object {command, group}", inline=True))
|
||||
cmd_name = normalize_cmd_name(str(defn["command"]))
|
||||
group_name = str(defn["group"])
|
||||
if not cmd_name or not group_name:
|
||||
print("place requires {command, group}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
section = ensure_section("CommandsPlacement")
|
||||
section_indent = get_child_indent(section)
|
||||
|
||||
existing = find_command_by_name(section, cmd_name)
|
||||
if existing is not None:
|
||||
for child in existing:
|
||||
if isinstance(child.tag, str) and localname(child) == "CommandGroup":
|
||||
child.text = group_name
|
||||
modify_count += 1
|
||||
info(f"Updated placement: {cmd_name} -> {group_name}")
|
||||
return
|
||||
|
||||
frag_xml = f'<Command name="{cmd_name}"><CommandGroup>{group_name}</CommandGroup><Placement>Auto</Placement></Command>'
|
||||
nodes = import_ci_fragment(frag_xml)
|
||||
if nodes:
|
||||
insert_before_closing(section, nodes[0], section_indent)
|
||||
add_count += 1
|
||||
info(f"Placed: {cmd_name} -> {group_name}")
|
||||
|
||||
def do_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||
json_val, "-Value for operation 'order'", "a JSON object {group, commands:[...]}", inline=True))
|
||||
group_name = str(defn["group"])
|
||||
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
||||
if not group_name or not commands:
|
||||
print("order requires {group, commands:[...]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
section = ensure_section("CommandsOrder")
|
||||
section_indent = get_child_indent(section)
|
||||
|
||||
# Remove existing entries for this group
|
||||
to_remove = []
|
||||
for child in section:
|
||||
if not isinstance(child.tag, str) or localname(child) != "Command":
|
||||
continue
|
||||
for gc in child:
|
||||
if isinstance(gc.tag, str) and localname(gc) == "CommandGroup" and (gc.text or "").strip() == group_name:
|
||||
to_remove.append(child)
|
||||
break
|
||||
for node in to_remove:
|
||||
remove_with_indent(node)
|
||||
remove_count += 1
|
||||
|
||||
# Add new entries
|
||||
for cmd_name in commands:
|
||||
frag_xml = f'<Command name="{cmd_name}"><CommandGroup>{group_name}</CommandGroup></Command>'
|
||||
nodes = import_ci_fragment(frag_xml)
|
||||
if nodes:
|
||||
insert_before_closing(section, nodes[0], section_indent)
|
||||
add_count += 1
|
||||
info(f"Set order for {group_name} : {len(commands)} commands")
|
||||
|
||||
def do_subsystem_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||
json_val, "-Value for operation 'subsystem-order'", "a JSON array of subsystem paths", inline=True))
|
||||
subsystems = [str(s) for s in parsed]
|
||||
if not subsystems:
|
||||
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
section = ensure_section("SubsystemsOrder")
|
||||
section_indent = get_child_indent(section)
|
||||
|
||||
# Clear existing
|
||||
for child in list(section):
|
||||
if isinstance(child.tag, str):
|
||||
remove_with_indent(child)
|
||||
remove_count += 1
|
||||
|
||||
# Add new entries
|
||||
for sub in subsystems:
|
||||
new_el = etree.Element(f"{{{CI_NS}}}Subsystem")
|
||||
new_el.text = sub
|
||||
insert_before_closing(section, new_el, section_indent)
|
||||
add_count += 1
|
||||
info(f"Set subsystem order: {len(subsystems)} entries")
|
||||
|
||||
def do_group_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||
json_val, "-Value for operation 'group-order'", "a JSON array of group names", inline=True))
|
||||
groups = [str(g) for g in parsed]
|
||||
if not groups:
|
||||
print("group-order requires array of group names", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
section = ensure_section("GroupsOrder")
|
||||
section_indent = get_child_indent(section)
|
||||
|
||||
# Clear existing
|
||||
for child in list(section):
|
||||
if isinstance(child.tag, str):
|
||||
remove_with_indent(child)
|
||||
remove_count += 1
|
||||
|
||||
# Add new entries
|
||||
for grp in groups:
|
||||
new_el = etree.Element(f"{{{CI_NS}}}Group")
|
||||
new_el.text = grp
|
||||
insert_before_closing(section, new_el, section_indent)
|
||||
add_count += 1
|
||||
info(f"Set group order: {len(groups)} entries")
|
||||
|
||||
# --- Execute operations ---
|
||||
operations = []
|
||||
if args.DefinitionFile:
|
||||
def_file = args.DefinitionFile
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
operations = [ops]
|
||||
else:
|
||||
operations = [{"operation": args.Operation, "value": args.Value or ""}]
|
||||
|
||||
for op in operations:
|
||||
op_name = op.get("operation", args.Operation or "")
|
||||
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||
op_key = str(op_name).lower()
|
||||
op_value = op.get("value", args.Value or "")
|
||||
|
||||
if op_key == "hide":
|
||||
do_hide(parse_value_list(op_value, op_name))
|
||||
elif op_key == "show":
|
||||
do_show(parse_value_list(op_value, op_name))
|
||||
elif op_key == "place":
|
||||
do_place(op_value)
|
||||
elif op_key == "order":
|
||||
do_order(op_value)
|
||||
elif op_key == "subsystem-order":
|
||||
do_subsystem_order(op_value)
|
||||
elif op_key == "group-order":
|
||||
do_group_order(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Save ---
|
||||
save_xml_bom(tree, resolved_path)
|
||||
info(f"Saved: {resolved_path}")
|
||||
|
||||
# --- Auto-validate ---
|
||||
if not args.NoValidate:
|
||||
validate_script = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "interface-validate", "scripts", "interface-validate.py"))
|
||||
if os.path.isfile(validate_script):
|
||||
print()
|
||||
print("--- Running interface-validate ---")
|
||||
subprocess.run([sys.executable, validate_script, "-CIPath", resolved_path])
|
||||
|
||||
# --- Summary ---
|
||||
print()
|
||||
print("=== interface-edit summary ===")
|
||||
print(f" Added: {add_count}")
|
||||
print(f" Removed: {remove_count}")
|
||||
print(f" Modified: {modify_count}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,139 +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
|
||||
powershell.exe -NoProfile -File ".agents/skills/meta-compile/scripts/meta-compile.ps1" -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` |
|
||||
| ExternalDataSource (внешний источник данных) | `reference/external-data-source.md` |
|
||||
| FunctionalOption, FilterCriterion, SettingsStorage, CommonForm, CommonPicture, CommonTemplate, служебные | `reference/other-types.md` |
|
||||
|
||||
Кросс-типовые детали:
|
||||
- **`reference/attributes.md`** — объектная форма реквизита и колонки ТЧ (значение заполнения, параметры
|
||||
выбора, формат, подсказка, границы, …) + свойства самой ТЧ.
|
||||
- **`reference/blocks.md`** — блоки объекта: представления, команды (+ характеристики/стандартные реквизиты).
|
||||
|
||||
Эта инструкция и reference-файлы — полная документация. Не ищи примеры XML в выгрузках конфигураций.
|
||||
|
||||
## Примеры
|
||||
|
||||
Справочник с реквизитами:
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"] }
|
||||
```
|
||||
|
||||
Документ с движениями и ТЧ:
|
||||
```json
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] } }
|
||||
```
|
||||
|
||||
Регистр сведений:
|
||||
```json
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
Batch:
|
||||
```json
|
||||
[ { "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
||||
{ "type": "Catalog", "name": "Валюты" },
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" } ]
|
||||
```
|
||||
@@ -1,140 +0,0 @@
|
||||
# Объектная форма реквизита и табличной части
|
||||
|
||||
Когда реквизиту (в `attributes` / `dimensions` / `resources` / колонках ТЧ) нужны свойства сверх
|
||||
shorthand — вместо строки задаётся объект:
|
||||
|
||||
```json
|
||||
{ "name": "Цена", "type": "Number(15,2)", "tooltip": "Цена за единицу", "fillValue": 0 }
|
||||
```
|
||||
|
||||
`name` и `type` обязательны (тип можно задать и раздельно: `"type": "Number", "length": 15, "precision": 2`).
|
||||
Остальные ключи — ниже, все со значением по умолчанию (не задавать, если устраивает дефолт).
|
||||
|
||||
## Свойства реквизита
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML (строка или `{ru,en}`) |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (то же, что флаг `req`) |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fillFromFillingValue` | `false` | bool |
|
||||
| `fillValue` | по типу (см. ниже) | значение заполнения |
|
||||
| `createOnInput` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `quickChoice` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `dataHistory` | `Use` | `Use` / `DontUse` |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (реквизит иерархического справочника) |
|
||||
| `passwordMode` | `false` | bool |
|
||||
| `multiLine` | `false` | bool (то же, что флаг `multiline`) |
|
||||
| `extendedEdit` | `false` | bool (расширенное редактирование — многострочный ввод) |
|
||||
| `mask` | пусто | строка маски ввода |
|
||||
| `format` / `editFormat` | пусто | форматная строка 1С (ML) |
|
||||
| `markNegatives` | `false` | bool (выделять отрицательные, для Number) |
|
||||
| `minValue` / `maxValue` | не задано | граница диапазона (см. ниже) |
|
||||
| `choiceParameterLinks` | пусто | связи параметров выбора (см. ниже) |
|
||||
| `choiceParameters` | пусто | параметры выбора (см. ниже) |
|
||||
| `choiceForm` | пусто | ссылка на форму выбора `Тип.Объект.Form.ИмяФормы` |
|
||||
| `choiceFoldersAndItems` | `Items` | `Items` / `Folders` / `FoldersAndItems` (что выбирать в иерарх. справочнике) |
|
||||
|
||||
Индексирование задаётся флагом `index` / `indexAdditional` в shorthand, либо в объекте — как и в строковой форме,
|
||||
через `"type": "… | index"`.
|
||||
|
||||
### `fillValue` — значение заполнения
|
||||
|
||||
Пустое значение по типу компилятор подставляет сам — ключ **не задают**:
|
||||
|
||||
| Тип реквизита | Пустое значение |
|
||||
|---------------|-----------------|
|
||||
| String | пустая строка |
|
||||
| Number | `0` |
|
||||
| Boolean, Date, ссылочный, составной | не задано (nil) |
|
||||
|
||||
Ключ `fillValue` задают для **конкретного** значения — интерпретируется по типу реквизита:
|
||||
|
||||
- **Boolean** — `true` / `false`.
|
||||
- **Number** — число (`21`, `1.5`).
|
||||
- **String** — строка.
|
||||
- **Date** — ISO-строка `"2020-01-01T00:00:00"`.
|
||||
- **Ссылочный** — путь: `"Catalog.Валюты.EmptyRef"` (пустая ссылка), `"Enum.Периодичность.EnumValue.Месяц"`
|
||||
(значение перечисления), `"Catalog.СтраныМира.Россия"` (предопределённый элемент).
|
||||
- **`null`** — явно «значение не задано» (nil), когда нужно перекрыть непустой дефолт типа.
|
||||
- **`{ "emptyRef": true }`** — пустая ссылка для реквизита типа `DefinedType.X` (когда тип из пути не выводится).
|
||||
|
||||
> Пустая ссылка (`EmptyRef`) и `null` — разное: платформа хранит их отдельно.
|
||||
|
||||
### `minValue` / `maxValue` — границы диапазона
|
||||
|
||||
Число → числовая граница; строка → строковая (напр. год `"2000"`). Без ключа — граница не задана.
|
||||
|
||||
### `choiceParameterLinks` — связи параметров выбора
|
||||
|
||||
Связывают параметр выбора этого реквизита с другим реквизитом объекта. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameterLinks": ["Отбор.Организация=Организация", "Отбор.Договор=Договор:DontChange"]
|
||||
"choiceParameterLinks": [{ "name": "Отбор.Организация", "dataPath": "Организация", "valueChange": "Clear" }]
|
||||
```
|
||||
|
||||
- `dataPath` — реквизит **того же объекта**: имя обычного реквизита (`"Организация"`) или стандартного
|
||||
(`"Владелец"`, `"Ссылка"`).
|
||||
- `valueChange` — `Clear` (по умолчанию) / `DontChange`.
|
||||
|
||||
### `choiceParameters` — параметры выбора
|
||||
|
||||
Фиксируют параметр выбора значением. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameters": ["Отбор.ЭтоГруппа=false"]
|
||||
"choiceParameters": [{ "name": "Отбор.Владелец", "value": "Catalog.Организации.EmptyRef" }]
|
||||
```
|
||||
|
||||
- `value` — bool / число / строка / ссылочный путь (несёт тип) ИЛИ массив (список фиксированных значений).
|
||||
- Для набора голых имён-значений добавьте `type` (тип поля-фильтра), чтобы они стали ссылками:
|
||||
`{ "name": "Отбор.Тип", "type": "EnumRef.ТипыВЕТИС", "value": ["EmptyRef", "ТТН"] }`.
|
||||
|
||||
### Редкие ключи
|
||||
|
||||
`linkByType` — связь по типу (тип реквизита-Характеристики берётся из другого реквизита):
|
||||
`{ "dataPath": "Свойство", "linkItem": 0 }` или строка-путь. Применяется для реквизитов-характеристик.
|
||||
|
||||
---
|
||||
|
||||
## Табличная часть — объектная форма
|
||||
|
||||
Значение в `tabularSections` — массив колонок ЛИБО объект со свойствами самой ТЧ:
|
||||
|
||||
```json
|
||||
"tabularSections": {
|
||||
"Товары": {
|
||||
"synonym": { "ru": "Товары", "en": "Goods" },
|
||||
"tooltip": "Строки заказа",
|
||||
"fillChecking": "ShowError",
|
||||
"attributes": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (обязательность заполнения ТЧ) |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
||||
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
||||
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||
| `lineNumberLength` | по режиму совместимости | `5`…`9` — разрядность номера строки: `5` → до 99 999 строк, `9` → до 999 999 999. Требует формата 2.20 (платформа 8.3.27) |
|
||||
|
||||
### `lineNumber` — стандартный реквизит НомерСтроки
|
||||
|
||||
У каждой ТЧ есть стандартный реквизит НомерСтроки. По умолчанию все его свойства типовые. Ключ `lineNumber`
|
||||
на объектной форме ТЧ их переопределяет:
|
||||
|
||||
```json
|
||||
"Строки": { "lineNumber": { "synonym": "Номер п/п", "fullTextSearch": "DontUse" }, "attributes": [...] }
|
||||
```
|
||||
|
||||
Переопределяемые: `synonym`, `comment`, `fullTextSearch` (`Use`/`DontUse`), `tooltip`, `format`, `editFormat`,
|
||||
`choiceHistoryOnInput` (`Auto`/`DontUse`).
|
||||
@@ -1,109 +0,0 @@
|
||||
# Блоки объекта
|
||||
|
||||
Кросс-типовые блоки уровня объекта (применимы к ссылочным типам — Catalog, Document, ChartOf*, ExchangePlan,
|
||||
BusinessProcess, Task и др.).
|
||||
|
||||
## Представления
|
||||
|
||||
Тексты представления объекта в интерфейсе (ML — строка или `{ru,en}`, по умолчанию пусто):
|
||||
|
||||
| Ключ | Смысл |
|
||||
|------|-------|
|
||||
| `objectPresentation` | представление объекта |
|
||||
| `extendedObjectPresentation` | расширенное представление объекта |
|
||||
| `listPresentation` | представление списка |
|
||||
| `extendedListPresentation` | расширенное представление списка |
|
||||
| `explanation` | пояснение |
|
||||
|
||||
Набор доступных ключей зависит от типа (у списочных без формы объекта нет `objectPresentation` и т.п.).
|
||||
|
||||
```json
|
||||
"listPresentation": "Организации", "objectPresentation": { "ru": "Организация", "en": "Company" }
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
Команды объекта. Ключ — имя команды, значение — объект свойств (map `имя → объект` или массив `[{name, …}]`).
|
||||
Для каждой команды создаётся заготовка модуля с обработчиком `ОбработкаКоманды`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `group` | **обязательно** | группа размещения (см. ниже) |
|
||||
| `commandParameterType` | пусто | тип параметра (напр. `CatalogRef.Номенклатура`) — **только для групп формы** |
|
||||
| `parameterUseMode` | `Single` | `Single` / `Multiple` |
|
||||
| `modifiesData` | `false` | bool |
|
||||
| `representation` | `Auto` | вид отображения |
|
||||
| `picture` | пусто | ссылка на картинку (`StdPicture.Print`, `CommonPicture.Загрузка`) |
|
||||
| `shortcut` | пусто | сочетание клавиш |
|
||||
|
||||
```json
|
||||
"commands": {
|
||||
"ПечатьЭтикеток": { "synonym": "Печать этикеток", "group": "FormCommandBarImportant",
|
||||
"commandParameterType": "CatalogRef.Номенклатура", "picture": "StdPicture.Print" }
|
||||
}
|
||||
```
|
||||
|
||||
**Группа (`group`) обязательна** — каждая команда размещается в группе командного интерфейса:
|
||||
|
||||
- **Командный интерфейс раздела** (панель навигации / панель действий; `commandParameterType` **недоступен**):
|
||||
`NavigationPanelImportant` / `NavigationPanelOrdinary` / `NavigationPanelSeeAlso`,
|
||||
`ActionsPanelCreate` / `ActionsPanelReports` / `ActionsPanelTools`.
|
||||
- **Командный интерфейс формы** (`commandParameterType` допустим): `FormCommandBarImportant` /
|
||||
`FormCommandBarCreateBasedOn`, `FormNavigationPanelImportant` / `FormNavigationPanelGoTo` / `FormNavigationPanelSeeAlso`.
|
||||
- **Кастомная группа:** `CommandGroup.<Имя>` (параметр допустим).
|
||||
|
||||
Группа раздела вместе с `commandParameterType` → ошибка.
|
||||
|
||||
## `inputByString` / `dataLockFields` / `basedOn`
|
||||
|
||||
Списки полей/объектов уровня объекта. Поля — по имени реквизита объекта (обычного или стандартного).
|
||||
|
||||
- **`inputByString`** — поля быстрого ввода по строке. По умолчанию выводятся из Кода/Наименования — ключ не нужен;
|
||||
задать при другом наборе/порядке, либо `[]` для отключения.
|
||||
```json
|
||||
"inputByString": ["Код", "Наименование", "Контрагент"]
|
||||
```
|
||||
- **`dataLockFields`** — поля управляемой блокировки данных (по умолчанию пусто).
|
||||
```json
|
||||
"dataLockFields": ["Организация", "Контрагент"]
|
||||
```
|
||||
- **`basedOn`** — «ввод на основании»: список ссылок на объекты метаданных (по умолчанию пусто).
|
||||
```json
|
||||
"basedOn": ["Catalog.Контрагенты", "Document.ЗаказПоставщику"]
|
||||
```
|
||||
|
||||
## `standardAttributes` — кастомизация стандартных реквизитов
|
||||
|
||||
Стандартные реквизиты объекта (Наименование, Код, Владелец, …) переопределяются блоком
|
||||
`standardAttributes` — объект `{ ИмяРеквизита: { переопределения } }`. Имена — как в 1С: `Description`, `Code`,
|
||||
`Owner`, `Parent`, `DeletionMark`, `Ref` и т.д. (для Document — `Date`, `Number`, `Posted`).
|
||||
|
||||
Переопределяемые поля — как у обычного реквизита (`synonym`, `tooltip`, `fillChecking`, `fillValue`,
|
||||
`choiceParameters`, `comment`, `mask`, `choiceForm`; полный набор — `attributes.md`).
|
||||
|
||||
```json
|
||||
"standardAttributes": {
|
||||
"Description": { "synonym": "Наименование контрагента" },
|
||||
"Code": { "fillChecking": "ShowError" }
|
||||
}
|
||||
```
|
||||
|
||||
## `characteristics` — «Дополнительные реквизиты и сведения»
|
||||
|
||||
Привязка плана видов характеристик. Массив; каждый элемент связывает **источник типов** (где определены
|
||||
характеристики) и **источник значений** (где хранятся значения).
|
||||
|
||||
```json
|
||||
"characteristics": [{
|
||||
"types": { "from": "Catalog.НаборыДопРеквизитов.ДополнительныеРеквизиты",
|
||||
"key": "Свойство", "filterField": "Ссылка", "filterValue": "Справочник_Организации" },
|
||||
"values": { "from": "Catalog.Организации.TabularSection.ДополнительныеРеквизиты",
|
||||
"object": "Ссылка", "type": "Свойство", "value": "Значение" }
|
||||
}]
|
||||
```
|
||||
|
||||
- `from` — таблица-источник; `key`/`filterField`/`object`/`type`/`value` — поля источника (по имени реквизита).
|
||||
- `filterValue` — значение фильтра типов: имя предопределённого набора (строка) или путь к элементу.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Catalog (Справочник)
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
||||
```
|
||||
|
||||
## Свойства
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `hierarchyType` | `HierarchyFoldersAndItems` | `HierarchyFoldersAndItems` / `HierarchyOfItems` |
|
||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||
| `owners` | `[]` | массив владельцев: `["Catalog.Контрагенты"]` |
|
||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||
| `codeType` | `String` | `String` / `Number` |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `codeSeries` | `WholeCatalog` | `WholeCatalog` / `WithinSubordination` / `WithinOwnerSubordination` |
|
||||
| `autonumbering` | `true` | bool (автонумерация) |
|
||||
| `checkUnique` | `false` | bool (контроль уникальности кода) |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `defaultPresentation` | `AsDescription` | `AsDescription` / `AsCode` |
|
||||
| `quickChoice` | `true` | bool (быстрый выбор) |
|
||||
| `choiceMode` | `BothWays` | `BothWays` / `QuickChoice` / `FromForm` |
|
||||
| `editType` | `InDialog` | `InDialog` / `InList` / `BothWays` |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fullTextSearchOnInputByString` | `DontUse` | `Use` / `DontUse` |
|
||||
| `searchStringModeOnInputByString` | `Begin` | `Begin` / `AnyPart` |
|
||||
| `predefinedDataUpdate` | `Auto` | `Auto` / `DontAutoUpdate` / `AutoUpdate` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` | `[]` | реквизиты (shorthand / объектная форма) |
|
||||
| `tabularSections` | `{}` | табличные части |
|
||||
|
||||
**Формы.** Ссылка на форму — `Тип.Объект.Form.ИмяФормы` (напр. `Catalog.Организации.Form.ФормаЭлемента`).
|
||||
Слоты основных форм: `defaultObjectForm`, `defaultFolderForm`, `defaultListForm`, `defaultChoiceForm`,
|
||||
`defaultFolderChoiceForm`; вспомогательных — те же имена с префиксом `auxiliary` (`auxiliaryObjectForm`, …).
|
||||
|
||||
## `predefined` — предопределённые элементы
|
||||
|
||||
Массив предопределённых элементов → `Ext/Predefined.xml`. Элемент — строка (плоский случай) или объект (иерархия).
|
||||
|
||||
**Строка:** `"(Код) Имя [Наименование]"` — `Имя` обязательно; `(Код)` и `[Наименование]` опциональны.
|
||||
Без `[...]` наименование выводится из имени; `[]` — пустое; `[текст]` — заданное.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"Основной",
|
||||
"(1) ДокументОПриемке [Документ о приемке]",
|
||||
{ "name": "Группа1", "isFolder": true, "description": "Прочие",
|
||||
"childItems": ["Факс", "(7) Скайп"] }
|
||||
]
|
||||
```
|
||||
|
||||
**Объект:** `name` (обязательно), `code`, `description` (наименование), `isFolder` (признак группы),
|
||||
`childItems` (вложенные, рекурсивно). Тип кода — по свойству `codeType`.
|
||||
|
||||
## Дополнительно
|
||||
|
||||
- Свойства реквизитов и табличных частей — `attributes.md`.
|
||||
- Представления (`objectPresentation`, `listPresentation`, …), команды объекта, характеристики
|
||||
(«ДопРеквизиты и сведения»), кастомизация стандартных реквизитов, `inputByString` / `dataLockFields` /
|
||||
`basedOn` — `blocks.md`.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Планы: ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes
|
||||
|
||||
Все три — ссылочные типы (наследуют слой Catalog: коды, `standardAttributes`, `characteristics`, `inputByString`,
|
||||
формы, представления — см. `catalog.md` / `attributes.md` / `blocks.md`) с предопределёнными элементами и своими
|
||||
специальными свойствами.
|
||||
|
||||
## ChartOfCharacteristicTypes (План видов характеристик)
|
||||
|
||||
Хранит определения характеристик (видов). Иерархический (папки+элементы).
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | любой примитив | тип значения характеристики (составной — строка `"A + B"` или массив `valueTypes`) |
|
||||
| `characteristicExtValues` | пусто | ссылка на справочник доп. значений |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `foldersOnTop` | `true` | bool |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `codeSeries` | `WholeCharacteristicKind` | серия кодов |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды (несут тип значения — см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Предопределённые виды** несут **тип значения на элемент** — короткой строкой после `:`
|
||||
(`"(Код) Имя [Наименование]: Тип"`, составной через `+`) или объектной формой с ключом `type`:
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"(000001) Цвет: CatalogRef.Цвета",
|
||||
"(000002) Размер [Размер одежды]: String(50) + Number(3,0)",
|
||||
{ "name": "Группа", "isFolder": true, "type": "" }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfAccounts (План счетов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `extDimensionTypes` | пусто | ссылка на ПВХ видов субконто `ChartOfCharacteristicTypes.X` |
|
||||
| `maxExtDimensionCount` | `0` (без ПВХ) / `3` (с ПВХ) | макс. число субконто |
|
||||
| `codeMask` | пусто | маска кода счёта (напр. `"@@@.@@"`) |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `codeSeries` | `WholeChartOfAccounts` | серия кодов |
|
||||
| `defaultPresentation` | `AsCode` | `AsCode` / `AsDescription` |
|
||||
| `autoOrderByCode` | `true` | bool |
|
||||
| `orderLength` | `9` | длина строки упорядочивания |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `accountingFlags` | `[]` | признаки учёта (как реквизиты, тип по умолчанию Boolean; массив имён/реквизитов) |
|
||||
| `extDimensionAccountingFlags` | `[]` | признаки учёта субконто (как реквизиты) |
|
||||
| `predefined` | `[]` | предопределённые счета (см. ниже) |
|
||||
|
||||
**Предопределённый счёт** (объектная форма):
|
||||
|
||||
| Поле | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `name` | — | имя (обязательно) |
|
||||
| `code` | пусто | код счёта |
|
||||
| `description` | из имени | наименование |
|
||||
| `accountType` | `ActivePassive` | `Active` / `Passive` / `ActivePassive` |
|
||||
| `offBalance` | `false` | bool (забалансовый) |
|
||||
| `order` | — | строка сортировки |
|
||||
| `flags` | `[]` | включённые признаки учёта (только TRUE) |
|
||||
| `subconto` | `[]` | виды субконто (см. ниже) |
|
||||
| `childItems` | `[]` | подчинённые счета |
|
||||
|
||||
`subconto` — строка `"Вид | Признак1, Признак2"` (после `|` — включённые признаки учёта субконто; токен `Turnover` —
|
||||
«только обороты») или объект `{ type, turnover, flags }`. `Вид` — имя предопределённого вида из ПВХ `extDimensionTypes`.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
{ "name": "ОсновныеСредства", "code": "01", "accountType": "Active", "order": " 01",
|
||||
"flags": ["Количественный"], "subconto": ["Номенклатура | Суммовой, Валютный"],
|
||||
"childItems": [ { "name": "ОСВОрганизации", "code": "01.01", "accountType": "Active", "order": " 01.01" } ] }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfCalculationTypes (План видов расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `codeLength` | `5` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `dependenceOnCalculationTypes` | `DontUse` | `DontUse` / `OnPeriod` / `OnActionPeriod` |
|
||||
| `baseCalculationTypes` | `[]` | базовые виды расчёта (список ссылок `ChartOfCalculationTypes.X`) |
|
||||
| `actionPeriodUse` | `false` | bool (использовать период действия) |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды расчёта (см. ниже) |
|
||||
|
||||
**Предопределённый вид расчёта** — плоский: строка `"(Код) Имя [Наименование]"` или объект
|
||||
`{ name, code, description, actionPeriodIsBase }` (`actionPeriodIsBase` — bool, по умолчанию `false`).
|
||||
|
||||
```json
|
||||
"predefined": [ "(00001) Оклад [Оклад по дням]", { "name": "Премия", "code": "00002", "actionPeriodIsBase": true } ]
|
||||
```
|
||||
|
||||
> **ChartOfAccounts** ссылается на ПВХ через `extDimensionTypes`. Регистр бухгалтерии/расчёта требует
|
||||
> соответствующий план (см. `registers.md`).
|
||||
@@ -1,56 +0,0 @@
|
||||
# CommonModule, ScheduledJob, EventSubscription (объекты, привязанные к коду)
|
||||
|
||||
## CommonModule (Общий модуль)
|
||||
|
||||
Флаги контекста выполнения (все bool, по умолчанию `false`). Создаёт пустой `Ext/Module.bsl`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `context` | — | шорткат флагов (см. ниже) |
|
||||
| `global` | `false` | bool |
|
||||
| `server` | `false` | bool |
|
||||
| `serverCall` | `false` | bool (вызов сервера) |
|
||||
| `clientManagedApplication` | `false` | bool (клиент управляемого приложения) |
|
||||
| `clientOrdinaryApplication` | `false` | bool (клиент обычного приложения) |
|
||||
| `externalConnection` | `false` | bool |
|
||||
| `privileged` | `false` | bool |
|
||||
| `returnValuesReuse` | `DontUse` | `DontUse` / `DuringRequest` / `DuringSession` |
|
||||
|
||||
Шорткат `context`: `"server"` → Server+ServerCall; `"client"` → ClientManagedApplication;
|
||||
`"serverClient"` → Server+ClientManagedApplication.
|
||||
|
||||
```json
|
||||
{ "type": "CommonModule", "name": "ОбменДаннымиСервер", "context": "server", "returnValuesReuse": "DuringRequest" }
|
||||
```
|
||||
|
||||
## ScheduledJob (Регламентное задание)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `methodName` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
| `description` | пусто | наименование задания |
|
||||
| `key` | пусто | ключ |
|
||||
| `use` | `false` | bool (использование) |
|
||||
| `predefined` | `false` | bool (предопределённое) |
|
||||
| `restartCountOnFailure` | `3` | число повторов при сбое |
|
||||
| `restartIntervalOnFailure` | `10` | интервал повтора, сек |
|
||||
|
||||
```json
|
||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить", "use": true }
|
||||
```
|
||||
|
||||
## EventSubscription (Подписка на событие)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `source` | `[]` | объекты-источники: `["CatalogObject.Контрагенты", "DocumentObject.Реализация"]` |
|
||||
| `event` | `BeforeWrite` | `BeforeWrite` / `OnWrite` / `BeforeDelete` / `OnReadAtServer` / `FillCheckProcessing` … |
|
||||
| `handler` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
|
||||
```json
|
||||
{ "type": "EventSubscription", "name": "ПередЗаписьюКонтрагента",
|
||||
"source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite",
|
||||
"handler": "ОбщегоНазначенияСервер.ПередЗаписьюКонтрагента" }
|
||||
```
|
||||
|
||||
> Процедура-обработчик (`methodName` / `handler`) должна существовать в указанном общем модуле (экспортная).
|
||||
@@ -1,79 +0,0 @@
|
||||
# Document, DocumentJournal, Sequence, DocumentNumerator
|
||||
|
||||
## Document (Документ)
|
||||
|
||||
```json
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] } }
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `numerator` | пусто | ссылка на нумератор `DocumentNumerator.X` |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / `Month` / `Quarter` / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `posting` | `Allow` | `Allow` / `Deny` (проведение) |
|
||||
| `realTimePosting` | `Deny` | `Allow` / `Deny` (оперативное проведение) |
|
||||
| `registerRecordsDeletion` | `AutoDelete` | `AutoDelete` / `AutoDeleteOnUnpost` / `AutoDeleteOff` |
|
||||
| `registerRecordsWritingOnPost` | `WriteSelected` | `WriteModified` / `WriteSelected` / `WriteAll` |
|
||||
| `sequenceFilling` | `AutoFill` | заполнение последовательностей |
|
||||
| `postInPrivilegedMode` | `true` | bool |
|
||||
| `unpostInPrivilegedMode` | `true` | bool |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `registerRecords` | `[]` | движения: список ссылок `["AccumulationRegister.ОстаткиТоваров", "InformationRegister.Цены"]` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Формы: `defaultObjectForm`, `defaultListForm`, `defaultChoiceForm`, `auxiliary*` (см. `catalog.md`).
|
||||
Реквизиты и ТЧ — `attributes.md`. Представления, команды, характеристики, `basedOn`, `standardAttributes`,
|
||||
`inputByString`, `dataLockFields` — `blocks.md`.
|
||||
|
||||
## DocumentJournal (Журнал документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registeredDocuments` | `[]` | документы журнала: `["Document.Встреча", "Document.Звонок"]` |
|
||||
| `columns` | `[]` | графы журнала (см. ниже) |
|
||||
|
||||
Графа — строка `"Имя"` или объект `{ name, synonym, indexing, references }`, где `indexing` — `Index`/`DontIndex`,
|
||||
`references` — пути к реквизитам документов, отображаемым в графе.
|
||||
|
||||
```json
|
||||
{ "type": "DocumentJournal", "name": "Взаимодействия",
|
||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
||||
"columns": [{ "name": "Организация", "indexing": "Index",
|
||||
"references": ["Document.Встреча.Attribute.Организация"] }] }
|
||||
```
|
||||
|
||||
## Sequence (Последовательность документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `moveBoundaryOnPosting` | `DontMove` | сдвиг границы при проведении |
|
||||
| `documents` | `[]` | документы последовательности (список ссылок) |
|
||||
| `registerRecords` | `[]` | движения (список ссылок) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` | `[]` | измерения `{name, type, documentMap[], registerRecordsMap[]}` |
|
||||
|
||||
`documentMap` / `registerRecordsMap` — пути к реквизитам документов / движениям, соответствующим измерению.
|
||||
|
||||
## DocumentNumerator (Нумератор документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / … / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
@@ -1,41 +0,0 @@
|
||||
# ExchangePlan (План обмена)
|
||||
|
||||
Близок к справочнику (без иерархии/владельцев), плюс состав объектов обмена. Наследует слой Catalog:
|
||||
`codeLength`, `codeAllowedLength`, `descriptionLength`, `defaultPresentation`, `editType`, `quickChoice`,
|
||||
`choiceMode`, формы, `standardAttributes`, `characteristics`, `inputByString`, `basedOn`, представления —
|
||||
см. `catalog.md` / `attributes.md` / `blocks.md`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `distributedInfoBase` | `false` | bool (распределённая ИБ — РИБ) |
|
||||
| `includeConfigurationExtensions` | `false` | bool (включать расширения конфигурации) |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `content` | `[]` | состав обмена (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
## `content` — состав плана обмена
|
||||
|
||||
Список объектов-участников обмена; у каждого — признак авторегистрации изменений (по умолчанию выключена).
|
||||
Элемент — ссылка на объект метаданных (строка) или объект с признаком:
|
||||
|
||||
```json
|
||||
"content": [
|
||||
"Catalog.Организации", // авторегистрация выключена
|
||||
"InformationRegister.Курсы: autoRecord", // авторегистрация включена (токен)
|
||||
{ "metadata": "Document.РеализацияТоваров", "autoRecord": true }
|
||||
]
|
||||
```
|
||||
|
||||
- Строка `"Тип.Имя"` — авторегистрация выключена; суффикс `: autoRecord` — включена.
|
||||
- Объект: `metadata` (ссылка), `autoRecord` (bool или `Allow`/`Deny`).
|
||||
|
||||
```json
|
||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "distributedInfoBase": false,
|
||||
"content": ["Catalog.Номенклатура: autoRecord", "Catalog.Контрагенты: autoRecord"],
|
||||
"attributes": ["АдресСервера: String(200)"] }
|
||||
```
|
||||
@@ -1,147 +0,0 @@
|
||||
# ExternalDataSource (внешний источник данных)
|
||||
|
||||
Источник описывается **одним JSON целиком**: сам источник, его таблицы с полями и функции.
|
||||
Результат — `ExternalDataSources/<Имя>.xml` плюс по файлу на таблицу в `<Имя>/Tables/`.
|
||||
|
||||
Строку соединения, пользователя, пароль и тип СУБД задавать не нужно: в конфигурации их нет,
|
||||
они настраиваются в режиме «Предприятие» и хранятся в базе.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ExternalDataSource",
|
||||
"name": "PG",
|
||||
"tables": {
|
||||
"prices": ["product_id: Number(10,0)", "period: Date", "price: Number(15,2)"],
|
||||
"products": {
|
||||
"nameInDataSource": "eds.public.products",
|
||||
"tableDataType": "ObjectData",
|
||||
"keyFields": ["id"],
|
||||
"presentationField": "name",
|
||||
"fields": [
|
||||
"id: Number(10,0)",
|
||||
"name: String(150)",
|
||||
"article: String(50) | nullable",
|
||||
"parent_id: ExternalDataSourceTableRef.PG.products | nullable"
|
||||
]
|
||||
}
|
||||
},
|
||||
"functions": {
|
||||
"total": { "expression": "public.f_total(&1, &2)", "returns": "Number(15,2)" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Свойства источника
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `dataLockControlMode` | `Automatic` | `Automatic` / `Managed` / `AutomaticAndManaged` |
|
||||
| `tables` | `{}` | таблицы (см. ниже) |
|
||||
| `functions` | `{}` | функции (см. ниже) |
|
||||
|
||||
При `AutomaticAndManaged` режим блокировок решает каждая таблица сама; при конкретном значении
|
||||
одноимённое свойство таблицы игнорируется платформой.
|
||||
|
||||
## Таблицы
|
||||
|
||||
Ключ — имя таблицы в конфигурации. Значение — **массив полей** либо **объект** со свойствами
|
||||
и ключом `fields` (та же двойственность, что у `tabularSections` справочника).
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `nameInDataSource` | = имя таблицы | имя физической таблицы; для реляционной СУБД обычно `<база>.<схема>.<таблица>` |
|
||||
| `tableType` | `Table` | `Table` — таблица или представление; `Expression` — табличная функция |
|
||||
| `readOnly` | `false` | запрет записи; ставь `true` для представлений и таблиц вида `Expression` — писать в них нельзя |
|
||||
| `expressionInDataSource` | пусто | выражение для `Expression`, напр. `public.f_by_parent(&1)`; имя базы не указывается |
|
||||
| `tableDataType` | `NonobjectData` | `ObjectData` (запись определяется одним полем) / `NonobjectData` |
|
||||
| `keyFields` | `[]` | имена ключевых полей; без них таблица собирается, но недоступны форма записи и набор записей |
|
||||
| `presentationField` | пусто | имя поля представления (только `ObjectData`) |
|
||||
| `parentField` | пусто | имя поля родителя; его тип должен быть ссылкой на эту же таблицу |
|
||||
| `inputByString` | `[]` | имена полей ввода по строке; ключа нет → берётся `presentationField` |
|
||||
| `dataVersionField` | пусто | имя поля версии данных |
|
||||
| `dataLockFields` | `[]` | имена полей блокировки |
|
||||
| `transactionsIsolationLevel` | `Auto` | `Auto` / `ReadUncommitted` / `ReadCommitted` / `RepeatableRead` / `Serializable` |
|
||||
| `dataLockControlMode` | `Automatic` | `Automatic` / `Managed` / `AutomaticAndManaged` |
|
||||
| `basedOn` | `[]` | ввод на основании, ссылки вида `Catalog.Контрагенты` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `quickChoice` | `false` | bool |
|
||||
| `editType` | `InDialog` | `InDialog` / `InList` |
|
||||
| `fields` | `[]` | поля (см. ниже); синоним — `columns` |
|
||||
|
||||
Ссылки на поля (`keyFields`, `presentationField`, `parentField`, `dataVersionField`,
|
||||
`inputByString`, `dataLockFields`) задаются **короткими именами полей** этой же таблицы.
|
||||
|
||||
Прочие свойства — представления (`objectPresentation`, `listPresentation`, …), формы по умолчанию
|
||||
(`defaultObjectForm`, `defaultListForm`, …), `characteristics`, `explanation`,
|
||||
`includeHelpInContents` — как у справочника.
|
||||
|
||||
**Значение незаполненного родителя загрузкой XML не задаётся** — платформа сбрасывает его в пустое
|
||||
при любой загрузке, включая загрузку собственной выгрузки. Ставится только в Конфигураторе вручную.
|
||||
|
||||
## Поля
|
||||
|
||||
Строковая и объектная форма — те же, что у реквизитов (см. `attributes.md`). Своих ключа три:
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `nameInDataSource` | = имя поля | имя колонки; в одинарных кавычках уходит в SQL как есть |
|
||||
| `readOnly` | `false` | поле не записывается (вычисляемые, автоинкрементные) |
|
||||
| `allowNull` | `false` | допускает `NULL` |
|
||||
|
||||
Флаги строковой формы: `readonly`, `nullable`.
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
"id: Number(10,0) | readonly",
|
||||
{ "name": "article", "type": "String(50)", "nameInDataSource": "art_code", "allowNull": true }
|
||||
]
|
||||
```
|
||||
|
||||
Допустимые типы: `Number`, `String`, `Date`, `Boolean`, `UUID`, `BinaryData` и ссылка на таблицу
|
||||
внешнего источника — `ExternalDataSourceTableRef.<Источник>.<Таблица>`.
|
||||
|
||||
Двоичные данные: `BinaryData` — безлимит (так их пишет платформа при импорте из СУБД),
|
||||
`BinaryData(N)` — переменной длины, `BinaryData(N,fixed)` — фиксированной.
|
||||
|
||||
**Составной тип у поля недопустим** — платформа такую конфигурацию не загружает.
|
||||
|
||||
## Функции
|
||||
|
||||
Ключ — имя функции. Значение — строка (интерпретируется как `expression`) либо объект.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `expression` | — | выражение в источнике, обязательный |
|
||||
| `returns` | `String` | тип возвращаемого значения |
|
||||
| `returnValue` | `true` | `false` — процедура, тип не пишется |
|
||||
|
||||
Параметры описываются прямо в выражении как `&1`, `&2` — отдельного ключа для них нет.
|
||||
Необязательные — в фигурных скобках `f(&1{, &2})`, переменное число — `&n[]` (только последним).
|
||||
|
||||
```json
|
||||
"functions": {
|
||||
"nextKey": "NEXT VALUE FOR dbo.SimpleSequence",
|
||||
"total": { "expression": "public.f_total(&1, &2)", "returns": "Number(15,2)" }
|
||||
}
|
||||
```
|
||||
|
||||
## Добавить в существующий источник
|
||||
|
||||
`meta-compile` описывает источник **целиком**: повторный запуск заменяет его файл и выдаёт новый
|
||||
uuid, а таблицы, которых нет в описании, останутся на диске сиротами. Чтобы дописать таблицу или
|
||||
функцию в уже существующий источник, есть `meta-edit`:
|
||||
|
||||
```json
|
||||
{ "add": {
|
||||
"tables": { "sales": { "keyFields": ["id"], "fields": ["id: Number(10,0)", "summa: Number(15,2)"] } },
|
||||
"functions": { "nextKey": "NEXT VALUE FOR public.seq_key" }
|
||||
} }
|
||||
```
|
||||
|
||||
Удалить таблицу — `meta-remove ExternalDataSource.<Источник>.Table.<Таблица>`.
|
||||
|
||||
## Не поддерживается
|
||||
|
||||
- **Кубы OLAP** (`Cube`, `DimensionTable`, `Dimension`, `Resource`).
|
||||
- **Формы и модули** таблиц — форму добавляет навык `form-add`, содержимое собирает `form-compile`.
|
||||
Набор назначений зависит от `tableDataType`: `ObjectData` — Object/List/Choice, `NonobjectData` — Record/List/Choice.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Прочие типы
|
||||
|
||||
Редкие/служебные объекты. Каждый — минимальный набор свойств.
|
||||
|
||||
## FunctionalOption (Функциональная опция)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `location` | пусто | где хранится значение: `Constant.X` / `InformationRegister.X.Resource.Y` / `<Тип>.X.Attribute.Y` |
|
||||
| `content` | `[]` | реквизиты/измерения/ресурсы, зависящие от опции (полные пути к объектам) |
|
||||
| `privilegedGetMode` | `true` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FunctionalOption", "name": "ВестиУчетПоСкладам", "location": "Constant.ВестиУчетПоСкладам",
|
||||
"content": ["Document.РеализацияТоваров.TabularSection.Товары.Attribute.Склад"] }
|
||||
```
|
||||
|
||||
## FilterCriterion (Критерий отбора)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | — | тип значения отбора (составной через `+`) |
|
||||
| `content` | `[]` | реквизиты, по которым идёт отбор (пути к объектам) |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FilterCriterion", "name": "ДокументыПоКонтрагенту", "valueType": "CatalogRef.Контрагенты",
|
||||
"content": ["Document.Реализация.Attribute.Контрагент"] }
|
||||
```
|
||||
|
||||
## SettingsStorage (Хранилище настроек)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `defaultSaveForm` / `defaultLoadForm` | пусто | формы сохранения / загрузки |
|
||||
| `auxiliarySaveForm` / `auxiliaryLoadForm` | пусто | вспомогательные формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
## CommonForm (Общая форма)
|
||||
|
||||
Создаёт метаданные + заготовку формы. Содержимое формы наполняется `/form-compile` или `/form-edit`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `formType` | `Managed` | тип формы |
|
||||
| `usePurposes` | `[PlatformApplication, MobilePlatformApplication]` | назначение (массив) |
|
||||
| `useStandardCommands` | `false` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "CommonForm", "name": "НастройкиОбмена", "usePurposes": ["PlatformApplication"] }
|
||||
```
|
||||
|
||||
## CommonPicture / CommonTemplate (Общие картинки и макеты)
|
||||
|
||||
Только метаданные + регистрация; содержимое (`Ext/Picture*`, `Ext/Template.*`) импортируется отдельно
|
||||
(для табличного макета — `/mxl-compile`).
|
||||
|
||||
- **CommonPicture** — `availabilityForChoice` / `availabilityForAppearance` (bool, по умолчанию `false`).
|
||||
- **CommonTemplate** — `templateType` (`SpreadsheetDocument` по умолчанию / `TextDocument` / `HTMLDocument` /
|
||||
`BinaryData` / `AddIn` / `DataCompositionSchema` / `DataCompositionAppearanceTemplate` / `GraphicalSchema`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonTemplate", "name": "ПечатьЗаказа", "templateType": "SpreadsheetDocument" }
|
||||
```
|
||||
|
||||
## Служебные типы
|
||||
|
||||
- **SessionParameter** (параметр сеанса) — `valueType` (тип значения, составной через `+`).
|
||||
- **FunctionalOptionsParameter** (параметр функциональной опции) — `use` (массив измерений/реквизитов).
|
||||
- **WSReference** (WS-ссылка) — `locationURL` (URL WSDL).
|
||||
- **CommandGroup** (группа команд) — `category` (по умолч. `NavigationPanel`) — где размещается группа:
|
||||
`NavigationPanel` / `ActionsPanel` (командный интерфейс раздела) или `FormCommandBar` / `FormNavigationPanel`
|
||||
(командный интерфейс формы); `representation` (`Auto`), `tooltip` (ML), `picture`. Команды объекта ссылаются на
|
||||
группу через `group: "CommandGroup.<Имя>"` (см. `blocks.md`).
|
||||
- **CommonCommand** (общая команда) — `group`, `representation`, `tooltip`, `picture`, `shortcut`,
|
||||
`commandParameterType`, `parameterUseMode` (`Single`/`Multiple`), `modifiesData`, `includeHelpInContents`.
|
||||
Создаёт `Ext/CommandModule.bsl`.
|
||||
- **CommonAttribute** (общий реквизит) — `valueType` (по умолчанию `String(0)`) + свойства реквизита
|
||||
(`attributes.md`) + `content` (объекты, куда входит реквизит) + свойства разделения данных
|
||||
(`dataSeparation`, `separatedDataUse`, `usersSeparation`, … — по умолчанию `DontUse`/`Independently`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonAttribute", "name": "Организация", "valueType": "CatalogRef.Организации",
|
||||
"autoUse": "Use", "content": ["Document.РеализацияТоваров", "Document.ПоступлениеТоваров"] }
|
||||
```
|
||||
@@ -1,49 +0,0 @@
|
||||
# BusinessProcess, Task (Бизнес-процессы и Задачи)
|
||||
|
||||
Ссылочные типы. Наследуют слой Catalog (нумерация, формы, `standardAttributes`, `characteristics`, `basedOn`,
|
||||
представления — см. `catalog.md` / `attributes.md` / `blocks.md`). Бизнес-процесс всегда связан с задачей.
|
||||
|
||||
## BusinessProcess (Бизнес-процесс)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `task` | пусто | ссылка на задачу `Task.X` (обязательна для рабочего БП) |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Создаётся с картой маршрута (`Ext/Flowchart.xml`) и модулем объекта.
|
||||
|
||||
```json
|
||||
{ "type": "BusinessProcess", "name": "Согласование", "task": "Task.ЗадачаИсполнителя",
|
||||
"attributes": ["Документ: DocumentRef.ЗаявкаНаРасход"] }
|
||||
```
|
||||
|
||||
## Task (Задача)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `14` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `addressing` | пусто | ссылка на регистр сведений адресации `InformationRegister.X` |
|
||||
| `mainAddressingAttribute` | пусто | основной реквизит адресации (имя реквизита адресации) |
|
||||
| `currentPerformer` | пусто | реквизит текущего исполнителя |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `addressingAttributes` | `[]` | реквизиты адресации (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Реквизит адресации** — shorthand `"Имя: Тип"` или объект `{ name, type, addressingDimension }`
|
||||
(`addressingDimension` — измерение регистра адресации).
|
||||
|
||||
```json
|
||||
{ "type": "Task", "name": "ЗадачаИсполнителя",
|
||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи", "Роль: CatalogRef.Роли"] }
|
||||
```
|
||||
@@ -1,76 +0,0 @@
|
||||
# Регистры: Information, Accumulation, Accounting, Calculation
|
||||
|
||||
**Измерения и ресурсы** задаются как реквизиты (shorthand `"Имя: Тип | флаги"` или объектная форма, см.
|
||||
`attributes.md`). Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (регистр накопления).
|
||||
|
||||
```json
|
||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
```
|
||||
|
||||
## InformationRegister (Регистр сведений)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `writeMode` | `Independent` | `Independent` / `RecorderSubordinate` |
|
||||
| `periodicity` | `Nonperiodical` | `Nonperiodical` / `Second` / `Day` / `Month` / `Quarter` / `Year` / `RecorderPosition` |
|
||||
| `mainFilterOnPeriod` | `false` | bool (основной отбор по периоду) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
## AccumulationRegister (Регистр накопления)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registerType` | `Balance` | `Balance` (остатки) / `Turnovers` (обороты) |
|
||||
| `enableTotalsSplitting` | `true` | bool (разделение итогов) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура", "Склад: CatalogRef.Склады"],
|
||||
"resources": ["Количество: Number(15,3)"] }
|
||||
```
|
||||
|
||||
## AccountingRegister (Регистр бухгалтерии)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfAccounts` | — | **обязательно**: ссылка на план счетов `ChartOfAccounts.X` |
|
||||
| `correspondence` | `false` | bool (корреспонденция) |
|
||||
| `periodAdjustmentLength` | `0` | длина периода корректировки |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccountingRegister", "name": "Хозрасчетный",
|
||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
||||
"dimensions": ["Организация: CatalogRef.Организации"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
## CalculationRegister (Регистр расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfCalculationTypes` | — | **обязательно**: ссылка на ПВР `ChartOfCalculationTypes.X` |
|
||||
| `periodicity` | `Month` | периодичность |
|
||||
| `actionPeriod` | `false` | bool (период действия) |
|
||||
| `basePeriod` | `false` | bool (базовый период) |
|
||||
| `schedule` | пусто | ссылка на регистр сведений графиков |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "CalculationRegister", "name": "Начисления",
|
||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления", "periodicity": "Month",
|
||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
> **AccountingRegister** требует план счетов, **CalculationRegister** — план видов расчёта (и оба — документ-регистратор).
|
||||
@@ -1,45 +0,0 @@
|
||||
# Report, DataProcessor (Отчёты и Обработки)
|
||||
|
||||
Почти идентичны по составу: реквизиты, табличные части, формы, макеты, команды. Модуль объекта — `Ext/ObjectModule.bsl`.
|
||||
Реквизиты и ТЧ — `attributes.md`; команды — `blocks.md`.
|
||||
|
||||
Ссылки на формы/схемы/хранилища пишутся **как есть** (имя формы может быть буквально «Форма»).
|
||||
|
||||
## Report (Отчёт)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool (доступность через стандартный командный интерфейс) |
|
||||
| `mainDataCompositionSchema` | пусто | основной макет СКД (`Report.X.Template.ОсновнаяСхемаКомпоновкиДанных`) |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `defaultSettingsForm` / `auxiliarySettingsForm` / `defaultVariantForm` | пусто | формы настроек / вариантов |
|
||||
| `variantsStorage` / `settingsStorage` | пусто | хранилища вариантов / настроек (`SettingsStorage.X`) |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "Report", "name": "АнализПродаж", "useStandardCommands": false,
|
||||
"mainDataCompositionSchema": "Report.АнализПродаж.Template.ОсновнаяСхемаКомпоновкиДанных",
|
||||
"attributes": ["Период: StandardPeriod"] }
|
||||
```
|
||||
|
||||
## DataProcessor (Обработка)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "DataProcessor", "name": "ЗагрузкаТаблиц", "useStandardCommands": false,
|
||||
"attributes": [{ "name": "Таблица", "type": "ValueTree" }, { "name": "Произвольные", "type": "" }] }
|
||||
```
|
||||
|
||||
> Реквизиты отчётов/обработок допускают платформенные типы-коллекции: `ValueTable`, `ValueTree`, `ValueList`,
|
||||
> `StandardPeriod`, `SpreadsheetDocument` и др., а также `"type": ""` — реквизит без типа.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Enum, Constant, DefinedType
|
||||
|
||||
## Enum (Перечисление)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `values` | `[]` | значения перечисления (массив имён или объектов) |
|
||||
|
||||
Значение — строка `"ИмяЗначения"` или объект `{ name, synonym }`.
|
||||
|
||||
```json
|
||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
||||
```
|
||||
|
||||
## Constant (Константа)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | `String` | тип значения (shorthand типа) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
|
||||
`valueType` принимает shorthand: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`,
|
||||
составной через `+`.
|
||||
|
||||
```json
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||
```
|
||||
|
||||
## DefinedType (Определяемый тип)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueTypes` | `[]` | состав типа (массив shorthand-типов) |
|
||||
| `valueType` | — | то же одной строкой (`"A + B"`) или строкой одного типа |
|
||||
|
||||
```json
|
||||
{ "type": "DefinedType", "name": "ДенежныеСредства",
|
||||
"valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
||||
```
|
||||
@@ -1,70 +0,0 @@
|
||||
# HTTPService, WebService (Веб-сервисы)
|
||||
|
||||
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
||||
|
||||
## HTTPService (HTTP-сервис)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||
|
||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||
- строка — URL-путь без методов: `"/health"`;
|
||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `synonym`, `comment`,
|
||||
`methods` — `{ "ИмяМетода": def }`.
|
||||
|
||||
`methods` — значение либо строка (только HTTP-метод), либо объект: `httpMethod`, `handler`,
|
||||
`synonym`, `comment`.
|
||||
|
||||
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||
Обработчик по умолчанию именуется `{ИмяШаблона}{ИмяМетода}`; в типовых конфигурациях он часто
|
||||
произвольный — тогда задавайте `handler` явно.
|
||||
|
||||
```json
|
||||
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
||||
"urlTemplates": {
|
||||
"Users": { "template": "/v1/users/{id}", "methods": { "Get": "GET", "Create": "POST", "Delete": "DELETE" } },
|
||||
"Health": "/health"
|
||||
} }
|
||||
```
|
||||
|
||||
## WebService (Веб-сервис, SOAP)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `namespace` | пусто | URI пространства имён WSDL |
|
||||
| `xdtoPackages` | пусто | список пакетов (см. ниже) |
|
||||
| `descriptorFileName` | `= name` + `.1cws` | имя файла дескриптора |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `operations` | `{}` | операции (см. ниже) |
|
||||
|
||||
`xdtoPackages` — **массив** значений: `"XDTOPackage.Имя"` — пакет конфигурации, любое другое
|
||||
значение — URI внешнего пространства имён (например `"http://v8.1c.ru/8.3/data/ext"`).
|
||||
|
||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||
`procedureName` (имя процедуры, по умолчанию = имя операции; синоним ключа — `handler`),
|
||||
`dataLockControlMode` (по умолчанию `Managed`), `synonym`, `comment`, `parameters`.
|
||||
|
||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||
- строка — XDTO-тип (`direction` = `In`);
|
||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`),
|
||||
`direction` (`In` / `Out` / `InOut`), `synonym`, `comment`.
|
||||
|
||||
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||
Тип из собственного пространства имён задаётся в нотации Кларка — `"{http://ваш.uri}ИмяТипа"`;
|
||||
компилятор сам объявит локальный `xmlns` в теге, как это делает платформа.
|
||||
|
||||
```json
|
||||
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
||||
"operations": {
|
||||
"TestConnection": { "returnType": "xs:boolean", "handler": "ПроверкаПодключения",
|
||||
"parameters": { "ErrorMessage": { "type": "xs:string", "direction": "Out" } } },
|
||||
"GetVersion": "xs:string"
|
||||
} }
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: meta-decompile
|
||||
description: Декомпиляция объекта метаданных 1С в JSON-заготовку формата meta-compile. Используй когда нужно получить черновик DSL-описания нового объекта по образцу другого. Не сохраняет UUID/модули/формы.
|
||||
argument-hint: <ObjectPath> [-OutputPath <out.json>]
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /meta-decompile — DSL-заготовка из XML объекта метаданных
|
||||
|
||||
Читает XML объекта метаданных (`Catalogs/Имя.xml` и т.п.) и эмитит компактный JSON в формате `/meta-compile`. Назначение — **взять существующий объект образцом и собрать по нему НОВЫЙ**: декомпилировать → поправить → скомпилировать под другим именем.
|
||||
|
||||
## ⚠️ Главное: это НЕ обратимая выгрузка
|
||||
|
||||
Компиляция черновика создаёт **новый объект с новой идентичностью**, а не копию исходного. В JSON **не** попадают: UUID (идентичность самого объекта и всех дочерних), тела модулей, формы, макеты, права. Захватываются только структура и свойства.
|
||||
|
||||
Отсюда правило: **никогда не компилируй черновик поверх объекта-источника и не выдавай его за «реимпорт»** — у пересобранного объекта другие UUID, поэтому все ссылки на исходный объект (из кода, других объектов, состава подсистем, предопределённых данных) сломаются, а код модулей и формы пропадут.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
**Собрать новый объект по образцу существующего** — получить DSL-заготовку рабочего объекта, переименовать и адаптировать состав, скомпилировать в новый. Быстрее, чем писать DSL с нуля для богатого объекта.
|
||||
|
||||
## Когда **не** использовать
|
||||
|
||||
- **Точечная правка существующего объекта** (добавить реквизит, ТЧ, свойство) → `/meta-edit`. Цикл decompile→compile тут вреден: даёт объект с новой идентичностью и теряет модули/формы.
|
||||
- **Сохранить / восстановить / перенести тот же объект** (бэкап, миграция между конфигурациями с сохранением ссылок) → штатная выгрузка 1С (`/db-dump-xml` ↔ `/db-load-xml`, CF), а не decompile.
|
||||
- **Просто понять структуру** объекта (реквизиты, ТЧ, типы) без пересборки → `/meta-info` (дешевле, не плодит файл).
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `ObjectPath` | Путь к XML объекта (`Catalogs/Имя.xml`), обязательный |
|
||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File ".agents/skills/meta-decompile/scripts/meta-decompile.ps1" -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user