mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-08-17 16:50:30 +03:00
Compare commits
259
Commits
@@ -1,4 +1,4 @@
|
||||
# cf-edit v1.10 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||
@@ -163,10 +163,17 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
Assert-EditAllowed $resolvedPath 'editable'
|
||||
|
||||
# --- Load XML with PreserveWhitespace ---
|
||||
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
|
||||
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
|
||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$script:xmlDoc.PreserveWhitespace = $true
|
||||
$script:xmlDoc.Load($resolvedPath)
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
|
||||
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
|
||||
|
||||
$script:addCount = 0
|
||||
$script:removeCount = 0
|
||||
$script:modifyCount = 0
|
||||
@@ -686,7 +693,9 @@ $bodyBlock$declarations
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote panel layout: $caiPath"
|
||||
}
|
||||
@@ -864,7 +873,7 @@ function Do-SetHomePage($valArg) {
|
||||
|
||||
$hpXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
|
||||
$leftXml
|
||||
$rightXml
|
||||
@@ -875,7 +884,9 @@ $rightXml
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote home page layout: $hpPath"
|
||||
}
|
||||
@@ -977,6 +988,14 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-edit v1.10 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.19 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,65 @@ import uuid as _uuid
|
||||
from html import escape as html_escape
|
||||
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
|
||||
@@ -341,21 +400,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (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"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #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 — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -379,7 +439,7 @@ def main():
|
||||
parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page"])
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
if args.DefinitionFile and args.Operation:
|
||||
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
||||
@@ -410,6 +470,10 @@ def main():
|
||||
tree = etree.parse(resolved_path, xml_parser)
|
||||
xml_root = tree.getroot()
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
format_version = xml_root.get('version') or '2.17'
|
||||
|
||||
add_count = 0
|
||||
remove_count = 0
|
||||
modify_count = 0
|
||||
@@ -758,7 +822,7 @@ def main():
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
layout = ci_json(json.loads(layout))
|
||||
except json.JSONDecodeError:
|
||||
print(f"set-panels value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -913,7 +977,7 @@ def main():
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
layout = ci_json(json.loads(layout))
|
||||
except json.JSONDecodeError:
|
||||
print("set-home-page value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -959,7 +1023,7 @@ def main():
|
||||
'<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">\r\n'
|
||||
f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">\r\n'
|
||||
f'\t<WorkingAreaTemplate>{tmpl}</WorkingAreaTemplate>\r\n'
|
||||
f'{left_xml}\r\n'
|
||||
f'{right_xml}\r\n'
|
||||
@@ -981,7 +1045,7 @@ def main():
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
||||
ops = json.loads(fh.read())
|
||||
ops = ci_json(json.loads(fh.read()))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
@@ -991,23 +1055,25 @@ def main():
|
||||
|
||||
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_name == "modify-property":
|
||||
if op_key == "modify-property":
|
||||
do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-childObject":
|
||||
elif op_key == "add-childobject":
|
||||
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-childObject":
|
||||
elif op_key == "remove-childobject":
|
||||
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-defaultRole":
|
||||
elif op_key == "add-defaultrole":
|
||||
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-defaultRole":
|
||||
elif op_key == "remove-defaultrole":
|
||||
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-defaultRoles":
|
||||
elif op_key == "set-defaultroles":
|
||||
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-panels":
|
||||
elif op_key == "set-panels":
|
||||
do_set_panels(op_value)
|
||||
elif op_name == "set-home-page":
|
||||
elif op_key == "set-home-page":
|
||||
do_set_home_page(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# cf-info v1.5 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# cf-info v1.5 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,28 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Argument parsing ---
|
||||
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
||||
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
||||
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
|
||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
||||
parser.add_argument("-OutFile", default="", help="Write output to file")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Output helper (collect all, paginate at the end) ---
|
||||
lines_buf = []
|
||||
|
||||
@@ -22,6 +22,21 @@ allowed-tools:
|
||||
| `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 "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
||||
@@ -36,8 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name
|
||||
# С версией и поставщиком
|
||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||
|
||||
# Другой режим совместимости
|
||||
... -Name TestCfg -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-init v1.2 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.14 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -7,12 +7,56 @@ param(
|
||||
[string]$OutputDir = "src",
|
||||
[string]$Version,
|
||||
[string]$Vendor,
|
||||
[string]$CompatibilityMode = "Version8_3_24"
|
||||
[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
|
||||
@@ -38,6 +82,11 @@ $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"),
|
||||
@@ -54,6 +103,12 @@ $mobileFuncs = @(
|
||||
@("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) {
|
||||
@@ -63,17 +118,43 @@ foreach ($mf in $mobileFuncs) {
|
||||
# --- 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>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
|
||||
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
|
||||
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
|
||||
# на своё место, а не в конец.
|
||||
$nl = "`r`n"
|
||||
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
|
||||
$palNs = ""
|
||||
if ($is221) {
|
||||
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
|
||||
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
|
||||
$f221AuxForms = $nl + ((@(
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
|
||||
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
|
||||
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
|
||||
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
|
||||
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
|
||||
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
|
||||
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="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>
|
||||
@@ -106,7 +187,7 @@ $cfgXml = @"
|
||||
</xr:ContainedObject>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
@@ -117,8 +198,8 @@ $cfgXml = @"
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles/>
|
||||
<Vendor>$vendorXml</Vendor>
|
||||
<Version>$versionXml</Version>
|
||||
$vendorEl
|
||||
$versionEl
|
||||
<UpdateCatalogAddress/>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
@@ -140,15 +221,15 @@ $cfgXml = @"
|
||||
<DefaultDataHistoryChangeHistoryForm/>
|
||||
<DefaultDataHistoryVersionDataForm/>
|
||||
<DefaultDataHistoryVersionDifferencesForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
|
||||
<RequiredMobileApplicationPermissions/>
|
||||
<UsedMobileApplicationFunctionalities>$mobileXml
|
||||
</UsedMobileApplicationFunctionalities>
|
||||
<StandaloneConfigurationRestrictionRoles/>
|
||||
<MobileApplicationURLs/>
|
||||
<AllowedIncomingShareRequestTypes/>
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||
<DefaultInterface/>
|
||||
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
|
||||
<DefaultInterface/>$f221Captions
|
||||
<DefaultStyle/>
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
@@ -160,7 +241,7 @@ $cfgXml = @"
|
||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
@@ -175,7 +256,7 @@ $cfgXml = @"
|
||||
# --- 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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="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>
|
||||
@@ -235,11 +316,20 @@ if (-not (Test-Path $extDir)) {
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
[System.IO.File]::WriteAllText($caiFile, $caiXml, $enc)
|
||||
Write-XmlFile $caiFile $caiXml $enc
|
||||
|
||||
# --- Output ---
|
||||
Write-Host "[OK] Создана конфигурация: $Name"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-init v1.2 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.14 — 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, uuid
|
||||
import sys, os, argparse, re, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с 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")
|
||||
@@ -24,7 +70,32 @@ def main():
|
||||
parser.add_argument('-Version', dest='Version', default='')
|
||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
args = parser.parse_args()
|
||||
# Версия формата выгрузки (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
|
||||
@@ -49,6 +120,11 @@ def main():
|
||||
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"),
|
||||
@@ -65,6 +141,13 @@ def main():
|
||||
("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:
|
||||
@@ -73,10 +156,12 @@ def main():
|
||||
# --- 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(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
||||
version_xml = esc_xml(version) if version else ""
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
class_ids = [
|
||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||
@@ -88,6 +173,28 @@ def main():
|
||||
"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>
|
||||
@@ -96,12 +203,12 @@ def main():
|
||||
\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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="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(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
@@ -112,8 +219,8 @@ def main():
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles/>
|
||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
||||
\t\t\t<Version>{version_xml}</Version>
|
||||
\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>
|
||||
@@ -135,15 +242,15 @@ def main():
|
||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>
|
||||
\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/>
|
||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||
\t\t\t<DefaultInterface/>
|
||||
\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/>
|
||||
@@ -155,7 +262,7 @@ def main():
|
||||
\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>
|
||||
\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/>
|
||||
@@ -168,7 +275,7 @@ def main():
|
||||
|
||||
# --- 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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="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>
|
||||
@@ -217,11 +324,11 @@ def main():
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_utf8_bom(cfg_file, cfg_xml)
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_utf8_bom(lang_file, lang_xml)
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||
write_utf8_bom(cai_file, cai_xml)
|
||||
write_xml_file(cai_file, cai_xml)
|
||||
|
||||
print(f"[OK] Создана конфигурация: {name}")
|
||||
print(f" Каталог: {output_dir}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-validate v1.4 — Validate 1C configuration root structure
|
||||
# cf-validate v1.7 — Validate 1C configuration root structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -89,6 +89,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||
@@ -203,10 +216,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-validate v1.4 — Validate 1C configuration XML structure
|
||||
# cf-validate v1.7 — Validate 1C configuration XML structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -110,6 +132,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -170,7 +206,7 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
config_path = args.ConfigPath
|
||||
max_errors = args.MaxErrors
|
||||
@@ -230,10 +266,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
|
||||
@@ -41,7 +41,6 @@ allowed-tools:
|
||||
- `Enum.ВидыОплат` — перечисление
|
||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||
Поддерживаются все 44 типа объектов конфигурации.
|
||||
|
||||
### Заимствование форм
|
||||
|
||||
@@ -71,31 +70,33 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Заимствовать один объект
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||
|
||||
# Заимствовать форму (автоматически заимствует родительский объект)
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
|
||||
# Несколько объектов за раз
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
|
||||
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
|
||||
# Заимствовать форму с ВСЕМИ реквизитами объекта
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-borrow v1.9 — Borrow objects from configuration into extension (CFE)
|
||||
# cfe-borrow v1.31 — Borrow objects from configuration into extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ExtensionPath,
|
||||
@@ -16,18 +16,38 @@ function Warn([string]$msg) { Write-Host "[WARN] $msg" }
|
||||
# Form data-binding tags (value = attribute path). A binding survives only if its root
|
||||
# attribute is borrowed into the form's <Attributes>; otherwise it must be stripped or the
|
||||
# platform rejects the form with "Неверный путь к данным" on load.
|
||||
$script:formBindingDataTags = @('DataPath','TitleDataPath','FooterDataPath','HeaderDataPath','MultipleValueDataPath','MultipleValuePresentDataPath')
|
||||
# RowPictureDataPath тоже путь к данным («Объект.Товары.РасхождениеЗаказ», «Список.DefaultPicture»),
|
||||
# а не индекс картинки: эталон Конфигуратора сохраняет его с заимствованным основным реквизитом
|
||||
# и выбрасывает без него — то же правило, что у остальных путей.
|
||||
$script:formBindingDataTags = @('DataPath','TitleDataPath','FooterDataPath','HeaderDataPath','MultipleValueDataPath','MultipleValuePresentDataPath','RowPictureDataPath')
|
||||
# Picture-path binding tags (value = picture index path, never a data attribute) — always stripped in the skeleton.
|
||||
$script:formBindingPictureTags = @('RowPictureDataPath','MultipleValuePictureDataPath')
|
||||
$script:formBindingPictureTags = @('MultipleValuePictureDataPath')
|
||||
|
||||
# Пути ссылок параметров выбора, которые пришлось вырезать (для предупреждения в конце)
|
||||
$script:droppedLinks = @()
|
||||
|
||||
# id основного реквизита в заимствованной форме — как у Конфигуратора
|
||||
$script:mainAttrId = "1000001"
|
||||
|
||||
# Виды дочерних объектов, которые заимствуются в оболочку поимённо (табличные части — отдельно)
|
||||
$script:childObjectKinds = @('Attribute','Dimension','Resource','AddressingAttribute')
|
||||
|
||||
# Прямые дети <Form>, которые в заимствованную форму не переносятся.
|
||||
# Структурные секции: AutoCommandBar и ChildItems забираются отдельно, остальные выбрасываются целиком.
|
||||
$script:formStructuralSections = @('Events','Attributes','Commands','Parameters','CommandInterface')
|
||||
# Свойства формы, значение которых — имя реквизита формы (реквизиты не заимствуются, ссылка повиснет).
|
||||
$script:formAttributeRefProps = @('ReportResult','DetailsData','VariantAppearance','GroupList')
|
||||
|
||||
# Strip data-binding tags whose root attribute isn't borrowed.
|
||||
# $keepObjekt=$true (BorrowMainAttribute): keep Объект.* data bindings, strip the rest.
|
||||
# $keepObjekt=$false (default skeleton): strip all bindings. Picture-path tags are always stripped.
|
||||
# $mainAttrName задан (BorrowMainAttribute): оставить привязки от его имени, остальные снять.
|
||||
# Пусто (скелет без основного реквизита): снять все. Картиночные пути снимаются всегда.
|
||||
function Strip-FormBindings {
|
||||
param([string]$xml, [bool]$keepObjekt)
|
||||
param([string]$xml, [string]$mainAttrName)
|
||||
foreach ($tag in $script:formBindingDataTags) {
|
||||
if ($keepObjekt) {
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>(?!Объект\.)[^<]*</$tag>", '')
|
||||
if ($mainAttrName) {
|
||||
# Оставить и «Список.Поле», и путь ровно на сам реквизит («Список» у таблицы формы)
|
||||
$root = [regex]::Escape($mainAttrName)
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>(?!$root(\.|<))[^<]*</$tag>", '')
|
||||
} else {
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>[^<]*</$tag>", '')
|
||||
}
|
||||
@@ -38,6 +58,119 @@ function Strip-FormBindings {
|
||||
return $xml
|
||||
}
|
||||
|
||||
# Ссылки параметров выбора (<ChoiceParameterLinks>/<xr:Link>) — привязка особого рода: путь лежит
|
||||
# в <xr:DataPath> и обычным стриппингом не снимается. Текстовое имя в расширении разрешается только
|
||||
# если его корень объявлен в <Attributes> самой заимствованной формы; иначе платформа отвергает
|
||||
# загрузку — «Неверный путь к полю - X». Реквизиты формы не заимствуются никогда, поэтому ссылка на
|
||||
# них разрешима только через id: Конфигуратор подставляет id реквизита ИСХОДНОЙ формы (эталоны
|
||||
# Issue66Example4/5/6, JR2433, JR2976, JR49904 — совпадение на шести расширениях). Именно id
|
||||
# исходной, а не заимствованной: при заимствовании реквизиты перенумеровываются в 1000000+, а
|
||||
# ссылка продолжает указывать в нумерацию базовой формы.
|
||||
# Путь на основной реквизит («Объект.X») при заимствованном основном реквизите разрешается текстом
|
||||
# и остаётся читаемым; без заимствования переводится в «<id>/0:<uuid реквизита объекта>».
|
||||
# Реквизит, которого в источнике нет, недоступен и по uuid: такую связь вырезаем целиком.
|
||||
# Пути вида «Items.<Элемент>.CurrentData.<Поле>» не трогаем — их кодировка отдельная.
|
||||
function Rewrite-ChoiceParameterLinks {
|
||||
param([string]$xml, $attrUuids, $formAttrIds, [string]$mainAttrName, [bool]$mainAttrBorrowed)
|
||||
|
||||
if ($xml -notmatch '<ChoiceParameterLinks>') { return $xml }
|
||||
|
||||
$mainPat = if ($mainAttrName) { [regex]::Escape($mainAttrName) } else { $null }
|
||||
$mainId = if ($mainAttrName -and $formAttrIds.ContainsKey($mainAttrName)) { $formAttrIds[$mainAttrName] } else { "1" }
|
||||
|
||||
$xml = [regex]::Replace($xml, '(?s)\s*<xr:Link>.*?</xr:Link>', {
|
||||
param($m)
|
||||
$link = $m.Value
|
||||
$dp = [regex]::Match($link, '<xr:DataPath[^>]*>([^<]+)</xr:DataPath>')
|
||||
if (-not $dp.Success) { return $link }
|
||||
$path = $dp.Groups[1].Value
|
||||
|
||||
# Путь на основной реквизит формы
|
||||
if ($mainPat -and $path -match "^${mainPat}\.(.+)$") {
|
||||
$attrName = $Matches[1]
|
||||
if ($mainAttrBorrowed) {
|
||||
# Реквизит объекта разрешается текстом и остаётся читаемым. Стандартное поле
|
||||
# («Объект.Owner», «Объект.Date») — нет: платформа отвергает «Неверный путь к данным».
|
||||
# Конфигуратор в этом случае оставляет ссылку на сам реквизит (эталон Issue66Example7_1).
|
||||
if ($attrUuids.ContainsKey($attrName)) { return $link }
|
||||
return [regex]::Replace($link, '(<xr:DataPath[^>]*>)[^<]+(</xr:DataPath>)', "`${1}${mainId}`${2}")
|
||||
}
|
||||
if ($attrUuids.ContainsKey($attrName)) {
|
||||
return [regex]::Replace($link, '(<xr:DataPath[^>]*>)[^<]+(</xr:DataPath>)', "`${1}${mainId}/0:$($attrUuids[$attrName])`${2}")
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
# Односегментный путь на реквизит формы — только по id исходной формы
|
||||
if ($path -notmatch '\.' -and $formAttrIds.ContainsKey($path)) {
|
||||
return [regex]::Replace($link, '(<xr:DataPath[^>]*>)[^<]+(</xr:DataPath>)', "`${1}$($formAttrIds[$path])`${2}")
|
||||
}
|
||||
|
||||
# Уже непрозрачный путь (форма-источник сама из расширения) — не трогаем
|
||||
if ($path -match '^\d') { return $link }
|
||||
|
||||
# С заимствованным основным реквизитом текстовый путь разрешается: элементы формы на месте,
|
||||
# а их данные доступны через основной реквизит. Конфигуратор такие пути и оставляет текстом
|
||||
# (эталон Issue66Example7_1: «Items.Товары.CurrentData.Характеристика» перенесён как есть).
|
||||
if ($mainAttrBorrowed) { return $link }
|
||||
|
||||
# Прочее текстом не разрешается: платформа отвергает загрузку «Неверный путь к полю».
|
||||
# Сюда попадают «Items.<Элемент>.CurrentData.<Поле>» — их кодировка непрозрачна и по
|
||||
# имеющимся эталонам не воспроизводима. Связь параметров выбора — удобство подбора, а не
|
||||
# данные: без неё форма заимствуется и работает, с ней — не грузится вовсе.
|
||||
$script:droppedLinks += $path
|
||||
return ''
|
||||
})
|
||||
|
||||
# Опустевший контейнер платформе не нужен
|
||||
$xml = [regex]::Replace($xml, '(?s)\s*<ChoiceParameterLinks>\s*</ChoiceParameterLinks>', '')
|
||||
return $xml
|
||||
}
|
||||
|
||||
# Имена ПРЯМЫХ детей собственного <ChildObjects> объекта — для дедупа при повторном
|
||||
# заимствовании. Текстом это не снять: regex «первый <ChildObjects> до первого </ChildObjects>»
|
||||
# у объекта с табличными частями обрывается на закрытии первой ТЧ, забирает имена её колонок и
|
||||
# теряет то, что идёт после неё.
|
||||
function Get-OwnChildObjectNames {
|
||||
param([string]$objFile)
|
||||
|
||||
$names = @{}
|
||||
if (-not (Test-Path -LiteralPath $objFile)) { return $names }
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
try { $doc.Load($objFile) } catch { return $names }
|
||||
$objEl = $null
|
||||
foreach ($c in $doc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq 'Element') { $objEl = $c; break }
|
||||
}
|
||||
if (-not $objEl) { return $names }
|
||||
$childObjs = $objEl.SelectSingleNode("*[local-name()='ChildObjects']")
|
||||
if (-not $childObjs) { return $names }
|
||||
foreach ($child in $childObjs.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
$nameNode = $child.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||
if ($nameNode) { $names[$nameNode.InnerText.Trim()] = $true }
|
||||
}
|
||||
return $names
|
||||
}
|
||||
|
||||
# Вставка в СОБСТВЕННЫЙ <ChildObjects> объекта. Свой контейнер закрывается в файле последним:
|
||||
# объект в файле один, а вложенные <ChildObjects> табличных частей закрываются раньше. Замена по
|
||||
# всем вхождениям раскидывала реквизиты по каждой ТЧ — ps1 рвал XML, py прятал ТЧ внутрь ТЧ.
|
||||
function Insert-IntoOwnChildObjects {
|
||||
param([string]$text, [string]$content)
|
||||
|
||||
$closeIdx = $text.LastIndexOf('</ChildObjects>')
|
||||
if ($closeIdx -ge 0) {
|
||||
return $text.Substring(0, $closeIdx) + "${content}`r`n`t`t" + $text.Substring($closeIdx)
|
||||
}
|
||||
# Своего закрывающего тега нет — значит контейнер самозакрытый (детей у него нет, вложенных тоже)
|
||||
$selfMatches = [regex]::Matches($text, '<ChildObjects\s*/>')
|
||||
if ($selfMatches.Count -eq 0) { return $text }
|
||||
$m = $selfMatches[$selfMatches.Count - 1]
|
||||
return $text.Substring(0, $m.Index) + "<ChildObjects>${content}`r`n`t`t</ChildObjects>" + $text.Substring($m.Index + $m.Length)
|
||||
}
|
||||
|
||||
# --- 1. Resolve paths ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($ExtensionPath)) {
|
||||
$ExtensionPath = Join-Path (Get-Location).Path $ExtensionPath
|
||||
@@ -125,7 +258,7 @@ $childTypeDirMap = @{
|
||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"
|
||||
"HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"
|
||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "Bot"="Bots"; "Language"="Languages"
|
||||
}
|
||||
|
||||
# --- 4b. Russian synonym → English type ---
|
||||
@@ -153,7 +286,7 @@ $synonymMap = @{
|
||||
$script:typeOrder = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -209,7 +342,8 @@ $script:generatedTypes = @{
|
||||
@{ prefix = "AccumulationRegisterRecordKey"; category = "RecordKey" }
|
||||
)
|
||||
"AccountingRegister" = @(
|
||||
@{ prefix = "AccountingRegisterRecord"; category = "Record" }
|
||||
@{ prefix = "AccountingRegisterRecord"; category = "Record" }
|
||||
@{ prefix = "AccountingRegisterExtDimensions"; category = "ExtDimensions" }
|
||||
@{ prefix = "AccountingRegisterManager"; category = "Manager" }
|
||||
@{ prefix = "AccountingRegisterSelection"; category = "Selection" }
|
||||
@{ prefix = "AccountingRegisterList"; category = "List" }
|
||||
@@ -223,6 +357,7 @@ $script:generatedTypes = @{
|
||||
@{ prefix = "CalculationRegisterList"; category = "List" }
|
||||
@{ prefix = "CalculationRegisterRecordSet"; category = "RecordSet" }
|
||||
@{ prefix = "CalculationRegisterRecordKey"; category = "RecordKey" }
|
||||
@{ prefix = "RecalculationsManager"; category = "Recalcs" }
|
||||
)
|
||||
"ChartOfAccounts" = @(
|
||||
@{ prefix = "ChartOfAccountsObject"; category = "Object" }
|
||||
@@ -230,12 +365,15 @@ $script:generatedTypes = @{
|
||||
@{ prefix = "ChartOfAccountsSelection"; category = "Selection" }
|
||||
@{ prefix = "ChartOfAccountsList"; category = "List" }
|
||||
@{ prefix = "ChartOfAccountsManager"; category = "Manager" }
|
||||
@{ prefix = "ChartOfAccountsExtDimensionTypes"; category = "ExtDimensionTypes" }
|
||||
@{ prefix = "ChartOfAccountsExtDimensionTypesRow"; category = "ExtDimensionTypesRow" }
|
||||
)
|
||||
"ChartOfCharacteristicTypes" = @(
|
||||
@{ prefix = "ChartOfCharacteristicTypesObject"; category = "Object" }
|
||||
@{ prefix = "ChartOfCharacteristicTypesRef"; category = "Ref" }
|
||||
@{ prefix = "ChartOfCharacteristicTypesSelection"; category = "Selection" }
|
||||
@{ prefix = "ChartOfCharacteristicTypesList"; category = "List" }
|
||||
@{ prefix = "Characteristic"; category = "Characteristic" }
|
||||
@{ prefix = "ChartOfCharacteristicTypesManager"; category = "Manager" }
|
||||
)
|
||||
"ChartOfCalculationTypes" = @(
|
||||
@@ -245,8 +383,11 @@ $script:generatedTypes = @{
|
||||
@{ prefix = "ChartOfCalculationTypesList"; category = "List" }
|
||||
@{ prefix = "ChartOfCalculationTypesManager"; category = "Manager" }
|
||||
@{ prefix = "DisplacingCalculationTypes"; category = "DisplacingCalculationTypes" }
|
||||
@{ prefix = "DisplacingCalculationTypesRow"; category = "DisplacingCalculationTypesRow" }
|
||||
@{ prefix = "BaseCalculationTypes"; category = "BaseCalculationTypes" }
|
||||
@{ prefix = "BaseCalculationTypesRow"; category = "BaseCalculationTypesRow" }
|
||||
@{ prefix = "LeadingCalculationTypes"; category = "LeadingCalculationTypes" }
|
||||
@{ prefix = "LeadingCalculationTypesRow"; category = "LeadingCalculationTypesRow" }
|
||||
)
|
||||
"BusinessProcess" = @(
|
||||
@{ prefix = "BusinessProcessObject"; category = "Object" }
|
||||
@@ -254,6 +395,7 @@ $script:generatedTypes = @{
|
||||
@{ prefix = "BusinessProcessSelection"; category = "Selection" }
|
||||
@{ prefix = "BusinessProcessList"; category = "List" }
|
||||
@{ prefix = "BusinessProcessManager"; category = "Manager" }
|
||||
@{ prefix = "BusinessProcessRoutePointRef"; category = "RoutePointRef" }
|
||||
)
|
||||
"Task" = @(
|
||||
@{ prefix = "TaskObject"; category = "Object" }
|
||||
@@ -285,19 +427,51 @@ $script:generatedTypes = @{
|
||||
"DefinedType" = @(
|
||||
@{ prefix = "DefinedType"; category = "DefinedType" }
|
||||
)
|
||||
"Sequence" = @(
|
||||
@{ prefix = "SequenceRecord"; category = "Record" }
|
||||
@{ prefix = "SequenceManager"; category = "Manager" }
|
||||
@{ prefix = "SequenceRecordSet"; category = "RecordSet" }
|
||||
)
|
||||
"FilterCriterion" = @(
|
||||
@{ prefix = "FilterCriterionManager"; category = "Manager" }
|
||||
@{ prefix = "FilterCriterionList"; category = "List" }
|
||||
)
|
||||
"SettingsStorage" = @(
|
||||
@{ prefix = "SettingsStorageManager"; category = "Manager" }
|
||||
)
|
||||
"IntegrationService" = @(
|
||||
@{ prefix = "IntegrationServiceManager"; category = "Manager" }
|
||||
)
|
||||
"WSReference" = @(
|
||||
@{ prefix = "WSReferenceManager"; category = "Manager" }
|
||||
)
|
||||
}
|
||||
|
||||
# Types that need ChildObjects element
|
||||
# Types that need ChildObjects element — fallback when the source object cannot be probed.
|
||||
# The platform emits <ChildObjects> for every container type even when empty, and rejects
|
||||
# the file without it ("ожидаемое ChildObjects"); primary signal is the source object itself.
|
||||
$typesWithChildObjects = @(
|
||||
"Catalog","Document","ExchangePlan","ChartOfAccounts",
|
||||
"ChartOfCharacteristicTypes","ChartOfCalculationTypes",
|
||||
"BusinessProcess","Task","Enum",
|
||||
"InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister"
|
||||
"InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister",
|
||||
"DataProcessor","Report","DocumentJournal","FilterCriterion","SettingsStorage",
|
||||
"Sequence","HTTPService","WebService","IntegrationService","Subsystem"
|
||||
)
|
||||
|
||||
# CommonModule properties to copy from source
|
||||
$commonModuleProps = @("Global","ClientManagedApplication","Server","ExternalConnection","ClientOrdinaryApplication","ServerCall")
|
||||
|
||||
# Свойства объекта, от которых зависит существование стандартного поля: без них платформа
|
||||
# отвергает загрузку — «Неверный путь к данным». Конфигуратор переносит ровно их (эталоны
|
||||
# Issue66Example7_1 и Issue66Example2). Проверено сплошным прогоном по типам: у регистра сведений
|
||||
# без InformationRegisterPeriodicity не разрешается «Запись.Period».
|
||||
$script:typeGateProps = @{
|
||||
"InformationRegister" = @("InformationRegisterPeriodicity","WriteMode")
|
||||
}
|
||||
# Владельцы справочника — список <xr:Item>, а не скаляр: переносится фрагментом, как __TypeXml
|
||||
$script:typesWithOwners = @("Catalog","ChartOfCharacteristicTypes")
|
||||
|
||||
# Standard system fields to skip when collecting DataPath references
|
||||
$script:standardFields = @("Code","Description","Ref","Parent","DeletionMark","Predefined","IsFolder","LineNumber","RowsCount","PredefinedDataName")
|
||||
|
||||
@@ -346,9 +520,20 @@ function Expand-SelfClosingElement($container, $parentIndent) {
|
||||
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) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$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
|
||||
@@ -363,6 +548,20 @@ $script:formatVersion = Detect-FormatVersion $extDir
|
||||
# --- 8. Namespaces declaration for object XML ---
|
||||
$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"'
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
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='
|
||||
}
|
||||
|
||||
# --- 9. Parse -Object into items ---
|
||||
$items = @()
|
||||
foreach ($part in $Object.Split(";;")) {
|
||||
@@ -393,6 +592,37 @@ if ($BorrowMainAttribute) {
|
||||
}
|
||||
|
||||
# --- 10. Helper: read source object XML ---
|
||||
# Имена реквизитов исходного объекта → uuid. Нужны для непрозрачной формы пути в ссылках
|
||||
# параметров выбора (см. Rewrite-ChoiceParameterLinks).
|
||||
function Get-SourceAttributeUuids {
|
||||
param([string]$typeName, [string]$objName)
|
||||
|
||||
$result = @{}
|
||||
$dirName = $childTypeDirMap[$typeName]
|
||||
if (-not $dirName) { return $result }
|
||||
$srcFile = Join-Path (Join-Path $cfgDir $dirName) "${objName}.xml"
|
||||
if (-not (Test-Path $srcFile)) { return $result }
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $false
|
||||
$doc.Load($srcFile)
|
||||
$objEl = $null
|
||||
foreach ($c in $doc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq 'Element') { $objEl = $c; break }
|
||||
}
|
||||
if (-not $objEl) { return $result }
|
||||
$childObjects = $objEl.SelectSingleNode("*[local-name()='ChildObjects']")
|
||||
if (-not $childObjects) { return $result }
|
||||
foreach ($child in $childObjects.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
if ($child.LocalName -notin @('Attribute','TabularSection')) { continue }
|
||||
$uuid = $child.GetAttribute("uuid")
|
||||
$nameNode = $child.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||
if ($uuid -and $nameNode) { $result[$nameNode.InnerText.Trim()] = $uuid }
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Read-SourceObject {
|
||||
param([string]$typeName, [string]$objName)
|
||||
|
||||
@@ -452,8 +682,24 @@ function Read-SourceObject {
|
||||
$srcProps["__TypeXml"] = [regex]::Replace($typeNode.OuterXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
||||
}
|
||||
}
|
||||
# Владельцы: стандартное поле «Owner» появляется у справочника, только если задан Owners
|
||||
if ($script:typesWithOwners -ccontains $typeName) {
|
||||
$ownersNode = $propsNode.SelectSingleNode("md:Owners", $srcNs)
|
||||
if ($ownersNode -and $ownersNode.HasChildNodes) {
|
||||
$srcProps["__OwnersXml"] = [regex]::Replace($ownersNode.OuterXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
||||
}
|
||||
}
|
||||
# Скалярные свойства, включающие стандартные поля своего типа
|
||||
foreach ($gp in @($script:typeGateProps[$typeName])) {
|
||||
if (-not $gp) { continue }
|
||||
$gpNode = $propsNode.SelectSingleNode("md:${gp}", $srcNs)
|
||||
if ($gpNode) { $srcProps[$gp] = $gpNode.InnerText.Trim() }
|
||||
}
|
||||
}
|
||||
|
||||
# Whether the platform emits <ChildObjects> for this type — the source object is the ground truth
|
||||
$srcProps["__HasChildObjects"] = ($srcEl.SelectSingleNode("md:ChildObjects", $srcNs) -ne $null)
|
||||
|
||||
return @{
|
||||
Uuid = $srcUuid
|
||||
Properties = $srcProps
|
||||
@@ -569,30 +815,56 @@ function Borrow-Form {
|
||||
# (e.g. a 2.13 form inside a 2.17 extension). The platform itself upgrades the form to the root version.
|
||||
$formVersion = $script:formatVersion
|
||||
|
||||
# Find direct children: form properties, AutoCommandBar, ChildItems
|
||||
# Find direct children: form properties, AutoCommandBar, ChildItems.
|
||||
# Секции формы отбираются по имени, а не по позиции: свойства лежат и до, и после <CommandSet>
|
||||
# (корпусная проверка: у всех 794 форм документов ERP с CommandSet он стоит раньше AutoCommandBar,
|
||||
# а AutoTime/UsePostingMode/RepostOnWrite — после него). Позиционная отсечка теряла весь хвост,
|
||||
# и платформа молча подставляла дефолты вместо потерянных свойств.
|
||||
$srcAutoCmd = $null
|
||||
$srcChildItems = $null
|
||||
$formProps = @()
|
||||
$reachedVisual = $false
|
||||
foreach ($fc in $srcFormEl.ChildNodes) {
|
||||
if ($fc.NodeType -ne 'Element') { continue }
|
||||
if ($fc.LocalName -eq 'AutoCommandBar' -and -not $srcAutoCmd) {
|
||||
$reachedVisual = $true; $srcAutoCmd = $fc; continue
|
||||
$srcAutoCmd = $fc; continue
|
||||
}
|
||||
if ($fc.LocalName -eq 'ChildItems' -and -not $srcChildItems) {
|
||||
$reachedVisual = $true; $srcChildItems = $fc; continue
|
||||
}
|
||||
if ($fc.LocalName -eq 'Events' -or $fc.LocalName -eq 'Attributes' -or $fc.LocalName -eq 'Commands' -or $fc.LocalName -eq 'Parameters' -or $fc.LocalName -eq 'CommandSet') {
|
||||
$reachedVisual = $true; continue
|
||||
}
|
||||
if (-not $reachedVisual) {
|
||||
$formProps += $fc.OuterXml
|
||||
$srcChildItems = $fc; continue
|
||||
}
|
||||
# Структурные секции: в расширении их содержимое недействительно (обработчики, команды и
|
||||
# параметры базовой формы, ссылки командного интерфейса на команды базовой конфигурации).
|
||||
if ($script:formStructuralSections -ccontains $fc.LocalName) { continue }
|
||||
# Свойства, значение которых — имя реквизита формы. Реквизиты в заимствованную форму не
|
||||
# переносятся, поэтому Конфигуратор такие свойства выбрасывает (проверено на форме отчёта:
|
||||
# ReportResult и DetailsData выброшены, CustomSettingsFolder — имя элемента — сохранён).
|
||||
if ($script:formAttributeRefProps -ccontains $fc.LocalName) { continue }
|
||||
$formProps += $fc.OuterXml
|
||||
}
|
||||
|
||||
# Get OuterXml and strip redundant namespace redeclarations (they're on root <Form>)
|
||||
$nsStripPattern = '\s+xmlns(?::\w+)?="[^"]*"'
|
||||
|
||||
# Основной реквизит исходной формы: его имя — корень путей к данным, которые нужно сохранить
|
||||
# («Объект.» у формы объекта, «Список.» у формы списка, «Запись.» у формы записи регистра)
|
||||
# Имя основного реквизита источника нужно в обоих режимах: по нему опознаётся корень путей
|
||||
# в ссылках параметров выбора. А $mainAttrName управляет вырезанием привязок и потому остаётся
|
||||
# пустым в скелетном режиме — там привязки снимаются все.
|
||||
$srcMainInfo = Get-MainAttributeInfo $srcFormEl $nsStripPattern
|
||||
$srcMainAttrName = if ($srcMainInfo) { $srcMainInfo.Name } else { "" }
|
||||
$formAttrIds = Get-FormAttributeIds $srcFormEl
|
||||
$mainAttrInfo = if ($BorrowMainAttr) { $srcMainInfo } else { $null }
|
||||
$mainAttrName = if ($mainAttrInfo) { $mainAttrInfo.Name } else { "" }
|
||||
if ($BorrowMainAttr -and -not $mainAttrInfo) {
|
||||
Warn " У формы нет основного реквизита — -BorrowMainAttribute проигнорирован"
|
||||
}
|
||||
|
||||
# uuid реквизитов объекта нужны ровно там, где основной реквизит НЕ попал в форму:
|
||||
# только тогда путь «<основной>.X» переводится в непрозрачный вид
|
||||
# Имена реквизитов объекта нужны в обоих режимах: без заимствования — чтобы построить
|
||||
# непрозрачный путь, с заимствованием — чтобы отличить реквизит (разрешается текстом) от
|
||||
# стандартного поля (не разрешается)
|
||||
$srcAttrUuids = Get-SourceAttributeUuids $typeName $objName
|
||||
|
||||
# AutoCommandBar: keep ChildItems (buttons with CommandName→0), Autofill→false
|
||||
$autoCmdXml = ""
|
||||
if ($srcAutoCmd) {
|
||||
@@ -600,10 +872,13 @@ function Borrow-Form {
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, $nsStripPattern, '')
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
||||
$autoCmdXml = $autoCmdXml -replace '<Autofill>true</Autofill>', '<Autofill>false</Autofill>'
|
||||
# Strip ExcludedCommand (references to standard commands invalid in extension)
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
||||
# Вложенный CommandSet выбрасывается целиком, а не опустошается: Конфигуратор в заимствованной
|
||||
# форме оставляет только корневой (тот идёт свойством формы, здесь его нет).
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '(?s)\s*<CommandSet>.*?</CommandSet>', '')
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<CommandSet/>', '')
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
$autoCmdXml = Strip-FormBindings $autoCmdXml ([bool]$BorrowMainAttr)
|
||||
$autoCmdXml = Strip-FormBindings $autoCmdXml $mainAttrName
|
||||
$autoCmdXml = Rewrite-ChoiceParameterLinks $autoCmdXml $srcAttrUuids $formAttrIds $srcMainAttrName ([bool]$mainAttrInfo)
|
||||
}
|
||||
|
||||
# ChildItems: copy full tree, clean up base-config references
|
||||
@@ -615,9 +890,11 @@ function Borrow-Form {
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
# (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*/RowPicture*)
|
||||
$childItemsXml = Strip-FormBindings $childItemsXml ([bool]$BorrowMainAttr)
|
||||
# Strip ExcludedCommand in nested AutoCommandBars (references to standard commands invalid in extension)
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
||||
$childItemsXml = Strip-FormBindings $childItemsXml $mainAttrName
|
||||
$childItemsXml = Rewrite-ChoiceParameterLinks $childItemsXml $srcAttrUuids $formAttrIds $srcMainAttrName ([bool]$mainAttrInfo)
|
||||
# Вложенные CommandSet (у таблиц, полей табличного документа и т.п.) — целиком, см. выше
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '(?s)\s*<CommandSet>.*?</CommandSet>', '')
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<CommandSet/>', '')
|
||||
# Strip TypeLink blocks with human-readable DataPath (Items.XXX — can't convert to UUID)
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '(?s)\s*<TypeLink>\s*<xr:DataPath>Items\.[^<]*</xr:DataPath>.*?</TypeLink>', '')
|
||||
# Strip element-level Events (base form handlers not in extension)
|
||||
@@ -796,11 +1073,22 @@ function Borrow-Form {
|
||||
}
|
||||
}
|
||||
|
||||
# Extract the <Form ...> opening tag from source text (preserves namespace declarations)
|
||||
# Открывающий тег <Form ...> берём из исходной формы — ради её объявлений пространств имён,
|
||||
# но version подставляем СВОЮ: форма обязана нести версию расширения, иначе платформа
|
||||
# отвергает импорт (форма 2.13 внутри расширения 2.17). Раньше тег копировался целиком,
|
||||
# и версия источника молча побеждала.
|
||||
$xmlDecl = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
$formTag = "<Form version=`"${formVersion}`">"
|
||||
if ($srcFormContent -match '(?s)^(<\?xml[^?]*\?>)') { $xmlDecl = $Matches[1] }
|
||||
if ($srcFormContent -match '(<Form[^>]*>)') { $formTag = $Matches[1] }
|
||||
if ($srcFormContent -match '(<Form[^>]*>)') {
|
||||
$srcTag = $Matches[1]
|
||||
$srcNs = $srcTag -replace '^<Form\s*', '' -replace '\s*/?>$', '' -replace '\s*version="[^"]*"', ''
|
||||
# 2.21 (8.5): пространство палитры. Место строгое — после lf, перед style.
|
||||
if ((Get-FormatRank $formVersion) -ge 221 -and $srcNs -notmatch 'xmlns:pal=') {
|
||||
$srcNs = $srcNs -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
$formTag = if ($srcNs) { "<Form $srcNs version=`"${formVersion}`">" } else { "<Form version=`"${formVersion}`">" }
|
||||
}
|
||||
|
||||
# Build output Form.xml
|
||||
$formXmlSb = New-Object System.Text.StringBuilder
|
||||
@@ -823,17 +1111,9 @@ function Borrow-Form {
|
||||
$formXmlSb.Append("`r`n") | Out-Null
|
||||
}
|
||||
# Attributes: empty or with MainAttribute when BorrowMainAttr
|
||||
if ($BorrowMainAttr) {
|
||||
$objTypePrefix = ""
|
||||
$gtList = $script:generatedTypes[$typeName]
|
||||
if ($gtList) { foreach ($g in $gtList) { if ($g.category -eq "Object") { $objTypePrefix = $g.prefix; break } } }
|
||||
$mainAttrType = "cfg:${objTypePrefix}.${objName}"
|
||||
if ($BorrowMainAttr -and $mainAttrInfo) {
|
||||
$formXmlSb.Append("`t<Attributes>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t<Attribute name=`"Объект`" id=`"1000001`">`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t<Type><v8:Type>${mainAttrType}</v8:Type></Type>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t<MainAttribute>true</MainAttribute>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t<SavedData>true</SavedData>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t</Attribute>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t$($mainAttrInfo.Xml)`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t</Attributes>") | Out-Null
|
||||
} else {
|
||||
$formXmlSb.Append("`t<Attributes/>") | Out-Null
|
||||
@@ -867,13 +1147,15 @@ function Borrow-Form {
|
||||
}
|
||||
|
||||
# BaseForm Attributes: same as main section
|
||||
if ($BorrowMainAttr) {
|
||||
if ($BorrowMainAttr -and $mainAttrInfo) {
|
||||
$formXmlSb.Append("`t`t<Attributes>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t<Attribute name=`"Объект`" id=`"1000001`">`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t`t<Type><v8:Type>${mainAttrType}</v8:Type></Type>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t`t<MainAttribute>true</MainAttribute>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t`t<SavedData>true</SavedData>`r`n") | Out-Null
|
||||
$formXmlSb.Append("`t`t`t</Attribute>`r`n") | Out-Null
|
||||
# В BaseForm та же секция на уровень глубже — приём переиндентации тот же, что у ChildItems
|
||||
$maLines = $mainAttrInfo.Xml -split "`r?`n"
|
||||
for ($li = 0; $li -lt $maLines.Count; $li++) {
|
||||
if ($li -eq 0) { $formXmlSb.Append("`t`t`t$($maLines[$li])") | Out-Null }
|
||||
else { $formXmlSb.Append("`t$($maLines[$li])") | Out-Null }
|
||||
$formXmlSb.Append("`r`n") | Out-Null
|
||||
}
|
||||
$formXmlSb.Append("`t`t</Attributes>") | Out-Null
|
||||
} else {
|
||||
$formXmlSb.Append("`t`t<Attributes/>") | Out-Null
|
||||
@@ -889,8 +1171,21 @@ function Borrow-Form {
|
||||
New-Item -ItemType Directory -Path $formXmlDir -Force | Out-Null
|
||||
}
|
||||
$formXmlFile = Join-Path $formXmlDir "Form.xml"
|
||||
[System.IO.File]::WriteAllText($formXmlFile, $formXmlSb.ToString(), $enc)
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
# Здесь источник не XmlWriter, а OuterXml исходного документа — спацовывает так же.
|
||||
$formXmlText = $formXmlSb.ToString()
|
||||
$formXmlText = [regex]::Replace($formXmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Файл создаём мы — канон выгрузки: CRLF в разделителях строк.
|
||||
$formXmlText = ($formXmlText -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($formXmlFile, $formXmlText, $enc)
|
||||
Info " Created: $formXmlFile"
|
||||
if ($script:droppedLinks.Count -gt 0) {
|
||||
$uniq = @($script:droppedLinks | Sort-Object -Unique)
|
||||
Warn " Вырезано связей параметров выбора: $($uniq.Count) — путь не разрешается в расширении: $($uniq -join ', ')"
|
||||
$script:droppedLinks = @()
|
||||
}
|
||||
|
||||
# 6. Create empty Module.bsl — but NEVER overwrite an existing one (re-borrow must
|
||||
# not clobber user code added to the form module).
|
||||
@@ -995,8 +1290,16 @@ function Register-FormInObject {
|
||||
$text2 = [System.Text.Encoding]::UTF8.GetString($bytes2)
|
||||
if ($text2.Length -gt 0 -and $text2[0] -eq [char]0xFEFF) { $text2 = $text2.Substring(1) }
|
||||
$text2 = $text2.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text2 = [regex]::Replace($text2, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$utf8Bom2 = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $objFile) -and ([System.IO.File]::ReadAllText($objFile) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text2 = ($text2 -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objFile, $text2, $utf8Bom2)
|
||||
Info " Registered form in: $objFile"
|
||||
}
|
||||
@@ -1043,8 +1346,46 @@ function Build-InternalInfoXml {
|
||||
}
|
||||
|
||||
# --- 11b. Collect DataPath references from source Form.xml ---
|
||||
# --- 11b1. Основной реквизит исходной формы ---
|
||||
# Переносится ЦЕЛИКОМ, а не собирается из констант: имя, тип и состав детей зависят от вида формы.
|
||||
# У формы объекта это «Объект»/<Тип>Object + SavedData/UseAlways/Columns, у формы списка —
|
||||
# «Список»/DynamicList + Settings, у формы записи регистра — «Запись»/RecordManager + SavedData.
|
||||
# Синтез фиксированного набора давал для необъектных форм «Исключение XDTO» при загрузке.
|
||||
# Конфигуратор меняет у скопированного реквизита только id (эталоны Issue64UtB, Issue66Example2).
|
||||
# Имена реквизитов ИСХОДНОЙ формы → их id. Ссылки параметров выбора адресуют реквизит формы
|
||||
# именно по id базовой формы (см. Rewrite-ChoiceParameterLinks).
|
||||
function Get-FormAttributeIds {
|
||||
param($formEl)
|
||||
|
||||
$result = @{}
|
||||
$attrs = $formEl.SelectSingleNode("*[local-name()='Attributes']")
|
||||
if (-not $attrs) { return $result }
|
||||
foreach ($a in $attrs.ChildNodes) {
|
||||
if ($a.NodeType -ne 'Element' -or $a.LocalName -ne 'Attribute') { continue }
|
||||
$nm = $a.GetAttribute("name"); $id = $a.GetAttribute("id")
|
||||
if ($nm -and $id) { $result[$nm] = $id }
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-MainAttributeInfo {
|
||||
param($formEl, [string]$nsStripPattern)
|
||||
|
||||
$mainAttr = $formEl.SelectSingleNode("*[local-name()='Attributes']/*[local-name()='Attribute'][*[local-name()='MainAttribute']='true']")
|
||||
if (-not $mainAttr) { return $null }
|
||||
$xml = [regex]::Replace($mainAttr.OuterXml, $nsStripPattern, '')
|
||||
# id заменяется только в открывающем теге самого реквизита — у вложенных элементов свои
|
||||
$xml = [regex]::Replace($xml, '^(<Attribute\s[^>]*?)id="[^"]*"', "`${1}id=`"$script:mainAttrId`"")
|
||||
return @{ Name = $mainAttr.GetAttribute("name"); Xml = $xml }
|
||||
}
|
||||
|
||||
function Collect-FormDataPaths {
|
||||
param([string]$formXmlPath)
|
||||
param([string]$formXmlPath, [string]$mainAttrName)
|
||||
|
||||
# Корень путей — имя основного реквизита формы: «Объект» у формы объекта, «Список» у формы
|
||||
# списка, «Запись» у формы записи регистра. Зашитый «Объект» не находил ничего у необъектных
|
||||
# форм, и в оболочку не заимствовалось ни одного дочернего объекта.
|
||||
$root = [regex]::Escape($mainAttrName)
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
$content = [System.IO.File]::ReadAllText($formXmlPath, $enc)
|
||||
@@ -1055,7 +1396,7 @@ function Collect-FormDataPaths {
|
||||
# Scan every data-binding tag (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*)
|
||||
# for Объект.* references — picture-path tags carry picture indices, not data attributes.
|
||||
foreach ($tag in $script:formBindingDataTags) {
|
||||
$bms = [regex]::Matches($content, "<$tag>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</$tag>")
|
||||
$bms = [regex]::Matches($content, "<$tag>[^<]*\b$root\.(\w+(?:\.\w+)*)</$tag>")
|
||||
foreach ($m in $bms) {
|
||||
$path = $m.Groups[1].Value
|
||||
$segments = $path.Split(".")
|
||||
@@ -1073,7 +1414,7 @@ function Collect-FormDataPaths {
|
||||
|
||||
# Also scan <Field>Объект.X</Field> — object attributes referenced by filter/conditional-appearance
|
||||
# fields (and dynamic lists), not via a *DataPath binding (e.g. УдалитьЮрФизЛицо). Designer borrows these too.
|
||||
$fieldMatches = [regex]::Matches($content, "<Field>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</Field>")
|
||||
$fieldMatches = [regex]::Matches($content, "<Field>[^<]*\b$root\.(\w+(?:\.\w+)*)</Field>")
|
||||
foreach ($m in $fieldMatches) {
|
||||
$path = $m.Groups[1].Value
|
||||
$segments = $path.Split(".")
|
||||
@@ -1087,6 +1428,30 @@ function Collect-FormDataPaths {
|
||||
}
|
||||
}
|
||||
|
||||
# Also scan <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в
|
||||
# самой форме (напр. Объект.Товары.Артикул). Такая ТЧ может больше нигде на форме не встречаться,
|
||||
# и без её заимствования платформа отвергает форму: «Неверный путь к данным».
|
||||
$acMatches = [regex]::Matches($content, "<AdditionalColumns table=`"$root\.(\w+)`"")
|
||||
foreach ($m in $acMatches) {
|
||||
$seg0 = $m.Groups[1].Value
|
||||
if ($script:standardFields -contains $seg0) { continue }
|
||||
$firstLevel[$seg0] = $true
|
||||
}
|
||||
|
||||
# Текст запроса динамического списка — такое же место ссылки на реквизиты объекта, как DataPath.
|
||||
# Конфигуратор заимствует всё, что упомянуто в запросе: на эталоне Issue66Example2 это 21 из 27
|
||||
# дочерних объектов, совпадение с ним точное в обе стороны. У списка без ручного запроса
|
||||
# (<QueryText> нет) заимствуется только видимое на форме — эталон Issue66Example3.
|
||||
# Разбирать язык запросов не нужно: имена-кандидаты отфильтрует Resolve-SourceAttributes по
|
||||
# реальному составу объекта, поэтому лишние слова из запроса безвредны.
|
||||
foreach ($qm in [regex]::Matches($content, '(?s)<QueryText>(.*?)</QueryText>')) {
|
||||
foreach ($w in [regex]::Matches($qm.Groups[1].Value, '[\w]+')) {
|
||||
$word = $w.Value
|
||||
if ($script:standardFields -contains $word) { continue }
|
||||
$firstLevel[$word] = $true
|
||||
}
|
||||
}
|
||||
|
||||
# Deduplicate deep paths
|
||||
$seen = @{}
|
||||
$uniqueDeep = @()
|
||||
@@ -1137,7 +1502,11 @@ function Resolve-SourceAttributes {
|
||||
foreach ($child in $childObjs.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
|
||||
if ($child.LocalName -eq 'Attribute') {
|
||||
# Реквизит объекта, измерение и ресурс регистра — один и тот же вид дочернего объекта с
|
||||
# точки зрения заимствования, различается только имя элемента. Конфигуратор переносит их
|
||||
# своим видом (эталон Issue66Example2: у регистра <Dimension> x3 и <Resource>), поэтому вид
|
||||
# запоминается и выпускается как есть — иначе измерение уехало бы в файл как <Attribute>.
|
||||
if ($script:childObjectKinds -ccontains $child.LocalName) {
|
||||
$nameNode = $child.SelectSingleNode("md:Properties/md:Name", $srcNs)
|
||||
if (-not $nameNode) { continue }
|
||||
$attrName = $nameNode.InnerText
|
||||
@@ -1149,7 +1518,7 @@ function Resolve-SourceAttributes {
|
||||
# Strip namespace declarations from Type
|
||||
$typeXml = [regex]::Replace($typeXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
||||
|
||||
$attrs += @{ Name = $attrName; Uuid = $uuid; TypeXml = $typeXml }
|
||||
$attrs += @{ Name = $attrName; Uuid = $uuid; TypeXml = $typeXml; Kind = $child.LocalName }
|
||||
}
|
||||
elseif ($child.LocalName -eq 'TabularSection') {
|
||||
$nameNode = $child.SelectSingleNode("md:Properties/md:Name", $srcNs)
|
||||
@@ -1199,8 +1568,16 @@ function Resolve-SourceAttributes {
|
||||
$extraProps = [ordered]@{}
|
||||
$propsNode = $srcEl.SelectSingleNode("md:Properties", $srcNs)
|
||||
if ($propsNode) {
|
||||
$propsToExtract = @("Hierarchical","FoldersOnTop","CodeLength","DescriptionLength","CodeType","CodeAllowedLength",
|
||||
"NumberType","NumberLength","NumberAllowedLength","NumberPeriodicity")
|
||||
# NumberPeriodicity сюда НЕ входит: платформа считает его модификацией настроек нумерации и
|
||||
# тогда требует объявить ещё и <Numerator/>, иначе /UpdateDBCfg падает — «отключать
|
||||
# контролируемость свойства "Нумератор" недопустимо». Конфигуратор его не переносит
|
||||
# (эталон заимствования документа: NumberType/NumberLength/NumberAllowedLength и всё).
|
||||
# Загрузку это не ломает, ошибка вылезает только на обновлении конфигурации БД.
|
||||
# FoldersOnTop сюда НЕ входит: платформа его у заимствованной оболочки не хранит — при
|
||||
# загрузке молча выбрасывает (проверено раундтрипом: записали, выгрузили обратно, свойства
|
||||
# нет). Конфигуратор его тоже не переносит. Остальные из списка сохраняются.
|
||||
$propsToExtract = @("Hierarchical","CodeLength","DescriptionLength","CodeType","CodeAllowedLength",
|
||||
"NumberType","NumberLength","NumberAllowedLength")
|
||||
foreach ($pName in $propsToExtract) {
|
||||
$pNode = $propsNode.SelectSingleNode("md:${pName}", $srcNs)
|
||||
if ($pNode) { $extraProps[$pName] = $pNode.InnerText }
|
||||
@@ -1212,11 +1589,11 @@ function Resolve-SourceAttributes {
|
||||
|
||||
# --- 11d. Build adopted attribute XML ---
|
||||
function Build-AdoptedAttributeXml {
|
||||
param([string]$name, [string]$sourceUuid, [string]$typeXml, [string]$indent)
|
||||
param([string]$name, [string]$sourceUuid, [string]$typeXml, [string]$indent, [string]$kind = "Attribute")
|
||||
|
||||
$newUuid = [guid]::NewGuid().ToString()
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
$sb.AppendLine("${indent}<Attribute uuid=`"${newUuid}`">") | Out-Null
|
||||
$sb.AppendLine("${indent}<${kind} uuid=`"${newUuid}`">") | Out-Null
|
||||
$sb.AppendLine("${indent}`t<InternalInfo/>") | Out-Null
|
||||
$sb.AppendLine("${indent}`t<Properties>") | Out-Null
|
||||
$sb.AppendLine("${indent}`t`t<ObjectBelonging>Adopted</ObjectBelonging>") | Out-Null
|
||||
@@ -1225,7 +1602,7 @@ function Build-AdoptedAttributeXml {
|
||||
$sb.AppendLine("${indent}`t`t<ExtendedConfigurationObject>${sourceUuid}</ExtendedConfigurationObject>") | Out-Null
|
||||
$sb.AppendLine("${indent}`t`t${typeXml}") | Out-Null
|
||||
$sb.AppendLine("${indent}`t</Properties>") | Out-Null
|
||||
$sb.Append("${indent}</Attribute>") | Out-Null
|
||||
$sb.Append("${indent}</${kind}>") | Out-Null
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
@@ -1348,14 +1725,6 @@ function Merge-AttributesIntoObject {
|
||||
$added = 0
|
||||
foreach ($attr in $attrsToAdd) {
|
||||
if ($existingNames.ContainsKey($attr.Name)) { continue }
|
||||
$attrXml = Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t"
|
||||
|
||||
# Expand self-closing ChildObjects if needed
|
||||
if (-not $childObjs.HasChildNodes -or $childObjs.IsEmpty) {
|
||||
$closeWs = $objDoc.CreateWhitespace("`r`n`t`t")
|
||||
$childObjs.AppendChild($closeWs) | Out-Null
|
||||
}
|
||||
|
||||
$added++
|
||||
}
|
||||
|
||||
@@ -1364,7 +1733,8 @@ function Merge-AttributesIntoObject {
|
||||
$allAttrXml = ""
|
||||
foreach ($attr in $attrsToAdd) {
|
||||
if ($existingNames.ContainsKey($attr.Name)) { continue }
|
||||
$allAttrXml += "`r`n" + (Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t")
|
||||
$kind = if ($attr.Kind) { $attr.Kind } else { "Attribute" }
|
||||
$allAttrXml += "`r`n" + (Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t" $kind)
|
||||
}
|
||||
|
||||
# Save via text manipulation to avoid namespace issues with InnerXml
|
||||
@@ -1382,10 +1752,21 @@ function Merge-AttributesIntoObject {
|
||||
if ($text3.Length -gt 0 -and $text3[0] -eq [char]0xFEFF) { $text3 = $text3.Substring(1) }
|
||||
$text3 = $text3.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
|
||||
# Insert attributes before </ChildObjects>
|
||||
$text3 = $text3 -replace '</ChildObjects>', "${allAttrXml}`r`n`t`t</ChildObjects>"
|
||||
# Самозакрытый элемент раскрывается текстом, а не пробельным узлом в DOM: тот давал
|
||||
# лишнюю строку с табуляцией перед первым <Attribute> (у Конфигуратора пустых строк нет).
|
||||
$text3 = Insert-IntoOwnChildObjects $text3 $allAttrXml
|
||||
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
# Стоит ПОСЛЕ вставки реквизитов, чтобы накрыть и их.
|
||||
$text3 = [regex]::Replace($text3, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$utf8Bom3 = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $objFile) -and ([System.IO.File]::ReadAllText($objFile) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text3 = ($text3 -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objFile, $text3, $utf8Bom3)
|
||||
Info " Merged $added attribute(s) into: $objFile"
|
||||
}
|
||||
@@ -1407,7 +1788,16 @@ function Borrow-MainAttribute {
|
||||
Write-Error "Source Form.xml not found: $srcFormXmlPath"
|
||||
exit 1
|
||||
}
|
||||
$dp = Collect-FormDataPaths $srcFormXmlPath
|
||||
# Имя основного реквизита исходной формы — корень путей, которые надо собрать
|
||||
$dpDoc = New-Object System.Xml.XmlDocument
|
||||
$dpDoc.PreserveWhitespace = $true
|
||||
$dpDoc.Load($srcFormXmlPath)
|
||||
$dpInfo = Get-MainAttributeInfo $dpDoc.DocumentElement '\s+xmlns(?::\w+)?="[^"]*"'
|
||||
if (-not $dpInfo) {
|
||||
Warn " У формы нет основного реквизита — заимствовать нечего"
|
||||
return
|
||||
}
|
||||
$dp = Collect-FormDataPaths $srcFormXmlPath $dpInfo.Name
|
||||
$firstLevelNames = $dp.FirstLevel
|
||||
$deepPaths = $dp.DeepPaths
|
||||
Info " Collected $($firstLevelNames.Count) first-level DataPath references, $($deepPaths.Count) deep paths"
|
||||
@@ -1433,19 +1823,15 @@ function Borrow-MainAttribute {
|
||||
$objContent = [System.IO.File]::ReadAllText($objFile, (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
# Dedup: skip attributes/TS already present in object's ChildObjects (idempotent re-borrow)
|
||||
$existingChildNames = @{}
|
||||
if ($objContent -match '(?s)<ChildObjects>(.*?)</ChildObjects>') {
|
||||
foreach ($nm in [regex]::Matches($Matches[1], '<Name>(\w+)</Name>')) {
|
||||
$existingChildNames[$nm.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
$existingChildNames = Get-OwnChildObjectNames $objFile
|
||||
$insertAttrs = @($srcAttrs | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||
$insertTS = @($srcTS | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||
|
||||
# Generate full object XML with attributes and TS
|
||||
$contentSb = New-Object System.Text.StringBuilder
|
||||
foreach ($attr in $insertAttrs) {
|
||||
$attrXml = Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t"
|
||||
$attrKind = if ($attr.Kind) { $attr.Kind } else { "Attribute" }
|
||||
$attrXml = Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t" $attrKind
|
||||
$contentSb.AppendLine($attrXml) | Out-Null
|
||||
}
|
||||
foreach ($ts in $insertTS) {
|
||||
@@ -1470,17 +1856,9 @@ function Borrow-MainAttribute {
|
||||
}
|
||||
}
|
||||
|
||||
# Replace empty ChildObjects with adopted content
|
||||
# Добавить заимствованное содержимое в ChildObjects объекта (там уже может лежать <Form>)
|
||||
if ($adoptedContent) {
|
||||
# Handle <ChildObjects/> (self-closing)
|
||||
if ($objContent -match '<ChildObjects\s*/>') {
|
||||
$objContent = $objContent -replace '<ChildObjects\s*/>', "<ChildObjects>`r`n${adoptedContent}`r`n`t`t</ChildObjects>"
|
||||
}
|
||||
# Handle <ChildObjects>...</ChildObjects> (may already have Form entry)
|
||||
elseif ($objContent -match '(?s)<ChildObjects>(.*?)</ChildObjects>') {
|
||||
$existingInner = $Matches[1]
|
||||
$objContent = $objContent -replace '(?s)<ChildObjects>(.*?)</ChildObjects>', "<ChildObjects>${existingInner}`r`n${adoptedContent}`r`n`t`t</ChildObjects>"
|
||||
}
|
||||
$objContent = Insert-IntoOwnChildObjects $objContent "`r`n${adoptedContent}"
|
||||
}
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
@@ -1493,6 +1871,21 @@ function Borrow-MainAttribute {
|
||||
foreach ($ts in $srcTS) {
|
||||
foreach ($tsa in $ts.Attributes) { $allTypeXmls += $tsa.TypeXml }
|
||||
}
|
||||
# Типы из <Columns> основного реквизита формы: колонку мы переносим (Borrow-Form), значит и её
|
||||
# тип должен быть заимствован — иначе колонка ссылается на DefinedType/справочник, которого в
|
||||
# расширении нет. Конфигуратор поступает так же (эталон: DefinedTypes/Артикул при заимствовании
|
||||
# формы заказа поставщику).
|
||||
$srcFormForCols = Join-Path (Join-Path (Join-Path (Join-Path (Join-Path $cfgDir $dirName) $objName) "Forms") $formName) "Ext/Form.xml"
|
||||
if (Test-Path $srcFormForCols) {
|
||||
$colsDoc = New-Object System.Xml.XmlDocument
|
||||
$colsDoc.PreserveWhitespace = $true
|
||||
$colsDoc.Load($srcFormForCols)
|
||||
$colsInfo = Get-MainAttributeInfo $colsDoc.DocumentElement '\s+xmlns(?::\w+)?="[^"]*"'
|
||||
if ($colsInfo) {
|
||||
foreach ($m in [regex]::Matches($colsInfo.Xml, '(?s)<Columns>.*?</Columns>')) { $allTypeXmls += $m.Value }
|
||||
}
|
||||
}
|
||||
|
||||
$refTypes = Collect-ReferenceTypes $allTypeXmls
|
||||
Info " Reference types to borrow: $($refTypes.Count)"
|
||||
|
||||
@@ -1666,10 +2059,20 @@ function Build-BorrowedObjectXml {
|
||||
$sb.AppendLine("`t`t`t$($sourceProps['__TypeXml'])") | Out-Null
|
||||
}
|
||||
|
||||
# Свойства, от которых зависят стандартные поля (см. $script:typeGateProps / $script:typesWithOwners)
|
||||
foreach ($gp in @($script:typeGateProps[$typeName])) {
|
||||
if ($gp -and $sourceProps.ContainsKey($gp)) {
|
||||
$sb.AppendLine("`t`t`t<${gp}>$($sourceProps[$gp])</${gp}>") | Out-Null
|
||||
}
|
||||
}
|
||||
if ($sourceProps.ContainsKey("__OwnersXml")) {
|
||||
$sb.AppendLine("`t`t`t$($sourceProps['__OwnersXml'])") | Out-Null
|
||||
}
|
||||
|
||||
$sb.AppendLine("`t`t</Properties>") | Out-Null
|
||||
|
||||
# ChildObjects (for types that need it)
|
||||
if ($typesWithChildObjects -contains $typeName) {
|
||||
if ($sourceProps["__HasChildObjects"] -or ($typesWithChildObjects -contains $typeName)) {
|
||||
$sb.AppendLine("`t`t<ChildObjects/>") | Out-Null
|
||||
}
|
||||
|
||||
@@ -1833,6 +2236,47 @@ foreach ($item in $items) {
|
||||
}
|
||||
}
|
||||
|
||||
# --- 14b. Владельцы заимствованных справочников ---
|
||||
# Ссылка в <Owners> должна вести на объект, который в расширении есть: иначе платформа падает при
|
||||
# загрузке (проверено — access violation, не сообщение об ошибке). Конфигуратор владельца
|
||||
# заимствует (эталон Issue66Example7_1: вместе со справочником перенесён и его ПВХ-владелец).
|
||||
# Проход общий и повторяется, пока находятся новые: у владельца может быть свой владелец.
|
||||
$ownerPass = 0
|
||||
while ($true) {
|
||||
$ownerPass++
|
||||
if ($ownerPass -gt 10) { break }
|
||||
$newOwners = @()
|
||||
foreach ($shell in (Get-ChildItem -Path $extDir -Filter "*.xml" -Recurse -File)) {
|
||||
$shellText = [System.IO.File]::ReadAllText($shell.FullName)
|
||||
if ($shellText -notmatch '<Owners>') { continue }
|
||||
foreach ($om in [regex]::Matches($shellText, '<xr:Item[^>]*>(\w+)\.(\w+)</xr:Item>')) {
|
||||
$oType = $om.Groups[1].Value; $oName = $om.Groups[2].Value
|
||||
if (-not $childTypeDirMap.ContainsKey($oType)) { continue }
|
||||
if (Test-ObjectBorrowed $oType $oName) { continue }
|
||||
if ($newOwners | Where-Object { $_.T -eq $oType -and $_.N -eq $oName }) { continue }
|
||||
$newOwners += @{ T = $oType; N = $oName }
|
||||
}
|
||||
}
|
||||
if ($newOwners.Count -eq 0) { break }
|
||||
foreach ($ow in $newOwners) {
|
||||
$owSrcFile = Join-Path (Join-Path $cfgDir $childTypeDirMap[$ow.T]) "$($ow.N).xml"
|
||||
if (-not (Test-Path $owSrcFile)) {
|
||||
Warn " Владелец $($ow.T).$($ow.N) не найден в источнике — ссылка останется висячей"
|
||||
continue
|
||||
}
|
||||
$owSrc = Read-SourceObject $ow.T $ow.N
|
||||
$owXml = Build-BorrowedObjectXml $ow.T $ow.N $owSrc.Uuid $owSrc.Properties
|
||||
$owDir = Join-Path $extDir $childTypeDirMap[$ow.T]
|
||||
if (-not (Test-Path $owDir)) { New-Item -ItemType Directory -Path $owDir -Force | Out-Null }
|
||||
$owFile = Join-Path $owDir "$($ow.N).xml"
|
||||
$owEnc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($owFile, $owXml, $owEnc)
|
||||
Add-ToChildObjects $ow.T $ow.N
|
||||
$script:borrowedFiles += $owFile
|
||||
Info " Auto-borrowed owner: $($ow.T).$($ow.N)"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 15. Save modified Configuration.xml ---
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||
@@ -1849,8 +2293,16 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $extResolvedPath) -and ([System.IO.File]::ReadAllText($extResolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($extResolvedPath, $text, $utf8Bom)
|
||||
Info "Saved: $extResolvedPath"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
```
|
||||
|
||||
## Mode A — обзор расширения
|
||||
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
|
||||
|
||||
```powershell
|
||||
# Обзор — что изменено в расширении
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
|
||||
# Проверка переноса — все ли #Вставка перенесены
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.2 — Analyze and compare 1C configuration extension (CFE) (+тип Bot; cfe-diff/cfe-borrow: недостающие типы)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -49,7 +49,9 @@ $childTypeDirMap = @{
|
||||
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
||||
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||
"CommonAttribute"="CommonAttributes"
|
||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
|
||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"Bot"="Bots"
|
||||
}
|
||||
|
||||
# --- Parse extension Configuration.xml ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.2 — Analyze and compare 1C configuration extension (CFE) (+тип Bot; cfe-diff/cfe-borrow: недостающие типы)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ 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)
|
||||
|
||||
|
||||
# --- Namespace maps ---
|
||||
|
||||
MD_NSMAP = {
|
||||
@@ -60,6 +82,12 @@ CHILD_TYPE_DIR_MAP = {
|
||||
"Sequence": "Sequences",
|
||||
"IntegrationService": "IntegrationServices",
|
||||
"CommonAttribute": "CommonAttributes",
|
||||
"Style": "Styles",
|
||||
"XDTOPackage": "XDTOPackages",
|
||||
"WebService": "WebServices",
|
||||
"HTTPService": "HTTPServices",
|
||||
"WSReference": "WSReferences",
|
||||
"Bot": "Bots",
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +496,7 @@ def main():
|
||||
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
||||
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
||||
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
config_path = args.ConfigPath
|
||||
|
||||
@@ -33,39 +33,39 @@ allowed-tools:
|
||||
| `Name` | Имя расширения (обязат.) | — |
|
||||
| `Synonym` | Синоним | = Name |
|
||||
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
||||
| `OutputDir` | Каталог для создания | `src` |
|
||||
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
|
||||
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
||||
| `Version` | Версия расширения | — |
|
||||
| `Vendor` | Поставщик | — |
|
||||
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` |
|
||||
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
|
||||
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
||||
| `NoRole` | Без основной роли | false |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
||||
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src
|
||||
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-исправление с явным режимом совместимости
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-доработка с версией
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
|
||||
|
||||
# Без роли, с явным префиксом
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <OutputDir>
|
||||
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -16,6 +16,12 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Default NamePrefix ---
|
||||
@@ -121,20 +127,23 @@ $co7 = [guid]::NewGuid().ToString()
|
||||
# --- 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>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Role name ---
|
||||
$roleName = "${NamePrefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
$defaultRolesXml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
$defaultRolesEl = "<DefaultRoles/>"
|
||||
if (-not $NoRole) {
|
||||
$defaultRolesXml = "`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t"
|
||||
$defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
|
||||
}
|
||||
|
||||
# --- ChildObjects ---
|
||||
@@ -144,10 +153,32 @@ if (-not $NoRole) {
|
||||
}
|
||||
$childObjectsXml += "`r`n`t`t"
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
$f221Captions = ""
|
||||
if ((Get-FormatRank $formatVersion) -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -181,21 +212,21 @@ $cfgXml = @"
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
|
||||
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
<NamePrefix>$([System.Security.SecurityElement]::Escape($NamePrefix))</NamePrefix>
|
||||
<NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
|
||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles>$defaultRolesXml</DefaultRoles>
|
||||
<Vendor>$vendorXml</Vendor>
|
||||
<Version>$versionXml</Version>
|
||||
$defaultRolesEl
|
||||
$vendorEl
|
||||
$versionEl$f221Captions
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
<DetailedInformation/>
|
||||
@@ -212,7 +243,7 @@ $cfgXml = @"
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
$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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<InternalInfo/>
|
||||
<Properties>
|
||||
@@ -229,10 +260,10 @@ $langXml = @"
|
||||
# --- Role XML ---
|
||||
$roleXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Role uuid="$uuidRole">
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name>
|
||||
<Name>$(Esc-XmlText ($roleName))</Name>
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
</Properties>
|
||||
@@ -252,9 +283,18 @@ if (-not (Test-Path $langDir)) {
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
|
||||
# --- Role ---
|
||||
if (-not $NoRole) {
|
||||
@@ -263,7 +303,7 @@ if (-not $NoRole) {
|
||||
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
|
||||
}
|
||||
$roleFile = Join-Path $roleDir "$roleName.xml"
|
||||
[System.IO.File]::WriteAllText($roleFile, $roleXml, $enc)
|
||||
Write-XmlFile $roleFile $roleXml $enc
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
|
||||
@@ -1,20 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration extension."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с 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
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -29,7 +71,7 @@ def main():
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
|
||||
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -126,18 +168,23 @@ def main():
|
||||
# --- 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(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
||||
version_xml = esc_xml(version) if version else ""
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
# --- Role name ---
|
||||
role_name = f"{name_prefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
default_roles_xml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
default_roles_el = "<DefaultRoles/>"
|
||||
if not args.NoRole:
|
||||
default_roles_xml = f'\r\n\t\t\t\t<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>\r\n\t\t\t'
|
||||
default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
|
||||
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
|
||||
'\r\n\t\t\t</DefaultRoles>')
|
||||
|
||||
# --- ChildObjects ---
|
||||
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
|
||||
@@ -156,6 +203,40 @@ def main():
|
||||
]
|
||||
|
||||
contained_objects = ""
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
f221_captions = ""
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||
@@ -163,27 +244,27 @@ def main():
|
||||
\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" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
|
||||
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
\t\t\t<NamePrefix>{esc_xml(name_prefix)}</NamePrefix>
|
||||
\t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<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>{default_roles_xml}</DefaultRoles>
|
||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
||||
\t\t\t<Version>{version_xml}</Version>
|
||||
\t\t\t{default_roles_el}
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}{f221_captions}
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
\t\t\t<DetailedInformation/>
|
||||
@@ -198,7 +279,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<InternalInfo/>
|
||||
\t\t<Properties>
|
||||
@@ -213,10 +294,10 @@ def main():
|
||||
|
||||
# --- Role XML ---
|
||||
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Role uuid="{uuid_role}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(role_name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(role_name)}</Name>
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t</Properties>
|
||||
@@ -229,9 +310,9 @@ def main():
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_utf8_bom(cfg_file, cfg_xml)
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_utf8_bom(lang_file, lang_xml)
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
|
||||
# --- Role ---
|
||||
role_file = None
|
||||
@@ -239,7 +320,7 @@ def main():
|
||||
role_dir = os.path.join(output_dir, "Roles")
|
||||
os.makedirs(role_dir, exist_ok=True)
|
||||
role_file = os.path.join(role_dir, f"{role_name}.xml")
|
||||
write_utf8_bom(role_file, role_xml)
|
||||
write_xml_file(role_file, role_xml)
|
||||
|
||||
# --- Output ---
|
||||
print(f"[OK] Создано расширение: {name}")
|
||||
|
||||
@@ -110,36 +110,36 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# cfe-patch-method v2.7 — Source-aware method interceptor for 1C extension (CFE) (+прощающий ввод: имя каталога наравне с именем типа)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# cfe-patch-method v2.7 — Source-aware method interceptor for 1C extension (CFE) (+прощающий ввод: имя каталога наравне с именем типа)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
TYPE_DIR_MAP = {
|
||||
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
|
||||
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
|
||||
@@ -19,6 +41,24 @@ TYPE_DIR_MAP = {
|
||||
"BusinessProcess": "BusinessProcesses", "Task": "Tasks",
|
||||
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
|
||||
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
|
||||
# Прощающий ввод: имя каталога принимается наравне с именем типа (Catalogs.X ≡ Catalog.X) —
|
||||
# PS1-порт так умел с самого начала, PY отставал.
|
||||
"Catalogs": "Catalogs",
|
||||
"Documents": "Documents",
|
||||
"Enums": "Enums",
|
||||
"CommonModules": "CommonModules",
|
||||
"Reports": "Reports",
|
||||
"DataProcessors": "DataProcessors",
|
||||
"ExchangePlans": "ExchangePlans",
|
||||
"ChartsOfAccounts": "ChartsOfAccounts",
|
||||
"ChartsOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
|
||||
"ChartsOfCalculationTypes": "ChartsOfCalculationTypes",
|
||||
"BusinessProcesses": "BusinessProcesses",
|
||||
"Tasks": "Tasks",
|
||||
"InformationRegisters": "InformationRegisters",
|
||||
"AccumulationRegisters": "AccumulationRegisters",
|
||||
"AccountingRegisters": "AccountingRegisters",
|
||||
"CalculationRegisters": "CalculationRegisters",
|
||||
}
|
||||
# accept plural forms too
|
||||
for _v in list(TYPE_DIR_MAP.values()):
|
||||
@@ -539,7 +579,7 @@ def main():
|
||||
choices=["", "Before", "After", "Instead", "ModificationAndControl"])
|
||||
parser.add_argument("-Check", action="store_true")
|
||||
parser.add_argument("-Actualize", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
config_path = args.ConfigPath
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: cfe-validate
|
||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||
argument-hint: <ExtensionPath> [-Detailed] [-MaxErrors 30]
|
||||
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -17,13 +17,24 @@ allowed-tools:
|
||||
| Параметр | Обяз. | Умолч. | Описание |
|
||||
|---------------|:-----:|---------|-------------------------------------------------|
|
||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||
| OutFile | нет | — | Записать результат в файл |
|
||||
|
||||
### ConfigPath
|
||||
|
||||
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||
|
||||
Если пользователь не указал путь — определи сам:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||
3. Возьми её поле `configSrc`
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src/Configuration.xml"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension structure (CFE)
|
||||
# cfe-validate v1.10 — Validate 1C configuration extension structure (CFE) (полнота GeneratedType, ТЧ из AdditionalColumns, сверка путей с -ConfigPath)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -9,7 +9,11 @@ param(
|
||||
|
||||
[int]$MaxErrors = 30,
|
||||
|
||||
[string]$OutFile
|
||||
[string]$OutFile,
|
||||
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -89,6 +93,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||
@@ -108,7 +125,7 @@ $validClassIds = @(
|
||||
$childObjectTypes = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -122,7 +139,7 @@ $childObjectTypes = @(
|
||||
|
||||
# Type -> directory mapping
|
||||
$childTypeDirMap = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||
@@ -144,6 +161,46 @@ $childTypeDirMap = @{
|
||||
"IntegrationService"="IntegrationServices"
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
$generatedTypeCategories = @{
|
||||
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||
"Document" = @("Object","Ref","Selection","List","Manager")
|
||||
"Enum" = @("Ref","Manager","List")
|
||||
"Constant" = @("Manager","ValueManager","ValueKey")
|
||||
"Report" = @("Object","Manager")
|
||||
"DataProcessor" = @("Object","Manager")
|
||||
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
|
||||
"Task" = @("Object","Ref","Selection","List","Manager")
|
||||
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
|
||||
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
|
||||
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
|
||||
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
|
||||
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
|
||||
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
|
||||
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
|
||||
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
|
||||
"DocumentJournal" = @("Selection","List","Manager")
|
||||
"Sequence" = @("Record","Manager","RecordSet")
|
||||
"FilterCriterion" = @("Manager","List")
|
||||
"SettingsStorage" = @("Manager")
|
||||
"IntegrationService" = @("Manager")
|
||||
"WSReference" = @("Manager")
|
||||
"DefinedType" = @("DefinedType")
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
$script:standardObjectFields = @(
|
||||
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
|
||||
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
|
||||
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
|
||||
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
|
||||
"Движения","ВерсияДанных","КоличествоСтрок"
|
||||
)
|
||||
|
||||
# Valid enum values for extension properties
|
||||
$validEnumValues = @{
|
||||
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
||||
@@ -195,10 +252,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
@@ -536,6 +598,7 @@ if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
||||
$script:enumValuesIndex = @{}
|
||||
$script:borrowedTSIndex = @{}
|
||||
$script:formList = @()
|
||||
|
||||
# Helper: check if sub-item has explicit borrowed metadata
|
||||
@@ -639,6 +702,25 @@ if ($childObjNode) {
|
||||
} else {
|
||||
$borrowedOk++
|
||||
}
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
$expectedCats = $generatedTypeCategories[$typeName]
|
||||
if ($expectedCats) {
|
||||
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$foundCats = @{}
|
||||
if ($objInfo) {
|
||||
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
|
||||
$cat = $gt.GetAttribute("category")
|
||||
if ($cat) { $foundCats[$cat] = $true }
|
||||
}
|
||||
}
|
||||
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
|
||||
if ($missingCats.Count -gt 0) {
|
||||
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
|
||||
$check9Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
@@ -666,6 +748,12 @@ if ($childObjNode) {
|
||||
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
||||
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ($tsName) {
|
||||
$tsKey = "${typeName}.${childName}"
|
||||
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
|
||||
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
|
||||
}
|
||||
if (-not $tsInfo) {
|
||||
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
||||
$check10Ok = $false
|
||||
@@ -895,6 +983,38 @@ foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
}
|
||||
}
|
||||
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки ниже на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> самой формы, потом в <BaseForm>.
|
||||
$rootName = ""
|
||||
$rootMatch = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if ($rootMatch.Success) { $rootName = $rootMatch.Groups[1].Value }
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
$acTables = @{}
|
||||
if ($rootName) {
|
||||
$rootPat = [regex]::Escape($rootName)
|
||||
foreach ($m in [regex]::Matches($raw, "<AdditionalColumns table=`"${rootPat}\.(\w+)`"")) {
|
||||
$acTables[$m.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
if ($acTables.Count -gt 0) {
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerTS = $script:borrowedTSIndex[$ownerKey]
|
||||
foreach ($tblName in $acTables.Keys) {
|
||||
$depCheckCount++
|
||||
if (-not $ownerTS -or -not $ownerTS.ContainsKey($tblName)) {
|
||||
Report-Error "12. ${ctx}: <AdditionalColumns table=`"${rootName}.${tblName}`"> — TabularSection.${tblName} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($mi in $missingItems) {
|
||||
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
@@ -930,6 +1050,127 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
||||
Report-OK "13. TypeLink: clean"
|
||||
}
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
|
||||
if (-not $ConfigPath) {
|
||||
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
|
||||
} else {
|
||||
$cfgRoot = $ConfigPath
|
||||
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
|
||||
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
|
||||
|
||||
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
|
||||
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
|
||||
} else {
|
||||
$check14Ok = $true
|
||||
$pathCheckCount = 0
|
||||
|
||||
foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
$raw = $bf.RawText
|
||||
$ctx = $bf.Context
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if (-not $rootMatch14.Success) { continue }
|
||||
$rootName = $rootMatch14.Groups[1].Value
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerParts = $ownerKey -split '\.', 2
|
||||
if ($ownerParts.Count -lt 2) { continue }
|
||||
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
|
||||
$ownerDir = $childTypeDirMap[$ownerType]
|
||||
if (-not $ownerDir) { continue }
|
||||
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
|
||||
if (-not (Test-Path $srcObjFile)) {
|
||||
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
|
||||
continue
|
||||
}
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
$srcNames = @{}
|
||||
$srcTSColumns = @{}
|
||||
$srcDoc = New-Object System.Xml.XmlDocument
|
||||
$srcDoc.PreserveWhitespace = $false
|
||||
$srcDoc.Load($srcObjFile)
|
||||
$srcObjEl = $null
|
||||
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
|
||||
}
|
||||
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
|
||||
if ($srcChildObjects) {
|
||||
foreach ($sub in $srcChildObjects.ChildNodes) {
|
||||
if ($sub.NodeType -ne 'Element') { continue }
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
|
||||
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
|
||||
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||
if (-not $nameNode) { continue }
|
||||
$subName = $nameNode.InnerText.Trim()
|
||||
$srcNames[$subName] = $true
|
||||
if ($sub.LocalName -ne 'TabularSection') { continue }
|
||||
$cols = @{}
|
||||
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
|
||||
$cols[$colName.InnerText.Trim()] = $true
|
||||
}
|
||||
$srcTSColumns[$subName] = $cols
|
||||
}
|
||||
}
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
$rootPat14 = [regex]::Escape($rootName)
|
||||
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
|
||||
$tbl = $acm.Groups[1].Value
|
||||
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
|
||||
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
|
||||
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$badPaths = @{}
|
||||
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||
$segments = $m.Groups[1].Value -split '\.'
|
||||
$seg0 = $segments[0]
|
||||
$pathCheckCount++
|
||||
if ($script:standardObjectFields -contains $seg0) { continue }
|
||||
if (-not $srcNames.ContainsKey($seg0)) {
|
||||
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
|
||||
continue
|
||||
}
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
|
||||
$seg1 = $segments[1]
|
||||
if ($script:standardObjectFields -contains $seg1) { continue }
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
|
||||
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
|
||||
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
|
||||
}
|
||||
}
|
||||
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
|
||||
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
|
||||
$check14Ok = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($check14Ok) {
|
||||
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||
$extRootDir = Split-Path $resolvedPath -Parent
|
||||
$ctrlCount = 0
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension XML structure (CFE)
|
||||
# cfe-validate v1.10 — Validate 1C configuration extension XML structure (CFE) (полнота GeneratedType, ТЧ из AdditionalColumns, сверка путей с -ConfigPath)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -37,7 +59,7 @@ VALID_CLASS_IDS = [
|
||||
CHILD_OBJECT_TYPES = [
|
||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||
@@ -54,6 +76,7 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||
'Bot': 'Bots',
|
||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||
@@ -73,6 +96,50 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'IntegrationService': 'IntegrationServices',
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
GENERATED_TYPE_CATEGORIES = {
|
||||
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Enum': ['Ref', 'Manager', 'List'],
|
||||
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
|
||||
'Report': ['Object', 'Manager'],
|
||||
'DataProcessor': ['Object', 'Manager'],
|
||||
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
|
||||
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
|
||||
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
|
||||
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
|
||||
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
|
||||
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
|
||||
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
|
||||
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
|
||||
'DocumentJournal': ['Selection', 'List', 'Manager'],
|
||||
'Sequence': ['Record', 'Manager', 'RecordSet'],
|
||||
'FilterCriterion': ['Manager', 'List'],
|
||||
'SettingsStorage': ['Manager'],
|
||||
'IntegrationService': ['Manager'],
|
||||
'WSReference': ['Manager'],
|
||||
'DefinedType': ['DefinedType'],
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
|
||||
MAIN_ATTR_RE = re.compile(
|
||||
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
|
||||
|
||||
STANDARD_OBJECT_FIELDS = {
|
||||
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
|
||||
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
|
||||
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
|
||||
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
|
||||
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
|
||||
}
|
||||
|
||||
# Valid enum values for extension properties
|
||||
VALID_ENUM_VALUES = {
|
||||
'ConfigurationExtensionCompatibilityMode': [
|
||||
@@ -94,6 +161,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -154,11 +235,15 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
max_errors = args.MaxErrors
|
||||
out_file = args.OutFile
|
||||
config_path_arg = args.ConfigPath
|
||||
|
||||
# --- Resolve path ---
|
||||
if not os.path.isabs(extension_path):
|
||||
@@ -214,10 +299,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
@@ -536,6 +628,7 @@ def main():
|
||||
MD = NS['md']
|
||||
XR = NS['xr']
|
||||
enum_values_index = {}
|
||||
borrowed_ts_index = {}
|
||||
form_list = []
|
||||
|
||||
def is_borrowed_sub_item(sub_item):
|
||||
@@ -635,6 +728,23 @@ def main():
|
||||
else:
|
||||
borrowed_ok_count += 1
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
|
||||
if expected_cats:
|
||||
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
|
||||
found_cats = set()
|
||||
if obj_info is not None:
|
||||
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
|
||||
cat = gt.get('category')
|
||||
if cat:
|
||||
found_cats.add(cat)
|
||||
missing_cats = [c for c in expected_cats if c not in found_cats]
|
||||
if missing_cats:
|
||||
word = 'category' if len(missing_cats) == 1 else 'categories'
|
||||
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
|
||||
check9_ok = False
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
||||
if obj_child_objects is not None:
|
||||
@@ -662,6 +772,9 @@ def main():
|
||||
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
||||
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ts_name_el is not None and ts_name_el.text:
|
||||
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
|
||||
if ts_info is None:
|
||||
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
||||
check10_ok = False
|
||||
@@ -854,6 +967,29 @@ def main():
|
||||
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
||||
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
|
||||
root_match = MAIN_ATTR_RE.search(raw)
|
||||
root_name = root_match.group(1) if root_match else ""
|
||||
ac_tables = set()
|
||||
if root_name:
|
||||
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
|
||||
if ac_tables:
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_ts = borrowed_ts_index.get(owner_key, {})
|
||||
for tbl_name in sorted(ac_tables):
|
||||
dep_check_count += 1
|
||||
if tbl_name not in owner_ts:
|
||||
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
|
||||
check12_ok = False
|
||||
|
||||
for mi in missing_items:
|
||||
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
||||
check12_ok = False
|
||||
@@ -885,6 +1021,130 @@ def main():
|
||||
elif check13_ok:
|
||||
r.ok('13. TypeLink: clean')
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if not r.stopped and borrowed_forms_with_tree:
|
||||
if not config_path_arg:
|
||||
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
|
||||
else:
|
||||
cfg_root = config_path_arg
|
||||
if not os.path.isabs(cfg_root):
|
||||
cfg_root = os.path.join(os.getcwd(), cfg_root)
|
||||
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
|
||||
cfg_root = os.path.dirname(cfg_root)
|
||||
|
||||
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
|
||||
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
|
||||
else:
|
||||
check14_ok = True
|
||||
path_check_count = 0
|
||||
|
||||
for bf in borrowed_forms_with_tree:
|
||||
raw = bf['RawText']
|
||||
ctx = bf['Context']
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
root_match14 = MAIN_ATTR_RE.search(raw)
|
||||
if root_match14 is None:
|
||||
continue
|
||||
root_name14 = root_match14.group(1)
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if '>cfg:DynamicList<' in root_match14.group(0):
|
||||
continue
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_parts = owner_key.split('.', 1)
|
||||
if len(owner_parts) < 2:
|
||||
continue
|
||||
owner_type, owner_name = owner_parts
|
||||
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
|
||||
if not owner_dir:
|
||||
continue
|
||||
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
|
||||
if not os.path.isfile(src_obj_file):
|
||||
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
|
||||
continue
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
src_names = set()
|
||||
src_ts_columns = {}
|
||||
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
|
||||
src_obj_el = None
|
||||
for c in src_tree.getroot():
|
||||
if isinstance(c.tag, str):
|
||||
src_obj_el = c
|
||||
break
|
||||
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
|
||||
if src_child_objects is not None:
|
||||
for sub in src_child_objects:
|
||||
if not isinstance(sub.tag, str):
|
||||
continue
|
||||
sub_ln = etree.QName(sub.tag).localname
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
|
||||
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
|
||||
continue
|
||||
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
if name_el is None or not name_el.text:
|
||||
continue
|
||||
sub_name = name_el.text.strip()
|
||||
src_names.add(sub_name)
|
||||
if sub_ln != 'TabularSection':
|
||||
continue
|
||||
cols = set()
|
||||
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
|
||||
if col_name.text:
|
||||
cols.add(col_name.text.strip())
|
||||
src_ts_columns[sub_name] = cols
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
root_pat14 = re.escape(root_name14)
|
||||
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
|
||||
tbl = acm.group(1)
|
||||
cols = src_ts_columns.setdefault(tbl, set())
|
||||
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
|
||||
cols.add(cm.group(1))
|
||||
|
||||
bad_paths = {}
|
||||
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
|
||||
segments = m.group(1).split('.')
|
||||
seg0 = segments[0]
|
||||
path_check_count += 1
|
||||
if seg0 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
if seg0 not in src_names:
|
||||
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
|
||||
continue
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if len(segments) < 2 or seg0 not in src_ts_columns:
|
||||
continue
|
||||
seg1 = segments[1]
|
||||
if seg1 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
|
||||
continue
|
||||
if seg1 not in src_ts_columns[seg0]:
|
||||
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
|
||||
|
||||
for bad in sorted(bad_paths):
|
||||
r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}")
|
||||
check14_ok = False
|
||||
|
||||
if check14_ok:
|
||||
r.ok(f'14. Object paths vs source config: {path_check_count} checked')
|
||||
|
||||
if r.stopped:
|
||||
r.finalize(out_file)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||
ctrl_count = 0
|
||||
for dp, _dn, files in os.walk(config_dir):
|
||||
|
||||
@@ -45,6 +45,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
|
||||
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
|
||||
| `-AddToList` | нет | Добавить в список баз 1С |
|
||||
| `-ListName <имя>` | нет | Имя базы в списке |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-create v1.7 — Create 1C information base
|
||||
# db-create v1.11 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -30,6 +30,12 @@
|
||||
.PARAMETER ListName
|
||||
Имя базы в списке
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
@@ -61,12 +67,163 @@ param(
|
||||
[switch]$AddToList,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListName
|
||||
[string]$ListName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -111,32 +268,75 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -148,6 +348,10 @@ function Test-FileIbCreated {
|
||||
|
||||
$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) {
|
||||
@@ -181,8 +385,9 @@ try {
|
||||
}
|
||||
}
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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)
|
||||
@@ -194,7 +399,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -202,6 +407,8 @@ try {
|
||||
# --- 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 {
|
||||
@@ -226,11 +433,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "create_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -257,6 +465,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.7 — Create 1C information base
|
||||
# db-create v1.11 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -93,6 +272,18 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -103,7 +294,67 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def main():
|
||||
@@ -120,11 +371,33 @@ def main():
|
||||
parser.add_argument("-UseTemplate", default="")
|
||||
parser.add_argument("-AddToList", action="store_true")
|
||||
parser.add_argument("-ListName", default="")
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -150,7 +423,8 @@ def main():
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
||||
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)
|
||||
@@ -166,10 +440,7 @@ def main():
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -180,36 +451,33 @@ def main():
|
||||
# --- 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:
|
||||
# No embedded quotes: subprocess quotes the whole token; 1C's argv parser
|
||||
# strips outer quotes. Inner quotes get escaped by list2cmdline and break parsing.
|
||||
arguments.append(f'Srvr={args.InfoBaseServer};Ref={args.InfoBaseRef}')
|
||||
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||
else:
|
||||
arguments.append(f'File={args.InfoBasePath}')
|
||||
arguments.append(f'File="{args.InfoBasePath}"')
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", args.UseTemplate])
|
||||
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", 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", out_file])
|
||||
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(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
@@ -243,6 +511,7 @@ def main():
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
||||
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-cf v1.9 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.13 — 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 не исполняется.
|
||||
<#
|
||||
@@ -36,6 +36,12 @@
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||
|
||||
@@ -70,7 +76,13 @@ param(
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -83,6 +95,164 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -127,32 +297,75 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -163,6 +376,10 @@ function Test-OutputNonEmpty {
|
||||
|
||||
$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) {
|
||||
@@ -197,8 +414,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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)
|
||||
@@ -210,7 +428,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -240,11 +458,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -266,6 +485,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.9 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.13 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -130,11 +391,34 @@ def main():
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -165,7 +449,8 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
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)
|
||||
@@ -177,10 +462,6 @@ def main():
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -192,35 +473,32 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpCfg", args.OutputFile])
|
||||
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", 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", out_file])
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -246,6 +524,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-dt v1.8 — Dump 1C information base to DT file
|
||||
# db-dump-dt v1.12 — 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 не исполняется.
|
||||
<#
|
||||
@@ -29,6 +29,12 @@
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному DT-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
@@ -54,7 +60,13 @@ param(
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -67,6 +79,164 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -111,32 +281,75 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -147,6 +360,10 @@ function Test-OutputNonEmpty {
|
||||
|
||||
$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) {
|
||||
@@ -177,8 +394,10 @@ try {
|
||||
$arguments += "$OutputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
|
||||
$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)
|
||||
@@ -190,7 +409,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -213,11 +432,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -239,6 +459,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.8 — Dump 1C information base to DT file
|
||||
# db-dump-dt v1.12 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -128,11 +389,34 @@ def main():
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -158,7 +442,8 @@ def main():
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
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)
|
||||
@@ -170,10 +455,6 @@ def main():
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -185,29 +466,26 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpIB", args.OutputFile])
|
||||
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -233,6 +511,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -56,6 +56,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-xml v1.11 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.15 — 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 не исполняется.
|
||||
<#
|
||||
@@ -48,6 +48,12 @@
|
||||
.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
|
||||
|
||||
@@ -93,7 +99,13 @@ param(
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical"
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -106,6 +118,164 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -150,32 +320,75 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -186,6 +399,10 @@ function Test-DirNonEmpty {
|
||||
|
||||
$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) {
|
||||
@@ -238,8 +455,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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)
|
||||
@@ -251,7 +469,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -309,11 +527,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -336,6 +555,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.11 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.15 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -143,12 +404,35 @@ def main():
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -196,7 +480,8 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
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)
|
||||
@@ -208,10 +493,6 @@ def main():
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -223,16 +504,16 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpConfigToFiles", args.ConfigDir]
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
if args.Mode == "Full":
|
||||
@@ -249,7 +530,7 @@ def main():
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(object_list))
|
||||
|
||||
arguments += ["-listFile", list_file]
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
print(f"Objects to dump: {len(object_list)}")
|
||||
for obj in object_list:
|
||||
print(f" {obj}")
|
||||
@@ -259,22 +540,19 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", 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", out_file]
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -301,6 +579,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -29,6 +29,7 @@ allowed-tools:
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"v8args": ["/UseHwLicenses+"],
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
@@ -61,6 +62,8 @@ allowed-tools:
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
||||
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
||||
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
||||
| `databases` | array | Массив баз данных |
|
||||
| `default` | string | id базы по умолчанию |
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
||||
| `-InputFile <путь>` | да | Путь к CF-файлу |
|
||||
| `-Extension <имя>` | нет | Загрузить как расширение |
|
||||
| `-AllExtensions` | нет | Загрузить все расширения из архива |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-cf v1.10 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.14 — 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 не исполняется.
|
||||
<#
|
||||
@@ -36,6 +36,12 @@
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||
|
||||
@@ -70,7 +76,13 @@ param(
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -100,6 +112,164 @@ function Get-ExitAnnotation {
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -144,35 +314,82 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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) {
|
||||
@@ -207,8 +424,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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) {
|
||||
@@ -216,7 +434,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -246,11 +464,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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) {
|
||||
@@ -267,6 +486,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.10 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.14 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -148,11 +409,34 @@ def main():
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -183,16 +467,13 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -204,35 +485,32 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/LoadCfg", args.InputFile])
|
||||
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", 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", out_file])
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -252,6 +530,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -68,6 +68,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
|
||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-dt v1.9 — Load 1C information base from DT file
|
||||
# db-load-dt v1.13 — 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 не исполняется.
|
||||
<#
|
||||
@@ -36,6 +36,12 @@
|
||||
.PARAMETER UnlockCode
|
||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
@@ -67,7 +73,13 @@ param(
|
||||
[int]$JobsCount = 0,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UnlockCode
|
||||
[string]$UnlockCode,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -97,6 +109,164 @@ function Get-ExitAnnotation {
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -141,35 +311,82 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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) {
|
||||
@@ -201,8 +418,10 @@ try {
|
||||
$arguments += "$InputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
|
||||
$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) {
|
||||
@@ -210,7 +429,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -235,11 +454,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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) {
|
||||
@@ -256,6 +476,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.9 — Load 1C information base from DT file
|
||||
# db-load-dt v1.13 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -148,11 +409,34 @@ def main():
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-JobsCount", type=int, default=0)
|
||||
parser.add_argument("-UnlockCode", default="")
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -180,16 +464,13 @@ def main():
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -201,33 +482,30 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
if args.UnlockCode:
|
||||
arguments.append(f"/UC{args.UnlockCode}")
|
||||
arguments.append(f'/UC"{args.UnlockCode}"')
|
||||
|
||||
arguments.extend(["/RestoreIB", args.InputFile])
|
||||
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", out_file])
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -247,6 +525,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-git v1.15 — Load Git changes into 1C database
|
||||
# db-load-git v1.20 — 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 не исполняется.
|
||||
<#
|
||||
@@ -48,6 +48,12 @@
|
||||
.PARAMETER DryRun
|
||||
Только показать что будет загружено (без загрузки)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||
|
||||
@@ -102,7 +108,19 @@ param(
|
||||
[switch]$DryRun,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -115,6 +133,128 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
@@ -191,32 +331,110 @@ if (-not $DryRun) {
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
$engine = "1cv8"
|
||||
if (-not $DryRun) {
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
@@ -230,6 +448,10 @@ function Invoke-IbcmdProcess {
|
||||
}
|
||||
}
|
||||
|
||||
# --- 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
|
||||
@@ -396,24 +618,26 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($applyArgs -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $applyArgs
|
||||
$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) {
|
||||
@@ -421,7 +645,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($applyOut) { Write-Host ($applyOut | Out-String) }
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
@@ -466,14 +690,15 @@ try {
|
||||
$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 ($arguments -join ' ') @($Password, $UserName))"
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
Write-Host ""
|
||||
@@ -483,6 +708,7 @@ try {
|
||||
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) {
|
||||
@@ -491,6 +717,17 @@ try {
|
||||
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 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-git v1.15 — Load Git changes into 1C database
|
||||
# db-load-git v1.20 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,117 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +386,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def get_object_xml_from_subfile(relative_path):
|
||||
@@ -185,7 +478,22 @@ def main():
|
||||
)
|
||||
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
args = parser.parse_args()
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", 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")
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
v8path = None
|
||||
@@ -204,6 +512,18 @@ def main():
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
@@ -340,18 +660,13 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
@@ -360,17 +675,15 @@ def main():
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(apply_args), args.Password, args.UserName)}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
if ar.stdout:
|
||||
print(ar.stdout)
|
||||
if ar.stderr:
|
||||
print(ar.stderr, file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
@@ -382,24 +695,24 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
||||
arguments += ["-listFile", list_file]
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", args.Extension]
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
@@ -409,19 +722,16 @@ def main():
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print("")
|
||||
print("Executing partial configuration load...")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
@@ -431,6 +741,7 @@ def main():
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
@@ -442,6 +753,22 @@ def main():
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <
|
||||
| `-AllExtensions` | нет | Загрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-xml v1.16 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.21 — 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 не исполняется.
|
||||
<#
|
||||
@@ -48,6 +48,12 @@
|
||||
.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
|
||||
|
||||
@@ -102,7 +108,13 @@ param(
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -132,6 +144,165 @@ function Get-ExitAnnotation {
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -176,35 +347,117 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
$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) {
|
||||
@@ -268,25 +521,27 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
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"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($applyArgs -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $applyArgs
|
||||
$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) {
|
||||
@@ -294,7 +549,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($applyOut) { Write-Host ($applyOut | Out-String) }
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
@@ -373,11 +628,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Read log ---
|
||||
$logContent = $null
|
||||
@@ -386,28 +642,7 @@ try {
|
||||
}
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
$fatalLogPatterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу'
|
||||
)
|
||||
$silentFailures = @()
|
||||
if ($logContent) {
|
||||
foreach ($line in ($logContent -split "`r?`n")) {
|
||||
foreach ($pat in $fatalLogPatterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$silentFailures += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
@@ -424,11 +659,13 @@ try {
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
||||
Write-Host $msg -ForegroundColor Yellow
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.16 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.21 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,117 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +386,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -168,13 +461,37 @@ def main():
|
||||
action="store_true",
|
||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
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:
|
||||
@@ -232,18 +549,13 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
@@ -252,17 +564,15 @@ def main():
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(apply_args), args.Password, args.UserName)}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
if ar.stdout:
|
||||
print(ar.stdout)
|
||||
if ar.stderr:
|
||||
print(ar.stderr, file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -274,16 +584,16 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration load...")
|
||||
@@ -319,7 +629,7 @@ def main():
|
||||
for fl in file_list:
|
||||
print(f" {fl}")
|
||||
|
||||
arguments += ["-listFile", generated_list_file]
|
||||
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
@@ -327,7 +637,7 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", args.Extension]
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
@@ -337,16 +647,13 @@ def main():
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Read log ---
|
||||
@@ -361,22 +668,7 @@ def main():
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
fatal_log_patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
]
|
||||
silent_failures = []
|
||||
if log_content:
|
||||
for line in log_content.splitlines():
|
||||
for pat in fatal_log_patterns:
|
||||
if pat in line:
|
||||
silent_failures.append(line.strip())
|
||||
break
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
@@ -392,15 +684,20 @@ def main():
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||
if silent_failures:
|
||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
||||
print(
|
||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
||||
f"platform loaded config but dropped properties/refs{suffix}",
|
||||
file=sys.stderr,
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for f in silent_failures:
|
||||
print(f" {f}", file=sys.stderr)
|
||||
print(f" {f}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
|
||||
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
|
||||
| `-CParam <строка>` | нет | Параметр запуска (/C) |
|
||||
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-run v1.4 — Launch 1C:Enterprise
|
||||
# db-run v1.8 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -36,6 +36,12 @@
|
||||
.PARAMETER URL
|
||||
Навигационная ссылка (e1cib/...)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
@@ -73,7 +79,13 @@ param(
|
||||
[string]$CParam,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$URL
|
||||
[string]$URL,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -86,6 +98,151 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -129,6 +286,19 @@ if (-not (Test-Path $V8Path)) {
|
||||
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
|
||||
@@ -172,9 +342,14 @@ if ($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 $argString @($Password, $UserName)
|
||||
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||
Write-Host "Running: 1cv8.exe $displayArg"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.4 — Launch 1C:Enterprise
|
||||
# db-run v1.8 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,28 @@ 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."""
|
||||
@@ -33,6 +55,181 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -100,10 +297,34 @@ def main():
|
||||
parser.add_argument("-Execute", default="")
|
||||
parser.add_argument("-CParam", default="")
|
||||
parser.add_argument("-URL", default="")
|
||||
args = parser.parse_args()
|
||||
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", file=sys.stderr)
|
||||
@@ -140,10 +361,11 @@ def main():
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
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 ---
|
||||
|
||||
@@ -53,6 +53,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
||||
| `-Server` | нет | Обновление на стороне сервера |
|
||||
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-update v1.10 — Update 1C database configuration
|
||||
# db-update v1.15 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -42,6 +42,12 @@
|
||||
.PARAMETER WarningsAsErrors
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
@@ -83,7 +89,19 @@ param(
|
||||
[switch]$Server,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -113,6 +131,163 @@ function Get-ExitAnnotation {
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -157,35 +332,117 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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
|
||||
}
|
||||
|
||||
|
||||
$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) {
|
||||
@@ -215,8 +472,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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) {
|
||||
@@ -224,7 +482,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -265,11 +523,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "update_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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) {
|
||||
@@ -278,6 +537,7 @@ try {
|
||||
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) {
|
||||
@@ -286,6 +546,17 @@ try {
|
||||
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 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.10 — Update 1C database configuration
|
||||
# db-update v1.15 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,117 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +386,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -150,12 +443,38 @@ def main():
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
args = parser.parse_args()
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", 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)
|
||||
|
||||
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:
|
||||
@@ -184,16 +503,13 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -205,14 +521,14 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
@@ -226,22 +542,19 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", 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", out_file])
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -250,6 +563,7 @@ def main():
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
@@ -261,6 +575,22 @@ def main():
|
||||
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
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-build v1.9 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.13 — 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 не исполняется.
|
||||
<#
|
||||
@@ -33,6 +33,12 @@
|
||||
.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"
|
||||
|
||||
@@ -64,7 +70,13 @@ param(
|
||||
[string]$SourceFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
@@ -77,6 +89,165 @@ function Protect-Secrets {
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -121,32 +292,75 @@ if (-not (Test-Path $V8Path)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -156,6 +370,10 @@ function Test-OutputNonEmpty {
|
||||
}
|
||||
|
||||
$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
|
||||
@@ -168,8 +386,20 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||
Write-Host "No database specified. Creating temporary stub database..."
|
||||
$stubArgs = "-SourceDir `"$sourceDir`" -V8Path `"$V8Path`" -TempBasePath `"$autoBasePath`""
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$stubScript`" $stubArgs" -NoNewWindow -Wait -PassThru
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
# Invoked via -Command, not -File: -File takes the tail literally, so an array
|
||||
# parameter would arrive as a single comma-glued token.
|
||||
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
|
||||
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $autoBasePath)"
|
||||
if ($AdditionalV8Arguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
if ($AdditionalIbcmdArguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
|
||||
if ($stubProc.ExitCode -ne 0) {
|
||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -202,8 +432,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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)
|
||||
@@ -215,7 +446,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -238,11 +469,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "build_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -264,6 +496,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.9 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.13 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -129,11 +390,35 @@ def main():
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-SourceFile", required=True, help="Path to root XML source file")
|
||||
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
|
||||
args = parser.parse_args()
|
||||
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)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -145,10 +430,16 @@ def main():
|
||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
||||
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||
print("No database specified. Creating temporary stub database...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path, "-TempBasePath", auto_base_path],
|
||||
capture_output=False,
|
||||
)
|
||||
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||
"-TempBasePath", auto_base_path]
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
if v8_extra:
|
||||
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
|
||||
if ibcmd_extra:
|
||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||
result = subprocess.run(stub_cmd, capture_output=False)
|
||||
if result.returncode != 0:
|
||||
print("Error: failed to create stub database", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -181,7 +472,8 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
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)
|
||||
@@ -193,39 +485,32 @@ def main():
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", args.SourceFile, args.OutputFile]
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -251,6 +536,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# stub-db-create v1.3 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -7,20 +7,191 @@ param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$V8Path,
|
||||
|
||||
[string]$TempBasePath
|
||||
[string]$TempBasePath,
|
||||
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
$SourceDir = ConvertTo-CleanPath $SourceDir '-SourceDir'
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$TempBasePath = ConvertTo-CleanPath $TempBasePath '-TempBasePath'
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 1. Scan XML files for reference types ---
|
||||
|
||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||
|
||||
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
|
||||
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
|
||||
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
|
||||
# запасной вариант, если корень почему-то не попался.
|
||||
$srcRootVersion = ""
|
||||
$srcAnyVersion = ""
|
||||
|
||||
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
||||
foreach ($f in $xmlFiles) {
|
||||
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
||||
|
||||
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
|
||||
$ver = $Matches[1]
|
||||
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
|
||||
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
|
||||
$srcRootVersion = $ver
|
||||
}
|
||||
}
|
||||
|
||||
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
||||
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||
@@ -187,7 +358,24 @@ if ($hasRefTypes) {
|
||||
$cfgDir = Join-Path $TempBasePath "cfg"
|
||||
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
# Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
# формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
#
|
||||
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
|
||||
$srcRank = Get-FormatRank $srcVersion
|
||||
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
|
||||
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
|
||||
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
|
||||
# формата из docs/1c-configuration-spec.md.
|
||||
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
|
||||
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="' + $stubFormatVersion + '"'
|
||||
|
||||
# GeneratedType definitions per metadata type
|
||||
$gtDefs = @{
|
||||
@@ -371,7 +559,7 @@ if ($hasRefTypes) {
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
<ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -422,7 +610,7 @@ if ($hasRefTypes) {
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
<CompatibilityMode>$stubCompatMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
</Properties>
|
||||
<ChildObjects>$childXml
|
||||
@@ -1253,34 +1441,89 @@ $propsXml </Properties>$childObjLine
|
||||
}
|
||||
|
||||
# --- 5a. Stub via ibcmd (one call: create [--import --apply]) ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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 ---"
|
||||
}
|
||||
|
||||
|
||||
$stubEngine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-TempBasePath'; '--db-path' = '-TempBasePath' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $stubEngine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# Start-Process takes these argument lists as one string, so quote each token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
$extraArgString = -join ($extraArgs | ForEach-Object { Format-ArgToken $_ })
|
||||
if ($stubEngine -eq "ibcmd") {
|
||||
Write-Host "Creating infobase (ibcmd): $TempBasePath"
|
||||
$ibData = Join-Path $env:TEMP "stub_data_$(Get-Random)"
|
||||
@@ -1288,12 +1531,13 @@ if ($stubEngine -eq "ibcmd") {
|
||||
$ibArgs = @("infobase", "create", "--db-path=$TempBasePath", "--create-database")
|
||||
if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" }
|
||||
$ibArgs += "--data=$ibData"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $ibArgs
|
||||
$ibArgs += $extraArgs
|
||||
$__ib = Invoke-PlatformProcess $V8Path $ibArgs
|
||||
$ibOut = $__ib.Output
|
||||
$ibRc = $__ib.ExitCode
|
||||
Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($ibRc -ne 0) {
|
||||
if ($ibOut) { Write-Host ($ibOut | Out-String) }
|
||||
Write-PlatformOutput $ibOut
|
||||
Write-Error "Failed to create stub infobase (code: $ibRc)"
|
||||
exit 1
|
||||
}
|
||||
@@ -1305,9 +1549,10 @@ if ($stubEngine -eq "ibcmd") {
|
||||
|
||||
# --- 5. Create infobase ---
|
||||
Write-Host "Creating infobase: $TempBasePath"
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $createArgs -NoNewWindow -Wait -PassThru
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($createArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to create infobase (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1318,10 +1563,11 @@ if ($hasRefTypes) {
|
||||
# LoadConfigFromFiles
|
||||
Write-Host "Loading configuration from files..."
|
||||
$loadLog = Join-Path $env:TEMP "stub_load_log.txt"
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $loadArgs -NoNewWindow -Wait -PassThru
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($loadArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $loadLog) { Get-Content $loadLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to load config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1329,10 +1575,11 @@ if ($hasRefTypes) {
|
||||
# UpdateDBCfg
|
||||
Write-Host "Updating database configuration..."
|
||||
$updateLog = Join-Path $env:TEMP "stub_update_log.txt"
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $updateArgs -NoNewWindow -Wait -PassThru
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($updateArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $updateLog) { Get-Content $updateLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to update DB config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# stub-db-create v1.3 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -20,6 +20,75 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
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 clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -30,7 +99,167 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def new_uuid():
|
||||
@@ -110,6 +339,66 @@ def scan_ref_types(source_dir):
|
||||
return type_map
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def detect_stub_format_version(source_dir):
|
||||
"""Версия формата заглушечной конфигурации.
|
||||
|
||||
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
|
||||
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
|
||||
вложенные файлы — запасной вариант, если корень почему-то не попался.
|
||||
"""
|
||||
root_version = ""
|
||||
any_version = ""
|
||||
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
|
||||
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
|
||||
for dirpath, _, filenames in os.walk(source_dir):
|
||||
for fn in filenames:
|
||||
if not fn.endswith('.xml'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
m = ver_pattern.search(content)
|
||||
if not m:
|
||||
continue
|
||||
if not any_version:
|
||||
any_version = m.group(1)
|
||||
if not root_version and root_pattern.search(content):
|
||||
root_version = m.group(1)
|
||||
src_version = root_version or any_version or "2.17"
|
||||
src_rank = format_rank(src_version)
|
||||
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
|
||||
|
||||
|
||||
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
|
||||
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
|
||||
# из docs/1c-configuration-spec.md.
|
||||
COMPAT_BY_FORMAT = {
|
||||
"2.13": "Version8_3_20",
|
||||
"2.14": "Version8_3_21",
|
||||
"2.15": "Version8_3_22",
|
||||
"2.16": "Version8_3_23",
|
||||
}
|
||||
|
||||
|
||||
def stub_compatibility_mode(format_version):
|
||||
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
|
||||
|
||||
|
||||
def scan_register_columns(source_dir):
|
||||
"""Scan Form.xml for register record set columns referenced via DataPath.
|
||||
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
||||
@@ -188,7 +477,7 @@ NS = (
|
||||
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
CLASS_IDS = [
|
||||
@@ -802,11 +1091,24 @@ def main():
|
||||
parser.add_argument('-SourceDir', required=True)
|
||||
parser.add_argument('-V8Path', required=True)
|
||||
parser.add_argument('-TempBasePath', default='')
|
||||
args = parser.parse_args()
|
||||
parser.add_argument('-AdditionalV8Arguments', nargs='*', default=[],
|
||||
help='Extra 1cv8 arguments, e.g. /UseHwLicenses+')
|
||||
parser.add_argument('-AdditionalIbcmdArguments', nargs='*', default=[],
|
||||
help='Extra ibcmd arguments in --key=value form')
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.SourceDir = clean_path(args.SourceDir, "-SourceDir")
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.TempBasePath = clean_path(args.TempBasePath, "-TempBasePath")
|
||||
|
||||
type_map = scan_ref_types(args.SourceDir)
|
||||
register_columns = scan_register_columns(args.SourceDir)
|
||||
has_ref_types = len(type_map) > 0
|
||||
stub_format_version = detect_stub_format_version(args.SourceDir)
|
||||
stub_compat = stub_compatibility_mode(stub_format_version)
|
||||
ns_decl = f'{NS} version="{stub_format_version}"'
|
||||
|
||||
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
||||
|
||||
@@ -838,7 +1140,7 @@ def main():
|
||||
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
||||
|
||||
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>{co_xml}
|
||||
\t\t</InternalInfo>
|
||||
@@ -847,7 +1149,7 @@ def main():
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -898,7 +1200,7 @@ def main():
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
\t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects>{child_xml}
|
||||
@@ -912,7 +1214,7 @@ def main():
|
||||
lang_dir = os.path.join(cfg_dir, 'Languages')
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
||||
@@ -1041,7 +1343,7 @@ def main():
|
||||
child_obj_xml = '\n\t\t<ChildObjects/>'
|
||||
|
||||
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
||||
\t\t<Properties>
|
||||
{props_xml}
|
||||
@@ -1057,6 +1359,10 @@ def main():
|
||||
|
||||
# Stub via ibcmd (one call: create [--import --apply])
|
||||
stub_engine = "ibcmd" if os.path.basename(args.V8Path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {"/F": "-TempBasePath", "--db-path": "-TempBasePath"}
|
||||
extra_args = resolve_extra_args(stub_engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if stub_engine == "ibcmd":
|
||||
import shutil
|
||||
print(f'Creating infobase (ibcmd): {temp_base}')
|
||||
@@ -1065,6 +1371,7 @@ def main():
|
||||
if has_ref_types:
|
||||
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
|
||||
ib_args.append(f'--data={ib_data}')
|
||||
ib_args.extend(extra_args)
|
||||
result = run_ibcmd(ib_args, warn_no_user=False)
|
||||
shutil.rmtree(ib_data, ignore_errors=True)
|
||||
if result.returncode != 0:
|
||||
@@ -1083,11 +1390,10 @@ def main():
|
||||
|
||||
# Create infobase
|
||||
print(f'Creating infobase: {temp_base}')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'CREATEINFOBASE', f'File={temp_base}', '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['CREATEINFOBASE', f'File="{temp_base}"', '/DisableStartupDialogs']
|
||||
+ [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1095,21 +1401,18 @@ def main():
|
||||
cfg_dir = os.path.join(temp_base, 'cfg')
|
||||
# LoadConfigFromFiles
|
||||
print('Loading configuration from files...')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/LoadConfigFromFiles', cfg_dir, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# UpdateDBCfg
|
||||
print('Updating database configuration...')
|
||||
update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/UpdateDBCfg', '/Out', update_log, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/UpdateDBCfg', '/Out', f'"{update_log}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
if os.path.isfile(update_log):
|
||||
try:
|
||||
@@ -1117,6 +1420,7 @@ def main():
|
||||
print(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
||||
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-dump v1.8 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.12 — 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 не исполняется.
|
||||
<#
|
||||
@@ -36,6 +36,12 @@
|
||||
.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"
|
||||
|
||||
@@ -71,12 +77,177 @@ param(
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical"
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
@@ -128,32 +299,75 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function Invoke-IbcmdProcess {
|
||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
||||
param([string]$Exe, [string[]]$IbArgs)
|
||||
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 = ($IbArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
$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
|
||||
try {
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::GetEncoding(866)
|
||||
} catch {}
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
$out = $p.StandardOutput.ReadToEnd()
|
||||
$err = $p.StandardError.ReadToEnd()
|
||||
# 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.
|
||||
@@ -170,6 +384,10 @@ function Protect-Secrets {
|
||||
}
|
||||
|
||||
$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
|
||||
@@ -203,8 +421,9 @@ try {
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
||||
$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)
|
||||
@@ -216,7 +435,7 @@ try {
|
||||
} else {
|
||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
if ($output) { Write-Host ($output | Out-String) }
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
@@ -240,11 +459,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ($arguments -join ' ') @($Password, $UserName))"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
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.
|
||||
@@ -266,6 +486,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.8 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.12 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ 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."""
|
||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
@@ -86,6 +265,85 @@ IBCMD_NOUSER_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -96,7 +354,10 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
return subprocess.run(cmd, input="", capture_output=True, encoding="utf-8", errors="replace")
|
||||
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):
|
||||
@@ -135,12 +396,36 @@ def main():
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
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", file=sys.stderr)
|
||||
@@ -178,7 +463,8 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
print(f"Running: ibcmd {_redact(' '.join(arguments), args.Password, args.UserName)}")
|
||||
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)
|
||||
@@ -190,40 +476,33 @@ def main():
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", args.OutputDir, args.InputFile]
|
||||
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", out_file]
|
||||
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(arguments), args.Password, args.UserName)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
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 ---
|
||||
@@ -249,6 +528,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -18,19 +18,26 @@ allowed-tools:
|
||||
## Usage
|
||||
|
||||
```
|
||||
/epf-init <Name> [Synonym] [SrcDir]
|
||||
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-----------|:------------:|--------------|-------------------------------------|
|
||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|------------------------------------------------|
|
||||
| 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 "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"]
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -6,21 +6,65 @@ param(
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src"
|
||||
[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 xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalDataProcessor uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -33,11 +77,11 @@ $xml = @"
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$Name</Name>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$Synonym</v8:content>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
@@ -64,7 +108,16 @@ $extDir = Join-Path $processorDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
@@ -83,6 +136,11 @@ $moduleBsl = @"
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: 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"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external data processor."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с 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")
|
||||
@@ -21,7 +67,24 @@ def main():
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
args = parser.parse_args()
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -32,8 +95,36 @@ def main():
|
||||
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="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
@@ -46,11 +137,11 @@ def main():
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
@@ -72,7 +163,7 @@ def main():
|
||||
ext_dir = os.path.join(processor_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
@@ -89,7 +180,10 @@ def main():
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
write_utf8_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создана обработка: {root_file}")
|
||||
print(f" Каталог: {processor_dir}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-validate v1.2 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.5 — Validate 1C external data processor / report structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
param(
|
||||
@@ -111,6 +111,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
@@ -183,10 +196,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Detect type: ExternalDataProcessor or ExternalReport
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-validate v1.2 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.5 — Validate 1C external data processor / report structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
from io import StringIO
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
|
||||
}
|
||||
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -50,7 +87,7 @@ def main():
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||
parser.add_argument("-OutFile", default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
max_errors = args.MaxErrors
|
||||
|
||||
@@ -163,10 +200,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get("version", "")
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
report_warn("1. Missing version attribute on MetaDataObject")
|
||||
elif version not in ("2.17", "2.20", "2.21"):
|
||||
report_warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version_rank == 0:
|
||||
report_error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
report_warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
report_warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Detect type
|
||||
child_elements = []
|
||||
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dum
|
||||
| `-InputFile <путь>` | да | Путь к ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
|
||||
@@ -18,20 +18,27 @@ allowed-tools:
|
||||
## Usage
|
||||
|
||||
```
|
||||
/erf-init <Name> [Synonym] [SrcDir] [--with-skd]
|
||||
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-----------|:------------:|--------------|---------------------------------------|
|
||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|---------------------------------------|
|
||||
| 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 "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-WithSKD]
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# erf-init v1.1 — Init 1C external report scaffold
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -8,18 +8,62 @@ param(
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$WithSKD
|
||||
[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 = ""
|
||||
@@ -48,7 +92,7 @@ $childObjectsXml = if ($childObjectsContent) {
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalReport uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -61,11 +105,11 @@ $xml = @"
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$Name</Name>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$Synonym</v8:content>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
@@ -98,7 +142,16 @@ $extDir = Join-Path $reportDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
@@ -117,6 +170,11 @@ $moduleBsl = @"
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: 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"
|
||||
@@ -136,7 +194,7 @@ if ($WithSKD) {
|
||||
|
||||
$skdMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<Template uuid="$skdUuid">
|
||||
<Properties>
|
||||
<Name>$skdName</Name>
|
||||
@@ -153,7 +211,7 @@ if ($WithSKD) {
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($skdMetaPath, $skdMetaXml, $enc)
|
||||
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||
|
||||
$skdContent = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -173,7 +231,7 @@ if ($WithSKD) {
|
||||
"@
|
||||
|
||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||
[System.IO.File]::WriteAllText($skdFilePath, $skdContent, $enc)
|
||||
Write-XmlFile $skdFilePath $skdContent $enc
|
||||
|
||||
Write-Host " СКД: $skdMetaPath"
|
||||
Write-Host " Тело: $skdFilePath"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# erf-init v1.1 — Init 1C external report scaffold
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external report."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с 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")
|
||||
@@ -21,8 +67,25 @@ def main():
|
||||
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 = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -33,6 +96,34 @@ def main():
|
||||
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 = ""
|
||||
@@ -45,7 +136,7 @@ def main():
|
||||
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="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalReport uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
@@ -58,11 +149,11 @@ def main():
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
@@ -90,7 +181,7 @@ def main():
|
||||
ext_dir = os.path.join(report_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
@@ -107,7 +198,10 @@ def main():
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
write_utf8_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создан отчёт: {root_file}")
|
||||
print(f" Каталог: {report_dir}")
|
||||
@@ -124,7 +218,7 @@ def main():
|
||||
skd_uuid = new_uuid()
|
||||
|
||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Template uuid="{skd_uuid}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{skd_name}</Name>
|
||||
@@ -140,7 +234,7 @@ def main():
|
||||
\t</Template>
|
||||
</MetaDataObject>'''
|
||||
|
||||
write_utf8_bom(skd_meta_path, skd_meta_xml)
|
||||
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||
|
||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
@@ -158,7 +252,7 @@ def main():
|
||||
</DataCompositionSchema>'''
|
||||
|
||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||
write_utf8_bom(skd_file_path, skd_content)
|
||||
write_xml_file(skd_file_path, skd_content)
|
||||
|
||||
print(f" СКД: {skd_meta_path}")
|
||||
print(f" Тело: {skd_file_path}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-add v1.10 — Add managed form to 1C config object
|
||||
# form-add v1.25 — Add managed form to 1C config object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -154,9 +154,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
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) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$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
|
||||
@@ -166,6 +177,13 @@ function Detect-FormatVersion([string]$dir) {
|
||||
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)
|
||||
@@ -187,7 +205,26 @@ if (-not (Test-Path $ObjectPath)) {
|
||||
|
||||
$objectXmlFull = Resolve-Path $ObjectPath
|
||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
$script:formatVersion = $null
|
||||
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
|
||||
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
|
||||
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
|
||||
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
@@ -310,9 +347,16 @@ 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 xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||
<Form uuid="$formUuid">
|
||||
<Properties>
|
||||
<Name>$FormName</Name>
|
||||
@@ -328,20 +372,29 @@ $formMetaXml = @"
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||
</UsePurposes>$extPresentationLine
|
||||
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||
</Properties>
|
||||
</Form>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($formMetaPath, $formMetaXml, $encBom)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $formMetaPath $formMetaXml $encBom
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||
|
||||
$formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
# Динамический список
|
||||
# MainTable: тип.имя
|
||||
@@ -349,7 +402,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -374,7 +427,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -421,7 +474,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
@@ -441,7 +494,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
if (Test-Path $formXmlPath) {
|
||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($formXmlPath, $formXml, $encBom)
|
||||
Write-XmlFile $formXmlPath $formXml $encBom
|
||||
}
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
@@ -473,6 +526,11 @@ $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)
|
||||
}
|
||||
|
||||
@@ -484,7 +542,10 @@ if (-not $childObjects) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Добавить <Form>$FormName</Form>
|
||||
# Добавить <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
|
||||
|
||||
@@ -538,6 +599,7 @@ if ($insertBefore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
@@ -578,12 +640,27 @@ if ($SetDefault -or $isFirstFormForPurpose) {
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
$stream = New-Object System.IO.FileStream($objectXmlFull.Path, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$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: Вывод ---
|
||||
|
||||
@@ -603,7 +680,11 @@ 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 ""
|
||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-add v1.10 — Add managed form to 1C config object
|
||||
# form-add v1.25 — Add managed form to 1C config object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,28 @@ 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
|
||||
@@ -196,6 +218,16 @@ NSMAP = {
|
||||
|
||||
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:
|
||||
@@ -210,6 +242,12 @@ def detect_format_version(d):
|
||||
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 → файл новый (сохранить текущее поведение)."""
|
||||
@@ -227,21 +265,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (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"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #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 — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -257,10 +296,24 @@ def save_xml_with_bom(tree, path):
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
def 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():
|
||||
@@ -272,7 +325,7 @@ def main():
|
||||
parser.add_argument("-Synonym", default=None)
|
||||
parser.add_argument("-Purpose", default="Object")
|
||||
parser.add_argument("-SetDefault", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_path = args.ObjectPath
|
||||
form_name = args.FormName
|
||||
@@ -299,7 +352,64 @@ def main():
|
||||
|
||||
object_xml_full = os.path.abspath(object_path)
|
||||
assert_edit_allowed(object_xml_full, "editable")
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к 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)
|
||||
@@ -388,24 +498,7 @@ def main():
|
||||
|
||||
form_meta_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
f' version="{format_version}">\n'
|
||||
f'<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'
|
||||
@@ -422,37 +515,22 @@ def main():
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">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_text_with_bom(form_meta_path, form_meta_xml)
|
||||
write_xml_file(form_meta_path, form_meta_xml)
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' 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"'
|
||||
)
|
||||
|
||||
if purpose in ("List", "Choice"):
|
||||
# Dynamic list
|
||||
main_table = f"{object_type}.{object_name}"
|
||||
@@ -551,7 +629,7 @@ def main():
|
||||
if os.path.exists(form_xml_path):
|
||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(form_xml_path, form_xml)
|
||||
write_xml_file(form_xml_path, form_xml)
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
|
||||
@@ -582,7 +660,10 @@ def main():
|
||||
if os.path.exists(module_path):
|
||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
# --- Phase 4: Register in parent object ---
|
||||
|
||||
@@ -592,47 +673,50 @@ def main():
|
||||
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Add <Form>$FormName</Form>
|
||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||
form_elem.text = form_name
|
||||
# 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
|
||||
|
||||
# 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)
|
||||
if not already_registered:
|
||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||
form_elem.text = form_name
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
|
||||
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"
|
||||
# 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:
|
||||
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"
|
||||
# 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 ---
|
||||
|
||||
@@ -677,7 +761,10 @@ def main():
|
||||
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()
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
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}")
|
||||
print()
|
||||
|
||||
@@ -550,8 +550,8 @@ PictureField, привязанный к булеву/числу, рисует и
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Компиляция**: `/form-compile` генерирует `Form.xml` и автоматически регистрирует `<Form>` в `ChildObjects` родительского объекта (если OutputPath следует конвенции `.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml`).
|
||||
2. **Метаданные формы** (`ФормаСписка.xml`) и `Module.bsl` создаёт `/form-add`. Если `/form-add` ещё не вызывался — вызови после `/form-compile`. Он не перезаписывает существующий Form.xml.
|
||||
1. **Каркас**: `/form-add` создаёт метаданные формы (`ФормаСписка.xml`), `Module.bsl` и регистрирует форму у объекта.
|
||||
2. **Компиляция**: `/form-compile` наполняет `Form.xml` элементами.
|
||||
3. **Проверка**: `/form-validate`, `/form-info`.
|
||||
|
||||
## Верификация
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-compile v1.175 — Compile 1C managed form from JSON or object metadata
|
||||
# form-compile v1.192 — Compile 1C managed form from JSON or object metadata (гвард на группу additionalColumns без ключа columns)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[string]$JsonPath,
|
||||
@@ -1335,9 +1335,20 @@ function Generate-ChartOfAccountsChoiceDSL($meta, [hashtable]$presetData) {
|
||||
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) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$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
|
||||
@@ -1347,6 +1358,13 @@ function Detect-FormatVersion([string]$dir) {
|
||||
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
|
||||
}
|
||||
|
||||
# --- 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
|
||||
@@ -1482,6 +1500,17 @@ $script:outPathResolved = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $Ou
|
||||
Assert-EditAllowed $script:outPathResolved 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion ([System.IO.Path]::GetDirectoryName($script:outPathResolved))
|
||||
|
||||
# Объявления пространств имён — одной переменной: место эмиссии её только интерполирует.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$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:dcssch="http://v8.1c.ru/8.1/data-composition-system/schema" 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:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
# --- 0. Path normalization and mode dispatch ---
|
||||
|
||||
# Form name → purpose mapping
|
||||
@@ -1676,6 +1705,12 @@ function X {
|
||||
}
|
||||
|
||||
function Esc-Xml {
|
||||
param([string]$s)
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
|
||||
function Esc-XmlText {
|
||||
# Экранирование ТЕКСТА элемента (<v8:content>, <Value>): только & < > .
|
||||
# Кавычки/апострофы в тексте экранировать НЕ нужно (1С их не экранирует — пишет литерально);
|
||||
# " ломал бы раундтрип. Кавычки спецсимвольны лишь в значениях атрибутов.
|
||||
@@ -1690,14 +1725,14 @@ function Emit-MLItems {
|
||||
param($val, [string]$indent)
|
||||
if ($val -is [System.Collections.IDictionary]) {
|
||||
foreach ($k in $val.Keys) {
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>$k</v8:lang>"; X "$indent`t<v8:content>$(Esc-Xml "$($val[$k])")</v8:content>"; X "$indent</v8:item>"
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>$k</v8:lang>"; X "$indent`t<v8:content>$(Esc-XmlText "$($val[$k])")</v8:content>"; X "$indent</v8:item>"
|
||||
}
|
||||
} elseif ($val -is [System.Management.Automation.PSCustomObject]) {
|
||||
foreach ($p in $val.PSObject.Properties) {
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>$($p.Name)</v8:lang>"; X "$indent`t<v8:content>$(Esc-Xml "$($p.Value)")</v8:content>"; X "$indent</v8:item>"
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>$($p.Name)</v8:lang>"; X "$indent`t<v8:content>$(Esc-XmlText "$($p.Value)")</v8:content>"; X "$indent</v8:item>"
|
||||
}
|
||||
} else {
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>ru</v8:lang>"; X "$indent`t<v8:content>$(Esc-Xml "$val")</v8:content>"; X "$indent</v8:item>"
|
||||
X "$indent<v8:item>"; X "$indent`t<v8:lang>ru</v8:lang>"; X "$indent`t<v8:content>$(Esc-XmlText "$val")</v8:content>"; X "$indent</v8:item>"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1716,7 +1751,7 @@ function Emit-USPresentation {
|
||||
param($val, [string]$tag, [string]$indent)
|
||||
if ($null -eq $val) { return }
|
||||
if ($val -is [string]) {
|
||||
X "$indent<$tag xsi:type=`"xs:string`">$(Esc-Xml $val)</$tag>"
|
||||
X "$indent<$tag xsi:type=`"xs:string`">$(Esc-XmlText $val)</$tag>"
|
||||
} else {
|
||||
Emit-MLText -tag $tag -text $val -indent $indent -xsiType "v8:LocalStringType"
|
||||
}
|
||||
@@ -1845,10 +1880,10 @@ function Emit-FilterItem {
|
||||
}
|
||||
}
|
||||
if ($item.presentation) { Emit-USPresentation -val $item.presentation -tag "dcsset:presentation" -indent "$indent`t" }
|
||||
if ($item.viewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
if ($item.viewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
if ($item.userSettingID) {
|
||||
$guid = if ("$($item.userSettingID)" -eq "auto") { New-Guid-String } else { "$($item.userSettingID)" }
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-Xml $guid)</dcsset:userSettingID>"
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-XmlText $guid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($item.userSettingPresentation) { Emit-USPresentation -val $item.userSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t" }
|
||||
X "$indent</dcsset:item>"
|
||||
@@ -1856,10 +1891,10 @@ function Emit-FilterItem {
|
||||
}
|
||||
X "$indent<dcsset:item xsi:type=`"dcsset:FilterItemComparison`">"
|
||||
if ($item.use -eq $false) { X "$indent`t<dcsset:use>false</dcsset:use>" }
|
||||
X "$indent`t<dcsset:left xsi:type=`"dcscor:Field`">$(Esc-Xml "$($item.field)")</dcsset:left>"
|
||||
X "$indent`t<dcsset:left xsi:type=`"dcscor:Field`">$(Esc-XmlText "$($item.field)")</dcsset:left>"
|
||||
$compType = $script:comparisonTypes["$($item.op)"]
|
||||
if (-not $compType) { $compType = "$($item.op)" }
|
||||
X "$indent`t<dcsset:comparisonType>$(Esc-Xml $compType)</dcsset:comparisonType>"
|
||||
X "$indent`t<dcsset:comparisonType>$(Esc-XmlText $compType)</dcsset:comparisonType>"
|
||||
$valIsArray = ($item.value -is [array]) -or ($item.value -is [System.Collections.IList] -and $item.value -isnot [string])
|
||||
if ($valIsArray) {
|
||||
if (@($item.value).Count -eq 0) {
|
||||
@@ -1878,7 +1913,7 @@ function Emit-FilterItem {
|
||||
elseif ("$v" -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { $vt = 'dcscor:DesignTimeValue' }
|
||||
else { $vt = 'xs:string' }
|
||||
}
|
||||
$vStr = if ($v -is [bool]) { "$v".ToLower() } else { Esc-Xml "$v" }
|
||||
$vStr = if ($v -is [bool]) { "$v".ToLower() } else { Esc-XmlText "$v" }
|
||||
$nsAttr = Get-ValueTypeNsAttr -valueType $vt -value "$v"
|
||||
X "$indent`t<dcsset:right$nsAttr xsi:type=`"$vt`">$vStr</dcsset:right>"
|
||||
}
|
||||
@@ -1904,8 +1939,8 @@ function Emit-FilterItem {
|
||||
$variant = "$sv"; $hasDate = $false; $dateV = $null
|
||||
}
|
||||
X "$indent`t<dcsset:right xsi:type=`"v8:$sdType`">"
|
||||
X "$indent`t`t<v8:variant xsi:type=`"v8:${sdType}Variant`">$(Esc-Xml $variant)</v8:variant>"
|
||||
if ($hasDate) { X "$indent`t`t<v8:date>$(Esc-Xml $dateV)</v8:date>" }
|
||||
X "$indent`t`t<v8:variant xsi:type=`"v8:${sdType}Variant`">$(Esc-XmlText $variant)</v8:variant>"
|
||||
if ($hasDate) { X "$indent`t`t<v8:date>$(Esc-XmlText $dateV)</v8:date>" }
|
||||
X "$indent`t</dcsset:right>"
|
||||
} elseif ("$($item.value)" -eq '_') {
|
||||
# "_" — маркер пустого значения: платформа эмитит пустой self-closing <dcsset:right>
|
||||
@@ -1923,15 +1958,15 @@ function Emit-FilterItem {
|
||||
elseif ("$v" -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { $vt = "dcscor:DesignTimeValue" }
|
||||
else { $vt = "xs:string" }
|
||||
}
|
||||
$vStr = if ($item.value -is [bool]) { "$($item.value)".ToLower() } else { Esc-Xml "$($item.value)" }
|
||||
$vStr = if ($item.value -is [bool]) { "$($item.value)".ToLower() } else { Esc-XmlText "$($item.value)" }
|
||||
$nsAttr = Get-ValueTypeNsAttr -valueType $vt -value "$($item.value)"
|
||||
X "$indent`t<dcsset:right$nsAttr xsi:type=`"$vt`">$vStr</dcsset:right>"
|
||||
}
|
||||
if ($item.presentation) { Emit-USPresentation -val $item.presentation -tag "dcsset:presentation" -indent "$indent`t" }
|
||||
if ($item.viewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
if ($item.viewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
if ($item.userSettingID) {
|
||||
$uid = if ("$($item.userSettingID)" -eq "auto") { New-Guid-String } else { "$($item.userSettingID)" }
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>"
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($item.userSettingPresentation) { Emit-USPresentation -val $item.userSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t" }
|
||||
X "$indent</dcsset:item>"
|
||||
@@ -1955,10 +1990,10 @@ function Emit-Filter {
|
||||
Emit-FilterItem -item ([pscustomobject]$obj) -indent "$indent`t"
|
||||
} else { Emit-FilterItem -item $item -indent "$indent`t" }
|
||||
}
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockUserSettingID) {
|
||||
$uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" }
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>"
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($null -ne $blockUserSettingPresentation) { Emit-USPresentation -val $blockUserSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t" }
|
||||
X "$indent</dcsset:filter>"
|
||||
@@ -1980,7 +2015,7 @@ function Emit-Order {
|
||||
if ($parts.Count -gt 1 -and $parts[1] -match '^(?i)(desc|убыв)') { $dir = "Desc" }
|
||||
elseif ($parts.Count -gt 1 -and $parts[1] -match '^(?i)(asc|возр)') { $dir = "Asc" }
|
||||
X "$indent`t<dcsset:item xsi:type=`"dcsset:OrderItemField`">"
|
||||
X "$indent`t`t<dcsset:field>$(Esc-Xml $field)</dcsset:field>"
|
||||
X "$indent`t`t<dcsset:field>$(Esc-XmlText $field)</dcsset:field>"
|
||||
X "$indent`t`t<dcsset:orderType>$dir</dcsset:orderType>"
|
||||
X "$indent`t</dcsset:item>"
|
||||
}
|
||||
@@ -1990,16 +2025,16 @@ function Emit-Order {
|
||||
if ($dir -match '^(?i)(desc|убыв)') { $dir = "Desc" } elseif ($dir -match '^(?i)(asc|возр)') { $dir = "Asc" }
|
||||
X "$indent`t<dcsset:item xsi:type=`"dcsset:OrderItemField`">"
|
||||
if ($item.use -eq $false) { X "$indent`t`t<dcsset:use>false</dcsset:use>" }
|
||||
X "$indent`t`t<dcsset:field>$(Esc-Xml "$($item.field)")</dcsset:field>"
|
||||
X "$indent`t`t<dcsset:field>$(Esc-XmlText "$($item.field)")</dcsset:field>"
|
||||
X "$indent`t`t<dcsset:orderType>$dir</dcsset:orderType>"
|
||||
if ($item.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-Xml "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
if ($item.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-XmlText "$($item.viewMode)")</dcsset:viewMode>" }
|
||||
X "$indent`t</dcsset:item>"
|
||||
}
|
||||
}
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockUserSettingID) {
|
||||
$uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" }
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>"
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($null -ne $blockUserSettingPresentation) { Emit-USPresentation -val $blockUserSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t" }
|
||||
X "$indent</dcsset:order>"
|
||||
@@ -2031,7 +2066,7 @@ function Emit-AppearanceValue {
|
||||
if (_HasKey $val 'items') { $nestedItems = (_Get $val 'items') }
|
||||
}
|
||||
if ($useWrapper) { X "$indent`t<dcscor:use>false</dcscor:use>" }
|
||||
X "$indent`t<dcscor:parameter>$(Esc-Xml $key)</dcscor:parameter>"
|
||||
X "$indent`t<dcscor:parameter>$(Esc-XmlText $key)</dcscor:parameter>"
|
||||
$isFontDict = $false
|
||||
if ($innerVal -is [PSCustomObject]) {
|
||||
$tProp = $innerVal.PSObject.Properties['@type']
|
||||
@@ -2047,7 +2082,7 @@ function Emit-AppearanceValue {
|
||||
$lg = if (_HasKey $innerVal 'gap') { if ((_Get $innerVal 'gap')) { 'true' } else { 'false' } } else { 'false' }
|
||||
$ls = if (_HasKey $innerVal 'style') { "$(_Get $innerVal 'style')" } else { 'None' }
|
||||
X "$indent`t<dcscor:value xsi:type=`"v8ui:Line`" width=`"$lw`" gap=`"$lg`">"
|
||||
X "$indent`t`t<v8ui:style xsi:type=`"v8ui:SpreadsheetDocumentCellLineType`">$(Esc-Xml $ls)</v8ui:style>"
|
||||
X "$indent`t`t<v8ui:style xsi:type=`"v8ui:SpreadsheetDocumentCellLineType`">$(Esc-XmlText $ls)</v8ui:style>"
|
||||
X "$indent`t</dcscor:value>"
|
||||
} elseif ($isFontDict) {
|
||||
$attrParts = @()
|
||||
@@ -2060,7 +2095,7 @@ function Emit-AppearanceValue {
|
||||
X "$indent`t<dcscor:value xsi:type=`"v8ui:Font`" $($attrParts -join ' ')/>"
|
||||
} elseif ($isDict -and (_HasKey $innerVal 'field')) {
|
||||
# Ссылка на поле (dcscor:Field) — значение параметра оформления = поле компоновки
|
||||
X "$indent`t<dcscor:value xsi:type=`"dcscor:Field`">$(Esc-Xml "$(_Get $innerVal 'field')")</dcscor:value>"
|
||||
X "$indent`t<dcscor:value xsi:type=`"dcscor:Field`">$(Esc-XmlText "$(_Get $innerVal 'field')")</dcscor:value>"
|
||||
} elseif ($isDict) {
|
||||
# Локализуемый текст параметра оформления: платформа объявляет xsi:type на dcscor:value
|
||||
Emit-MLText -tag "dcscor:value" -text $innerVal -indent "$indent`t" -xsiType "v8:LocalStringType"
|
||||
@@ -2075,19 +2110,19 @@ function Emit-AppearanceValue {
|
||||
'ТипМакета' = 'dcsset:DataCompositionGroupTemplateType'
|
||||
}
|
||||
$keyType = $keyTypeMap[$key]
|
||||
if ($keyType) { X "$indent`t<dcscor:value xsi:type=`"$keyType`">$(Esc-Xml $actualVal)</dcscor:value>" }
|
||||
elseif ($actualVal -match '^(style|web|win):') { X "$indent`t<dcscor:value xsi:type=`"v8ui:Color`">$(Esc-Xml $actualVal)</dcscor:value>" }
|
||||
if ($keyType) { X "$indent`t<dcscor:value xsi:type=`"$keyType`">$(Esc-XmlText $actualVal)</dcscor:value>" }
|
||||
elseif ($actualVal -match '^(style|web|win):') { X "$indent`t<dcscor:value xsi:type=`"v8ui:Color`">$(Esc-XmlText $actualVal)</dcscor:value>" }
|
||||
elseif ($actualVal -eq "true" -or $actualVal -eq "false") { X "$indent`t<dcscor:value xsi:type=`"xs:boolean`">$actualVal</dcscor:value>" }
|
||||
elseif ($key -eq "Текст" -or $key -eq "Заголовок" -or $key -eq "Формат") {
|
||||
# Текст/Заголовок/Формат: голая строка = плоский xs:string (так платформа хранит
|
||||
# нелокализованный литерал). Локализуемый текст → объект {ru,en} (ветка isDict выше).
|
||||
# Пустая строка → самозакрывающийся тег (как у платформы).
|
||||
if ($actualVal -eq '') { X "$indent`t<dcscor:value xsi:type=`"xs:string`"/>" }
|
||||
else { X "$indent`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml $actualVal)</dcscor:value>" }
|
||||
else { X "$indent`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText $actualVal)</dcscor:value>" }
|
||||
}
|
||||
elseif ($actualVal -match '^-?\d+(\.\d+)?$') { X "$indent`t<dcscor:value xsi:type=`"xs:decimal`">$actualVal</dcscor:value>" }
|
||||
elseif ($key -eq 'ЦветТекста' -or $key -eq 'ЦветФона' -or $key -eq 'ЦветГраницы') { X "$indent`t<dcscor:value xsi:type=`"v8ui:Color`">$(Esc-Xml $actualVal)</dcscor:value>" }
|
||||
else { X "$indent`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml $actualVal)</dcscor:value>" }
|
||||
elseif ($key -eq 'ЦветТекста' -or $key -eq 'ЦветФона' -or $key -eq 'ЦветГраницы') { X "$indent`t<dcscor:value xsi:type=`"v8ui:Color`">$(Esc-XmlText $actualVal)</dcscor:value>" }
|
||||
else { X "$indent`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText $actualVal)</dcscor:value>" }
|
||||
}
|
||||
if ($nestedItems) {
|
||||
$niProps = if ($nestedItems -is [PSCustomObject]) { $nestedItems.PSObject.Properties } else { $null }
|
||||
@@ -2110,7 +2145,7 @@ function Emit-ConditionalAppearance {
|
||||
X "$indent`t`t<dcsset:selection>"
|
||||
foreach ($sel in $ca.selection) {
|
||||
X "$indent`t`t`t<dcsset:item>"
|
||||
X "$indent`t`t`t`t<dcsset:field>$(Esc-Xml "$sel")</dcsset:field>"
|
||||
X "$indent`t`t`t`t<dcsset:field>$(Esc-XmlText "$sel")</dcsset:field>"
|
||||
X "$indent`t`t`t</dcsset:item>"
|
||||
}
|
||||
X "$indent`t`t</dcsset:selection>"
|
||||
@@ -2129,12 +2164,12 @@ function Emit-ConditionalAppearance {
|
||||
Emit-MLItems -val $ca.presentation -indent "$indent`t`t`t"
|
||||
X "$indent`t`t</dcsset:presentation>"
|
||||
}
|
||||
else { X "$indent`t`t<dcsset:presentation xsi:type=`"xs:string`">$(Esc-Xml "$($ca.presentation)")</dcsset:presentation>" }
|
||||
else { X "$indent`t`t<dcsset:presentation xsi:type=`"xs:string`">$(Esc-XmlText "$($ca.presentation)")</dcsset:presentation>" }
|
||||
}
|
||||
if ($ca.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-Xml "$($ca.viewMode)")</dcsset:viewMode>" }
|
||||
if ($ca.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-XmlText "$($ca.viewMode)")</dcsset:viewMode>" }
|
||||
if ($ca.userSettingID) {
|
||||
$uid = if ("$($ca.userSettingID)" -eq "auto") { New-Guid-String } else { "$($ca.userSettingID)" }
|
||||
X "$indent`t`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>"
|
||||
X "$indent`t`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($ca.userSettingPresentation) { Emit-USPresentation -val $ca.userSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t`t" }
|
||||
if ($ca.useInDontUse -and $ca.useInDontUse.Count -gt 0) {
|
||||
@@ -2150,10 +2185,10 @@ function Emit-ConditionalAppearance {
|
||||
}
|
||||
X "$indent`t</dcsset:item>"
|
||||
}
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockUserSettingID) {
|
||||
$uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" }
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>"
|
||||
X "$indent`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>"
|
||||
}
|
||||
if ($null -ne $blockUserSettingPresentation) { Emit-USPresentation -val $blockUserSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t" }
|
||||
X "$indent</$wrapTag>"
|
||||
@@ -2192,14 +2227,14 @@ function Emit-GroupItemField {
|
||||
$pae = if ($level.periodAdditionEnd) { "$($level.periodAdditionEnd)" } else { '0001-01-01T00:00:00' }
|
||||
}
|
||||
X "$indent<dcsset:item xsi:type=`"dcsset:GroupItemField`">"
|
||||
X "$indent`t<dcsset:field>$(Esc-Xml $field)</dcsset:field>"
|
||||
X "$indent`t<dcsset:groupType>$(Esc-Xml $gt)</dcsset:groupType>"
|
||||
X "$indent`t<dcsset:periodAdditionType>$(Esc-Xml $pat)</dcsset:periodAdditionType>"
|
||||
X "$indent`t<dcsset:field>$(Esc-XmlText $field)</dcsset:field>"
|
||||
X "$indent`t<dcsset:groupType>$(Esc-XmlText $gt)</dcsset:groupType>"
|
||||
X "$indent`t<dcsset:periodAdditionType>$(Esc-XmlText $pat)</dcsset:periodAdditionType>"
|
||||
# Авто-детект: ISO-дата → xs:dateTime, иначе путь → dcscor:Field.
|
||||
$pabT = if ($pab -match '^\d{4}-\d{2}-\d{2}T') { 'xs:dateTime' } else { 'dcscor:Field' }
|
||||
$paeT = if ($pae -match '^\d{4}-\d{2}-\d{2}T') { 'xs:dateTime' } else { 'dcscor:Field' }
|
||||
X "$indent`t<dcsset:periodAdditionBegin xsi:type=`"$pabT`">$(Esc-Xml $pab)</dcsset:periodAdditionBegin>"
|
||||
X "$indent`t<dcsset:periodAdditionEnd xsi:type=`"$paeT`">$(Esc-Xml $pae)</dcsset:periodAdditionEnd>"
|
||||
X "$indent`t<dcsset:periodAdditionBegin xsi:type=`"$pabT`">$(Esc-XmlText $pab)</dcsset:periodAdditionBegin>"
|
||||
X "$indent`t<dcsset:periodAdditionEnd xsi:type=`"$paeT`">$(Esc-XmlText $pae)</dcsset:periodAdditionEnd>"
|
||||
X "$indent</dcsset:item>"
|
||||
}
|
||||
|
||||
@@ -2268,22 +2303,22 @@ function Emit-CalcFields {
|
||||
}
|
||||
$ci = "$indent`t"
|
||||
X "$indent<CalculatedField>"
|
||||
X "$ci<dcssch:dataPath>$(Esc-Xml $dataPath)</dcssch:dataPath>"
|
||||
X "$ci<dcssch:expression>$(Esc-Xml $expression)</dcssch:expression>"
|
||||
X "$ci<dcssch:dataPath>$(Esc-XmlText $dataPath)</dcssch:dataPath>"
|
||||
X "$ci<dcssch:expression>$(Esc-XmlText $expression)</dcssch:expression>"
|
||||
if ($title) { Emit-MLText -tag 'dcssch:title' -text $title -indent $ci -xsiType 'v8:LocalStringType' }
|
||||
if ($restrict.Count -gt 0) {
|
||||
X "$ci<dcssch:useRestriction>"
|
||||
foreach ($r in @('field','condition','group','order')) { if ($restrict -contains $r) { X "$ci`t<dcssch:$r>true</dcssch:$r>" } }
|
||||
X "$ci</dcssch:useRestriction>"
|
||||
}
|
||||
if ($pres) { X "$ci<dcssch:presentationExpression>$(Esc-Xml "$pres")</dcssch:presentationExpression>" }
|
||||
if ($pres) { X "$ci<dcssch:presentationExpression>$(Esc-XmlText "$pres")</dcssch:presentationExpression>" }
|
||||
if ($orderExpr) {
|
||||
$oeList = if ($orderExpr -is [System.Collections.IList]) { $orderExpr } else { @($orderExpr) }
|
||||
foreach ($oe in $oeList) {
|
||||
if ($oe -is [string]) { $exprV = $oe; $oType = 'Asc'; $auto = 'false' }
|
||||
else { $exprV = "$($oe.expression)"; $oType = if ($oe.orderType) { "$($oe.orderType)" } else { 'Asc' }; $auto = if ($oe.autoOrder) { 'true' } else { 'false' } }
|
||||
X "$ci<dcssch:orderExpression>"
|
||||
X "$ci`t<expression xmlns=`"$($script:dcsCommonNs)`">$(Esc-Xml $exprV)</expression>"
|
||||
X "$ci`t<expression xmlns=`"$($script:dcsCommonNs)`">$(Esc-XmlText $exprV)</expression>"
|
||||
X "$ci`t<orderType xmlns=`"$($script:dcsCommonNs)`">$oType</orderType>"
|
||||
X "$ci`t<autoOrder xmlns=`"$($script:dcsCommonNs)`">$auto</autoOrder>"
|
||||
X "$ci</dcssch:orderExpression>"
|
||||
@@ -2368,26 +2403,49 @@ $script:knownInvalidTypes = @{
|
||||
"FormTable" = "UI element type, not a data type"
|
||||
}
|
||||
|
||||
# Алиас на локальный словарь: тело Resolve-TypeStr ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
$script:typeSynonyms = $script:formTypeSynonyms
|
||||
|
||||
function Resolve-TypeStr {
|
||||
param([string]$typeStr)
|
||||
if (-not $typeStr) { return $typeStr }
|
||||
# Lenient: strip leading cfg: prefix if user passed it (canonical form is without prefix)
|
||||
if ($typeStr -match '^cfg:(.+)$') { $typeStr = $Matches[1] }
|
||||
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if ($typeStr.StartsWith('cfg:')) {
|
||||
$typeStr = $typeStr.Substring(4)
|
||||
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||
}
|
||||
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||
$base = $Matches[1].Trim(); $params = $Matches[2]
|
||||
$r = $script:formTypeSynonyms[$base.ToLower()]
|
||||
if ($r) { return "$r($params)" }
|
||||
$baseName = $Matches[1].Trim()
|
||||
$params = $Matches[2]
|
||||
$resolved = $script:typeSynonyms[$baseName.ToLower()]
|
||||
if ($resolved) { return "$resolved($params)" }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
if ($typeStr.Contains('.')) {
|
||||
$i = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $i); $suffix = $typeStr.Substring($i)
|
||||
$r = $script:formTypeSynonyms[$prefix.ToLower()]
|
||||
if ($r) { return "$r$suffix" }
|
||||
$dotIdx = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||
if ($resolved) { return "$resolved$suffix" }
|
||||
return $typeStr
|
||||
}
|
||||
$r = $script:formTypeSynonyms[$typeStr.ToLower()]
|
||||
if ($r) { return $r }
|
||||
|
||||
# Простое имя
|
||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||
if ($resolved) { return $resolved }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
@@ -3207,7 +3265,7 @@ function Emit-CommonElementProps {
|
||||
if ($null -ne $el.($p[0])) { X "$indent<$($p[1])>$(if ($el.($p[0])){'true'}else{'false'})</$($p[1])>" }
|
||||
}
|
||||
# Динамический заголовок колонки-группы из данных (HeaderDataPath) — перед HeaderHorizontalAlign (порядок XSD)
|
||||
if ($el.headerDataPath) { X "$indent<HeaderDataPath>$(Esc-Xml "$($el.headerDataPath)")</HeaderDataPath>" }
|
||||
if ($el.headerDataPath) { X "$indent<HeaderDataPath>$(Esc-XmlText "$($el.headerDataPath)")</HeaderDataPath>" }
|
||||
if ($el.footerHorizontalAlign) { X "$indent<FooterHorizontalAlign>$($el.footerHorizontalAlign)</FooterHorizontalAlign>" }
|
||||
if ($el.headerHorizontalAlign) { X "$indent<HeaderHorizontalAlign>$($el.headerHorizontalAlign)</HeaderHorizontalAlign>" }
|
||||
# Формат заголовка колонки-группы (ML-текст) — после HeaderHorizontalAlign (порядок XSD)
|
||||
@@ -3227,8 +3285,8 @@ function Emit-PictureRef {
|
||||
if (-not $src) { return }
|
||||
$srcStr = "$src"
|
||||
X "$indent<$picTag>"
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$indent`t<xr:Abs>$(Esc-Xml $matches[1])</xr:Abs>" }
|
||||
else { X "$indent`t<xr:Ref>$(Esc-Xml $srcStr)</xr:Ref>" }
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$indent`t<xr:Abs>$(Esc-XmlText $matches[1])</xr:Abs>" }
|
||||
else { X "$indent`t<xr:Ref>$(Esc-XmlText $srcStr)</xr:Ref>" }
|
||||
X "$indent`t<xr:LoadTransparent>$(if ($lt) { 'true' } else { 'false' })</xr:LoadTransparent>"
|
||||
if ($tpx) { X "$indent`t<xr:TransparentPixel x=`"$($tpx.x)`" y=`"$($tpx.y)`"/>" }
|
||||
X "$indent</$picTag>"
|
||||
@@ -3257,8 +3315,8 @@ function Emit-CommandPicture {
|
||||
if ($null -eq $lt -and $null -ne $elemLt) { $lt = [bool]$elemLt }
|
||||
$srcStr = "$src"
|
||||
X "$indent<Picture>"
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$indent`t<xr:Abs>$(Esc-Xml $matches[1])</xr:Abs>" }
|
||||
else { X "$indent`t<xr:Ref>$(Esc-Xml $srcStr)</xr:Ref>" }
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$indent`t<xr:Abs>$(Esc-XmlText $matches[1])</xr:Abs>" }
|
||||
else { X "$indent`t<xr:Ref>$(Esc-XmlText $srcStr)</xr:Ref>" }
|
||||
X "$indent`t<xr:LoadTransparent>$(if ($lt -eq $false) { 'false' } else { 'true' })</xr:LoadTransparent>"
|
||||
if ($tpx) { X "$indent`t<xr:TransparentPixel x=`"$($tpx.x)`" y=`"$($tpx.y)`"/>" }
|
||||
X "$indent</Picture>"
|
||||
@@ -3420,7 +3478,7 @@ function Emit-GenericScalars {
|
||||
X "$indent<$($s.Tag)>$(if ($p.Value){'true'}else{'false'})</$($s.Tag)>"
|
||||
} else {
|
||||
$v = "$($p.Value)"; if ($v -eq '') { continue }
|
||||
X "$indent<$($s.Tag)>$(Esc-Xml $v)</$($s.Tag)>"
|
||||
X "$indent<$($s.Tag)>$(Esc-XmlText $v)</$($s.Tag)>"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3467,7 +3525,7 @@ function Emit-BorderTag {
|
||||
$width = if ($val.PSObject.Properties['width'] -and $null -ne $val.width) { $val.width } else { 1 }
|
||||
$style = if ($val.PSObject.Properties['style']) { "$($val.style)" } else { $null }
|
||||
X "$indent<Border width=`"$width`">"
|
||||
if ($style) { X "$indent`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-Xml $style)</v8ui:style>" }
|
||||
if ($style) { X "$indent`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-XmlText $style)</v8ui:style>" }
|
||||
X "$indent</Border>"
|
||||
}
|
||||
|
||||
@@ -3494,13 +3552,13 @@ function PL-Bool {
|
||||
}
|
||||
function Emit-PlannerColor {
|
||||
param([string]$tag, $o, [string]$key, [string]$ind)
|
||||
X "$ind<pl:$tag>$(Esc-Xml "$(PL-Get $o $key 'auto')")</pl:$tag>"
|
||||
X "$ind<pl:$tag>$(Esc-XmlText "$(PL-Get $o $key 'auto')")</pl:$tag>"
|
||||
}
|
||||
# <pl:text>/<pl:tooltip>… — пустое → самозакрывающийся тег (как в выгрузке платформы).
|
||||
function Emit-PlannerText {
|
||||
param([string]$tag, $v, [string]$ind)
|
||||
if ([string]::IsNullOrEmpty("$v")) { X "$ind<pl:$tag/>" }
|
||||
else { X "$ind<pl:$tag>$(Esc-Xml "$v")</pl:$tag>" }
|
||||
else { X "$ind<pl:$tag>$(Esc-XmlText "$v")</pl:$tag>" }
|
||||
}
|
||||
# Признак ссылочного значения (объект разреза/элемент-ссылка) → xsi:type="xr:DesignTimeRef";
|
||||
# иначе xs:string. Покрывает англ. (Enum.X.EnumValue.Y) и рус. (Справочник.X) метатипы.
|
||||
@@ -3515,7 +3573,7 @@ function Emit-PlannerValue {
|
||||
param($v, [string]$ind)
|
||||
if ($null -eq $v -or "$v" -eq '') { X "$ind<pl:value xsi:nil=`"true`"/>"; return }
|
||||
$t = if (Test-PlannerRef "$v") { 'xr:DesignTimeRef' } else { 'xs:string' }
|
||||
X "$ind<pl:value xsi:type=`"$t`">$(Esc-Xml "$v")</pl:value>"
|
||||
X "$ind<pl:value xsi:type=`"$t`">$(Esc-XmlText "$v")</pl:value>"
|
||||
}
|
||||
function Emit-PlannerFont {
|
||||
param($o, [string]$ind)
|
||||
@@ -3529,14 +3587,14 @@ function Emit-PlannerBorder {
|
||||
$bw = if ($b) { PL-Get $b 'width' 1 } else { 1 }
|
||||
$bs = if ($b) { PL-Get $b 'style' 'Single' } else { 'Single' }
|
||||
X "$ind<pl:border width=`"$bw`">"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-Xml "$bs")</v8ui:style>"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-XmlText "$bs")</v8ui:style>"
|
||||
X "$ind</pl:border>"
|
||||
}
|
||||
function Emit-PlannerLevel {
|
||||
param($lv, [string]$cns, [string]$ind)
|
||||
$li = "$ind`t"
|
||||
X "$ind<level xmlns=`"$cns`">"
|
||||
X "$li<measure>$(Esc-Xml "$(PL-Get $lv 'measure' 'Hour')")</measure>"
|
||||
X "$li<measure>$(Esc-XmlText "$(PL-Get $lv 'measure' 'Hour')")</measure>"
|
||||
X "$li<interval>$(PL-Get $lv 'interval' 1)</interval>"
|
||||
X "$li<show>$(PL-Bool (PL-Get $lv 'show' $true))</show>"
|
||||
$line = PL-Get $lv 'line' $null
|
||||
@@ -3544,10 +3602,10 @@ function Emit-PlannerLevel {
|
||||
$lg = if ($line) { PL-Get $line 'gap' $false } else { $false }
|
||||
$lst = if ($line) { PL-Get $line 'style' 'Solid' } else { 'Solid' }
|
||||
X "$li<line width=`"$lw`" gap=`"$(PL-Bool $lg)`">"
|
||||
X "$li`t<v8ui:style xsi:type=`"v8ui:ChartLineType`">$(Esc-Xml "$lst")</v8ui:style>"
|
||||
X "$li`t<v8ui:style xsi:type=`"v8ui:ChartLineType`">$(Esc-XmlText "$lst")</v8ui:style>"
|
||||
X "$li</line>"
|
||||
X "$li<scaleColor>$(Esc-Xml "$(PL-Get $lv 'scaleColor' 'auto')")</scaleColor>"
|
||||
X "$li<dayFormatRule>$(Esc-Xml "$(PL-Get $lv 'dayFormatRule' 'MonthDayWeekDay')")</dayFormatRule>"
|
||||
X "$li<scaleColor>$(Esc-XmlText "$(PL-Get $lv 'scaleColor' 'auto')")</scaleColor>"
|
||||
X "$li<dayFormatRule>$(Esc-XmlText "$(PL-Get $lv 'dayFormatRule' 'MonthDayWeekDay')")</dayFormatRule>"
|
||||
$fmt = PL-Get $lv 'format' $null
|
||||
if ($null -eq $fmt) { $fmt = [ordered]@{ '#' = 'DF="HH:mm"'; 'ru' = 'DF="HH:mm"' } }
|
||||
X "$li<format>"
|
||||
@@ -3558,8 +3616,8 @@ function Emit-PlannerLevel {
|
||||
X "$li<labels>"
|
||||
X "$li`t<ticks>$ticks</ticks>"
|
||||
X "$li</labels>"
|
||||
X "$li<backColor>$(Esc-Xml "$(PL-Get $lv 'backColor' 'auto')")</backColor>"
|
||||
X "$li<textColor>$(Esc-Xml "$(PL-Get $lv 'textColor' 'auto')")</textColor>"
|
||||
X "$li<backColor>$(Esc-XmlText "$(PL-Get $lv 'backColor' 'auto')")</backColor>"
|
||||
X "$li<textColor>$(Esc-XmlText "$(PL-Get $lv 'textColor' 'auto')")</textColor>"
|
||||
X "$li<showPereodicalLabels>$(PL-Bool (PL-Get $lv 'showPereodicalLabels' $true))</showPereodicalLabels>"
|
||||
X "$ind</level>"
|
||||
}
|
||||
@@ -3568,14 +3626,14 @@ function Emit-PlannerTimeScale {
|
||||
$cns = $script:CHART_NS
|
||||
$ci = "$ind`t"
|
||||
X "$ind<pl:timeScale>"
|
||||
X "$ci<placement xmlns=`"$cns`">$(Esc-Xml "$(if ($ts) { PL-Get $ts 'placement' 'Left' } else { 'Left' })")</placement>"
|
||||
X "$ci<placement xmlns=`"$cns`">$(Esc-XmlText "$(if ($ts) { PL-Get $ts 'placement' 'Left' } else { 'Left' })")</placement>"
|
||||
$levels = if ($ts) { @(PL-Get $ts 'levels' @()) } else { @() }
|
||||
if (@($levels).Count -eq 0) { $levels = @($null) } # один уровень-дефолт
|
||||
foreach ($lv in $levels) { Emit-PlannerLevel $lv $cns $ci }
|
||||
$transp = if ($ts) { PL-Get $ts 'transparent' $false } else { $false }
|
||||
X "$ci<transparent xmlns=`"$cns`">$(PL-Bool $transp)</transparent>"
|
||||
X "$ci<backColor xmlns=`"$cns`">$(Esc-Xml "$(if ($ts) { PL-Get $ts 'backColor' 'auto' } else { 'auto' })")</backColor>"
|
||||
X "$ci<textColor xmlns=`"$cns`">$(Esc-Xml "$(if ($ts) { PL-Get $ts 'textColor' 'auto' } else { 'auto' })")</textColor>"
|
||||
X "$ci<backColor xmlns=`"$cns`">$(Esc-XmlText "$(if ($ts) { PL-Get $ts 'backColor' 'auto' } else { 'auto' })")</backColor>"
|
||||
X "$ci<textColor xmlns=`"$cns`">$(Esc-XmlText "$(if ($ts) { PL-Get $ts 'textColor' 'auto' } else { 'auto' })")</textColor>"
|
||||
X "$ci<currentLevel xmlns=`"$cns`">$(if ($ts) { PL-Get $ts 'currentLevel' 0 } else { 0 })</currentLevel>"
|
||||
X "$ind</pl:timeScale>"
|
||||
}
|
||||
@@ -3600,7 +3658,7 @@ function Emit-PlannerItem {
|
||||
X "$ii<pl:id>$id</pl:id>"
|
||||
X "$ii<pl:textFormatted>$(PL-Bool (PL-Get $it 'textFormatted' $false))</pl:textFormatted>"
|
||||
Emit-PlannerBorder $it $ii 'border'
|
||||
X "$ii<pl:editMode>$(Esc-Xml "$(PL-Get $it 'editMode' 'EnableEdit')")</pl:editMode>"
|
||||
X "$ii<pl:editMode>$(Esc-XmlText "$(PL-Get $it 'editMode' 'EnableEdit')")</pl:editMode>"
|
||||
X "$ind</pl:item>"
|
||||
}
|
||||
# Элемент измерения (<pl:item> внутри <pl:dimension>) — рекурсивен: может нести вложенные
|
||||
@@ -3655,7 +3713,7 @@ function Emit-PlannerSettings {
|
||||
$wfmt = PL-Get $pl 'timeScaleWrapHeadersFormat' $null
|
||||
if ($null -eq $wfmt) { $wfmt = [ordered]@{ '#' = 'DLF="DD"'; 'ru' = 'DLF="DD"' } }
|
||||
Emit-MLText -tag 'pl:timeScaleWrapHeadersFormat' -text $wfmt -indent $si
|
||||
X "$si<pl:periodicVariantUnit>$(Esc-Xml "$(PL-Get $pl 'periodicVariantUnit' 'Day')")</pl:periodicVariantUnit>"
|
||||
X "$si<pl:periodicVariantUnit>$(Esc-XmlText "$(PL-Get $pl 'periodicVariantUnit' 'Day')")</pl:periodicVariantUnit>"
|
||||
X "$si<pl:periodicVariantRepetition>$(PL-Get $pl 'periodicVariantRepetition' 1)</pl:periodicVariantRepetition>"
|
||||
X "$si<pl:timeScaleWrapBeginIndent>$(PL-Get $pl 'timeScaleWrapBeginIndent' 0)</pl:timeScaleWrapBeginIndent>"
|
||||
X "$si<pl:timeScaleWrapEndIndent>$(PL-Get $pl 'timeScaleWrapEndIndent' 0)</pl:timeScaleWrapEndIndent>"
|
||||
@@ -3668,16 +3726,16 @@ function Emit-PlannerSettings {
|
||||
X "$si</pl:period>"
|
||||
}
|
||||
X "$si<pl:displayCurrentDate>$(PL-Bool (PL-Get $pl 'displayCurrentDate' $true))</pl:displayCurrentDate>"
|
||||
X "$si<pl:itemsTimeRepresentation>$(Esc-Xml "$(PL-Get $pl 'itemsTimeRepresentation' 'BeginTime')")</pl:itemsTimeRepresentation>"
|
||||
X "$si<pl:itemsBehaviorWhenSpaceInsufficient>$(Esc-Xml "$(PL-Get $pl 'itemsBehaviorWhenSpaceInsufficient' 'CollapseItems')")</pl:itemsBehaviorWhenSpaceInsufficient>"
|
||||
X "$si<pl:itemsTimeRepresentation>$(Esc-XmlText "$(PL-Get $pl 'itemsTimeRepresentation' 'BeginTime')")</pl:itemsTimeRepresentation>"
|
||||
X "$si<pl:itemsBehaviorWhenSpaceInsufficient>$(Esc-XmlText "$(PL-Get $pl 'itemsBehaviorWhenSpaceInsufficient' 'CollapseItems')")</pl:itemsBehaviorWhenSpaceInsufficient>"
|
||||
X "$si<pl:autoMinColumnWidth>$(PL-Bool (PL-Get $pl 'autoMinColumnWidth' $true))</pl:autoMinColumnWidth>"
|
||||
X "$si<pl:autoMinRowHeight>$(PL-Bool (PL-Get $pl 'autoMinRowHeight' $true))</pl:autoMinRowHeight>"
|
||||
X "$si<pl:minColumnWidth>$(PL-Get $pl 'minColumnWidth' 0)</pl:minColumnWidth>"
|
||||
X "$si<pl:minRowHeight>$(PL-Get $pl 'minRowHeight' 0)</pl:minRowHeight>"
|
||||
X "$si<pl:fixDimensionsHeader>$(Esc-Xml "$(PL-Get $pl 'fixDimensionsHeader' 'auto')")</pl:fixDimensionsHeader>"
|
||||
X "$si<pl:fixTimeScaleHeader>$(Esc-Xml "$(PL-Get $pl 'fixTimeScaleHeader' 'auto')")</pl:fixTimeScaleHeader>"
|
||||
X "$si<pl:fixDimensionsHeader>$(Esc-XmlText "$(PL-Get $pl 'fixDimensionsHeader' 'auto')")</pl:fixDimensionsHeader>"
|
||||
X "$si<pl:fixTimeScaleHeader>$(Esc-XmlText "$(PL-Get $pl 'fixTimeScaleHeader' 'auto')")</pl:fixTimeScaleHeader>"
|
||||
Emit-PlannerBorder $pl $si 'border'
|
||||
X "$si<pl:newItemsTextType>$(Esc-Xml "$(PL-Get $pl 'newItemsTextType' 'String')")</pl:newItemsTextType>"
|
||||
X "$si<pl:newItemsTextType>$(Esc-XmlText "$(PL-Get $pl 'newItemsTextType' 'String')")</pl:newItemsTextType>"
|
||||
X "$ind</Settings>"
|
||||
}
|
||||
|
||||
@@ -3710,13 +3768,13 @@ function Emit-ChartNode {
|
||||
if ($keys -contains 'gap') {
|
||||
$w = Get-Prop $val 'width'; $g = Get-Prop $val 'gap'; $st = Get-Prop $val 'style'
|
||||
X "$ind<d4p1:$name width=`"$w`" gap=`"$(PL-Bool $g)`">"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ChartLineType`">$(Esc-Xml "$st")</v8ui:style>"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ChartLineType`">$(Esc-XmlText "$st")</v8ui:style>"
|
||||
X "$ind</d4p1:$name>"; return
|
||||
}
|
||||
if (($keys -contains 'style') -and ($keys -contains 'width')) {
|
||||
$w = Get-Prop $val 'width'; $st = Get-Prop $val 'style'
|
||||
X "$ind<d4p1:$name width=`"$w`">"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-Xml "$st")</v8ui:style>"
|
||||
X "$ind`t<v8ui:style xsi:type=`"v8ui:ControlBorderType`">$(Esc-XmlText "$st")</v8ui:style>"
|
||||
X "$ind</d4p1:$name>"; return
|
||||
}
|
||||
$isFont = $false; foreach ($fk in $script:CHART_FONT_KEYS) { if ($keys -contains $fk) { $isFont = $true; break } }
|
||||
@@ -3732,7 +3790,7 @@ function Emit-ChartNode {
|
||||
}
|
||||
if ($null -eq $val -or "$val" -eq '') { X "$ind<d4p1:$name/>"; return }
|
||||
if ($val -is [bool]) { X "$ind<d4p1:$name>$(PL-Bool $val)</d4p1:$name>"; return }
|
||||
X "$ind<d4p1:$name>$(Esc-Xml "$val")</d4p1:$name>"
|
||||
X "$ind<d4p1:$name>$(Esc-XmlText "$val")</d4p1:$name>"
|
||||
}
|
||||
function Emit-ChartSettings {
|
||||
param($chart, [string]$ind, [string]$ctype = 'd4p1:Chart')
|
||||
@@ -3754,7 +3812,7 @@ function Emit-Appearance {
|
||||
if ($null -eq $val -or ($val -is [string] -and $val -eq '')) { continue }
|
||||
$spec = $script:appearanceSpec[$key]
|
||||
switch ($spec.kind) {
|
||||
'color' { X "$indent<$($spec.tag)>$(Esc-Xml "$val")</$($spec.tag)>" }
|
||||
'color' { X "$indent<$($spec.tag)>$(Esc-XmlText "$val")</$($spec.tag)>" }
|
||||
'font' { Emit-FontTag -tag $spec.tag -val $val -indent $indent }
|
||||
'border' { Emit-BorderTag -val $val -indent $indent }
|
||||
}
|
||||
@@ -4039,13 +4097,13 @@ function Emit-Input {
|
||||
@('choiceForm','ChoiceForm'), @('choiceHistoryOnInput','ChoiceHistoryOnInput'),
|
||||
@('choiceFoldersAndItems','ChoiceFoldersAndItems'), @('footerDataPath','FooterDataPath')
|
||||
)) {
|
||||
if ($el.($p[0])) { X "$inner<$($p[1])>$(Esc-Xml "$($el.($p[0]))")</$($p[1])>" }
|
||||
if ($el.($p[0])) { X "$inner<$($p[1])>$(Esc-XmlText "$($el.($p[0]))")</$($p[1])>" }
|
||||
}
|
||||
# MinValue/MaxValue — типизированное. JSON-число → xs:decimal, строка → xs:string (тип сохранён декомпилятором).
|
||||
foreach ($p in @(@('minValue','MinValue'), @('maxValue','MaxValue'))) {
|
||||
if ($null -ne $el.($p[0])) {
|
||||
$mvt = if ($el.($p[0]) -is [string]) { 'xs:string' } else { 'xs:decimal' }
|
||||
X "$inner<$($p[1]) xsi:type=`"$mvt`">$(Esc-Xml "$($el.($p[0]))")</$($p[1])>"
|
||||
X "$inner<$($p[1]) xsi:type=`"$mvt`">$(Esc-XmlText "$($el.($p[0]))")</$($p[1])>"
|
||||
}
|
||||
}
|
||||
if ($el.choiceButtonRepresentation) { X "$inner<ChoiceButtonRepresentation>$($el.choiceButtonRepresentation)</ChoiceButtonRepresentation>" }
|
||||
@@ -4108,7 +4166,7 @@ function Emit-Check {
|
||||
|
||||
if ($null -ne $el.warningOnEdit) { Emit-MLText -tag "WarningOnEdit" -text $el.warningOnEdit -indent $inner }
|
||||
# FooterDataPath / FooterText — общие cell-свойства колонки (как у input/labelField)
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-Xml "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-XmlText "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
if ($null -ne $el.footerText) { Emit-MLText -tag "FooterText" -text $el.footerText -indent $inner }
|
||||
|
||||
# Формат / формат редактирования (LocalStringType — строка или {ru,en})
|
||||
@@ -4265,7 +4323,7 @@ function Emit-ChoicePresentation {
|
||||
foreach ($pair in $pairs) {
|
||||
X "$indent`t<v8:item>"
|
||||
X "$indent`t`t<v8:lang>$($pair[0])</v8:lang>"
|
||||
X "$indent`t`t<v8:content>$(Esc-Xml $pair[1])</v8:content>"
|
||||
X "$indent`t`t<v8:content>$(Esc-XmlText $pair[1])</v8:content>"
|
||||
X "$indent`t</v8:item>"
|
||||
}
|
||||
X "$indent</Presentation>"
|
||||
@@ -4275,7 +4333,7 @@ function Emit-ChoicePresentation {
|
||||
function Get-ChoiceValueTag {
|
||||
param($norm)
|
||||
if ([string]::IsNullOrEmpty($norm.Text)) { return "<Value xsi:type=`"$($norm.XsiType)`"/>" }
|
||||
return "<Value xsi:type=`"$($norm.XsiType)`">$(Esc-Xml $norm.Text)</Value>"
|
||||
return "<Value xsi:type=`"$($norm.XsiType)`">$(Esc-XmlText $norm.Text)</Value>"
|
||||
}
|
||||
|
||||
# Emit <ChoiceList> (список выбора) — у RadioButtonField и InputField.
|
||||
@@ -4488,8 +4546,8 @@ function Emit-ChoiceParameterLinks {
|
||||
}
|
||||
}
|
||||
X "$indent`t<xr:Link>"
|
||||
X "$indent`t`t<xr:Name>$(Esc-Xml "$name")</xr:Name>"
|
||||
X "$indent`t`t<xr:DataPath xsi:type=`"xs:string`">$(Esc-Xml "$dp")</xr:DataPath>"
|
||||
X "$indent`t`t<xr:Name>$(Esc-XmlText "$name")</xr:Name>"
|
||||
X "$indent`t`t<xr:DataPath xsi:type=`"xs:string`">$(Esc-XmlText "$dp")</xr:DataPath>"
|
||||
X "$indent`t`t<xr:ValueChange>$vc</xr:ValueChange>"
|
||||
X "$indent`t</xr:Link>"
|
||||
}
|
||||
@@ -4506,7 +4564,7 @@ function Emit-TypeLink {
|
||||
$li = Get-ElProp $tl @('linkItem','элементСвязи')
|
||||
if ($null -eq $li) { $li = 0 }
|
||||
X "$indent<TypeLink>"
|
||||
X "$indent`t<xr:DataPath>$(Esc-Xml "$dp")</xr:DataPath>"
|
||||
X "$indent`t<xr:DataPath>$(Esc-XmlText "$dp")</xr:DataPath>"
|
||||
X "$indent`t<xr:LinkItem>$li</xr:LinkItem>"
|
||||
X "$indent</TypeLink>"
|
||||
}
|
||||
@@ -4613,7 +4671,7 @@ function Emit-LabelField {
|
||||
if ($el.titleLocation) { X "$inner<TitleLocation>$(Map-TitleLoc "$($el.titleLocation)")</TitleLocation>" }
|
||||
if ($el.editMode) { X "$inner<EditMode>$($el.editMode)</EditMode>" }
|
||||
# FooterDataPath — путь данных подвала колонки (общий cell-prop, как у input); после EditMode
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-Xml "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-XmlText "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
# PasswordMode на LabelField — платформа эмитит явный false (редко); факт. значение
|
||||
if ($null -ne $el.passwordMode) { X "$inner<PasswordMode>$(if ($el.passwordMode){'true'}else{'false'})</PasswordMode>" }
|
||||
Emit-ColumnPics -el $el -indent $inner
|
||||
@@ -4936,7 +4994,7 @@ function Emit-Button {
|
||||
if (($btnParam -is [System.Management.Automation.PSCustomObject] -or $btnParam -is [hashtable]) -and $btnParam.type) {
|
||||
Emit-Type -typeStr "$($btnParam.type)" -indent $inner -tag "Parameter" -tagAttrs ' xsi:type="v8:TypeDescription"'
|
||||
} else {
|
||||
X "$inner<Parameter xsi:type=`"xr:MDObjectRef`">$(Esc-Xml "$btnParam")</Parameter>"
|
||||
X "$inner<Parameter xsi:type=`"xr:MDObjectRef`">$(Esc-XmlText "$btnParam")</Parameter>"
|
||||
}
|
||||
}
|
||||
# DataPath — привязка команды кнопки к контексту (Объект.Ref, Items.X.CurrentData.Поле)
|
||||
@@ -4991,8 +5049,8 @@ function Emit-PictureDecoration {
|
||||
$srcStr = "$($el.src)"
|
||||
$lt = if ($el.loadTransparent -eq $true) { "true" } else { "false" }
|
||||
X "$inner<Picture>"
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$inner`t<xr:Abs>$(Esc-Xml $matches[1])</xr:Abs>" }
|
||||
else { X "$inner`t<xr:Ref>$(Esc-Xml $srcStr)</xr:Ref>" }
|
||||
if ($srcStr -match '^abs:(.*)$') { X "$inner`t<xr:Abs>$(Esc-XmlText $matches[1])</xr:Abs>" }
|
||||
else { X "$inner`t<xr:Ref>$(Esc-XmlText $srcStr)</xr:Ref>" }
|
||||
X "$inner`t<xr:LoadTransparent>$lt</xr:LoadTransparent>"
|
||||
if ($el.transparentPixel) { X "$inner`t<xr:TransparentPixel x=`"$($el.transparentPixel.x)`" y=`"$($el.transparentPixel.y)`"/>" }
|
||||
X "$inner</Picture>"
|
||||
@@ -5036,7 +5094,7 @@ function Emit-PictureField {
|
||||
if ($null -ne $el.enableDrag) { X "$inner<EnableDrag>$(if ($el.enableDrag){'true'}else{'false'})</EnableDrag>" }
|
||||
|
||||
# FooterDataPath / FooterText — общие cell-свойства колонки (как у input/labelField)
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-Xml "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
if ($el.footerDataPath) { X "$inner<FooterDataPath>$(Esc-XmlText "$($el.footerDataPath)")</FooterDataPath>" }
|
||||
if ($null -ne $el.footerText) { Emit-MLText -tag "FooterText" -text $el.footerText -indent $inner }
|
||||
|
||||
# ValuesPicture — picture (collection) used to render the field's value.
|
||||
@@ -5392,18 +5450,18 @@ function Emit-DLValue {
|
||||
return
|
||||
}
|
||||
$valStr = if ($val -is [bool]) { if ($val) { 'true' } else { 'false' } } else { "$val" }
|
||||
if ($type -match '^(date|dateTime|time)') { X "$indent<dcssch:value xsi:type=`"xs:dateTime`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($type -eq "boolean") { X "$indent<dcssch:value xsi:type=`"xs:boolean`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($type -eq 'v8:Type') { $nsAttr = Get-ValueTypeNsAttr -valueType 'v8:Type' -value $valStr; X "$indent<dcssch:value$nsAttr xsi:type=`"v8:Type`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^ent:') { X "$indent<dcssch:value xsi:type=`"$type`">$(Esc-Xml $valStr)</dcssch:value>" } # системное перечисление (ent:X) — value несёт тот же xsi:type
|
||||
elseif ($type -match '^decimal') { X "$indent<dcssch:value xsi:type=`"xs:decimal`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^string') { X "$indent<dcssch:value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') { X "$indent<dcssch:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
if ($type -match '^(date|dateTime|time)') { X "$indent<dcssch:value xsi:type=`"xs:dateTime`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($type -eq "boolean") { X "$indent<dcssch:value xsi:type=`"xs:boolean`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($type -eq 'v8:Type') { $nsAttr = Get-ValueTypeNsAttr -valueType 'v8:Type' -value $valStr; X "$indent<dcssch:value$nsAttr xsi:type=`"v8:Type`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^ent:') { X "$indent<dcssch:value xsi:type=`"$type`">$(Esc-XmlText $valStr)</dcssch:value>" } # системное перечисление (ent:X) — value несёт тот же xsi:type
|
||||
elseif ($type -match '^decimal') { X "$indent<dcssch:value xsi:type=`"xs:decimal`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^string') { X "$indent<dcssch:value xsi:type=`"xs:string`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') { X "$indent<dcssch:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
else {
|
||||
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') { X "$indent<dcssch:value xsi:type=`"xs:dateTime`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($valStr -eq "true" -or $valStr -eq "false") { X "$indent<dcssch:value xsi:type=`"xs:boolean`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
elseif ($valStr -match '^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or $valStr -match '^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { X "$indent<dcssch:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
else { X "$indent<dcssch:value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</dcssch:value>" }
|
||||
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') { X "$indent<dcssch:value xsi:type=`"xs:dateTime`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($valStr -eq "true" -or $valStr -eq "false") { X "$indent<dcssch:value xsi:type=`"xs:boolean`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
elseif ($valStr -match '^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or $valStr -match '^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { X "$indent<dcssch:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
else { X "$indent<dcssch:value xsi:type=`"xs:string`">$(Esc-XmlText $valStr)</dcssch:value>" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5437,7 +5495,7 @@ function Emit-DLInputParameters {
|
||||
foreach ($item in $items) {
|
||||
X "$indent`t<dcscor:item>"
|
||||
if ((Has-DLProp $item 'use') -and $null -ne $item.use -and -not $item.use) { X "$indent`t`t<dcscor:use>false</dcscor:use>" }
|
||||
X "$indent`t`t<dcscor:parameter>$(Esc-Xml "$($item.parameter)")</dcscor:parameter>"
|
||||
X "$indent`t`t<dcscor:parameter>$(Esc-XmlText "$($item.parameter)")</dcscor:parameter>"
|
||||
if (Has-DLProp $item 'choiceParameters') {
|
||||
$cpItems = if ($null -ne $item.choiceParameters) { @($item.choiceParameters) } else { @() }
|
||||
if ($cpItems.Count -eq 0) { X "$indent`t`t<dcscor:value xsi:type=`"dcscor:ChoiceParameters`"/>" }
|
||||
@@ -5445,11 +5503,11 @@ function Emit-DLInputParameters {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"dcscor:ChoiceParameters`">"
|
||||
foreach ($cpItem in $cpItems) {
|
||||
X "$indent`t`t`t<dcscor:item>"
|
||||
X "$indent`t`t`t`t<dcscor:choiceParameter>$(Esc-Xml "$($cpItem.name)")</dcscor:choiceParameter>"
|
||||
X "$indent`t`t`t`t<dcscor:choiceParameter>$(Esc-XmlText "$($cpItem.name)")</dcscor:choiceParameter>"
|
||||
foreach ($v in @($cpItem.values)) {
|
||||
if ($v -is [bool]) { X "$indent`t`t`t`t<dcscor:value xsi:type=`"xs:boolean`">$(if ($v) { 'true' } else { 'false' })</dcscor:value>" }
|
||||
elseif ($v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [decimal]) { X "$indent`t`t`t`t<dcscor:value xsi:type=`"xs:decimal`">$v</dcscor:value>" }
|
||||
else { X "$indent`t`t`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml "$v")</dcscor:value>" }
|
||||
else { X "$indent`t`t`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-XmlText "$v")</dcscor:value>" }
|
||||
}
|
||||
X "$indent`t`t`t</dcscor:item>"
|
||||
}
|
||||
@@ -5462,8 +5520,8 @@ function Emit-DLInputParameters {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"dcscor:ChoiceParameterLinks`">"
|
||||
foreach ($cplItem in $cplItems) {
|
||||
X "$indent`t`t`t<dcscor:item>"
|
||||
X "$indent`t`t`t`t<dcscor:choiceParameter>$(Esc-Xml "$($cplItem.name)")</dcscor:choiceParameter>"
|
||||
X "$indent`t`t`t`t<dcscor:value>$(Esc-Xml "$($cplItem.value)")</dcscor:value>"
|
||||
X "$indent`t`t`t`t<dcscor:choiceParameter>$(Esc-XmlText "$($cplItem.name)")</dcscor:choiceParameter>"
|
||||
X "$indent`t`t`t`t<dcscor:value>$(Esc-XmlText "$($cplItem.value)")</dcscor:value>"
|
||||
$mode = if ($cplItem.mode) { "$($cplItem.mode)" } else { 'Auto' }
|
||||
X "$indent`t`t`t`t<dcscor:mode xmlns:d8p1=`"http://v8.1c.ru/8.1/data/enterprise`" xsi:type=`"d8p1:LinkedValueChangeMode`">$mode</dcscor:mode>"
|
||||
X "$indent`t`t`t</dcscor:item>"
|
||||
@@ -5474,15 +5532,15 @@ function Emit-DLInputParameters {
|
||||
# Связь по типу (dcscor:TypeLink) — field + linkItem (структурное значение параметра).
|
||||
$tl = $item.typeLink
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"dcscor:TypeLink`">"
|
||||
$tlf = Get-Prop $tl 'field'; if ($null -ne $tlf) { X "$indent`t`t`t<dcscor:field>$(Esc-Xml "$tlf")</dcscor:field>" }
|
||||
$tli = Get-Prop $tl 'linkItem'; if ($null -ne $tli) { X "$indent`t`t`t<dcscor:linkItem>$(Esc-Xml "$tli")</dcscor:linkItem>" }
|
||||
$tlf = Get-Prop $tl 'field'; if ($null -ne $tlf) { X "$indent`t`t`t<dcscor:field>$(Esc-XmlText "$tlf")</dcscor:field>" }
|
||||
$tli = Get-Prop $tl 'linkItem'; if ($null -ne $tli) { X "$indent`t`t`t<dcscor:linkItem>$(Esc-XmlText "$tli")</dcscor:linkItem>" }
|
||||
X "$indent`t`t</dcscor:value>"
|
||||
} elseif (Has-DLProp $item 'value') {
|
||||
$val = $item.value
|
||||
if ($val -is [bool]) { X "$indent`t`t<dcscor:value xsi:type=`"xs:boolean`">$(if ($val) { 'true' } else { 'false' })</dcscor:value>" }
|
||||
elseif ($val -is [int] -or $val -is [long] -or $val -is [double] -or $val -is [decimal]) { X "$indent`t`t<dcscor:value xsi:type=`"xs:decimal`">$val</dcscor:value>" }
|
||||
elseif ($val -is [hashtable] -or $val -is [System.Collections.IDictionary] -or $val -is [PSCustomObject]) { Emit-DLMLText -tag "dcscor:value" -text $val -indent "$indent`t`t" }
|
||||
else { X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml "$val")</dcscor:value>" }
|
||||
else { X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText "$val")</dcscor:value>" }
|
||||
}
|
||||
X "$indent`t</dcscor:item>"
|
||||
}
|
||||
@@ -5557,16 +5615,16 @@ function Emit-DataParameters {
|
||||
}
|
||||
X "$indent`t<dcscor:item xsi:type=`"dcsset:SettingsParameterValue`">"
|
||||
if ($dp.use -eq $false) { X "$indent`t`t<dcscor:use>false</dcscor:use>" }
|
||||
X "$indent`t`t<dcscor:parameter>$(Esc-Xml "$($dp.parameter)")</dcscor:parameter>"
|
||||
X "$indent`t`t<dcscor:parameter>$(Esc-XmlText "$($dp.parameter)")</dcscor:parameter>"
|
||||
$dpValIsArr = ($dp.value -is [array]) -or ($dp.value -is [System.Collections.IList] -and $dp.value -isnot [string])
|
||||
if ($dpValIsArr) {
|
||||
# Список значений параметра (valueListAllowed) — отдельный <dcscor:value> на каждое.
|
||||
$avtype = "$($dp.valueType)"
|
||||
foreach ($v in @($dp.value)) {
|
||||
$vStr = if ($v -is [bool]) { "$v".ToLower() } else { "$v" }
|
||||
if ($avtype -match '^[a-zA-Z]+:') { X "$indent`t`t<dcscor:value xsi:type=`"$avtype`">$(Esc-Xml $vStr)</dcscor:value>" }
|
||||
elseif ("$vStr" -match '^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or "$vStr" -match '^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { X "$indent`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml $vStr)</dcscor:value>" }
|
||||
else { X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml $vStr)</dcscor:value>" }
|
||||
if ($avtype -match '^[a-zA-Z]+:') { X "$indent`t`t<dcscor:value xsi:type=`"$avtype`">$(Esc-XmlText $vStr)</dcscor:value>" }
|
||||
elseif ("$vStr" -match '^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or "$vStr" -match '^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { X "$indent`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-XmlText $vStr)</dcscor:value>" }
|
||||
else { X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText $vStr)</dcscor:value>" }
|
||||
}
|
||||
} elseif ($dp.nilValue -eq $true) {
|
||||
X "$indent`t`t<dcscor:value xsi:nil=`"true`"/>"
|
||||
@@ -5589,41 +5647,41 @@ function Emit-DataParameters {
|
||||
if ($dp.value -is [PSCustomObject] -and $dp.value.PSObject.Properties['date']) { $_d = "$($dp.value.date)" }
|
||||
elseif (($dp.value -is [System.Collections.IDictionary]) -and $dp.value.Contains('date')) { $_d = "$($dp.value['date'])" }
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"v8:StandardBeginningDate`">"
|
||||
X "$indent`t`t`t<v8:variant xsi:type=`"v8:StandardBeginningDateVariant`">$(Esc-Xml $_variantStr)</v8:variant>"
|
||||
if ($_variantStr -eq 'Custom') { if (-not $_d) { $_d = '0001-01-01T00:00:00' }; X "$indent`t`t`t<v8:date>$(Esc-Xml $_d)</v8:date>" }
|
||||
X "$indent`t`t`t<v8:variant xsi:type=`"v8:StandardBeginningDateVariant`">$(Esc-XmlText $_variantStr)</v8:variant>"
|
||||
if ($_variantStr -eq 'Custom') { if (-not $_d) { $_d = '0001-01-01T00:00:00' }; X "$indent`t`t`t<v8:date>$(Esc-XmlText $_d)</v8:date>" }
|
||||
X "$indent`t`t</dcscor:value>"
|
||||
} else {
|
||||
$_sd = $null; $_ed = $null
|
||||
if ($dp.value -is [PSCustomObject]) { if ($dp.value.PSObject.Properties['startDate']) { $_sd = "$($dp.value.startDate)" }; if ($dp.value.PSObject.Properties['endDate']) { $_ed = "$($dp.value.endDate)" } }
|
||||
else { if ($dp.value.Contains('startDate')) { $_sd = "$($dp.value['startDate'])" }; if ($dp.value.Contains('endDate')) { $_ed = "$($dp.value['endDate'])" } }
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"v8:StandardPeriod`">"
|
||||
X "$indent`t`t`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-Xml $_variantStr)</v8:variant>"
|
||||
if ($_variantStr -eq 'Custom') { if (-not $_sd) { $_sd = '0001-01-01T00:00:00' }; if (-not $_ed) { $_ed = '0001-01-01T00:00:00' }; X "$indent`t`t`t<v8:startDate>$(Esc-Xml $_sd)</v8:startDate>"; X "$indent`t`t`t<v8:endDate>$(Esc-Xml $_ed)</v8:endDate>" }
|
||||
X "$indent`t`t`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-XmlText $_variantStr)</v8:variant>"
|
||||
if ($_variantStr -eq 'Custom') { if (-not $_sd) { $_sd = '0001-01-01T00:00:00' }; if (-not $_ed) { $_ed = '0001-01-01T00:00:00' }; X "$indent`t`t`t<v8:startDate>$(Esc-XmlText $_sd)</v8:startDate>"; X "$indent`t`t`t<v8:endDate>$(Esc-XmlText $_ed)</v8:endDate>" }
|
||||
X "$indent`t`t</dcscor:value>"
|
||||
}
|
||||
} elseif ($vtype -match '^[a-zA-Z]+:') {
|
||||
$vStr = if ($dp.value -is [bool]) { "$($dp.value)".ToLower() } else { "$($dp.value)" }
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"$vtype`">$(Esc-Xml $vStr)</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"$vtype`">$(Esc-XmlText $vStr)</dcscor:value>"
|
||||
} elseif ($vtype -eq 'boolean' -or $dp.value -is [bool]) {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:boolean`">$(Esc-Xml ("$($dp.value)".ToLower()))</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:boolean`">$(Esc-XmlText ("$($dp.value)".ToLower()))</dcscor:value>"
|
||||
} elseif ($vtype -match '^date' -or "$($dp.value)" -match '^\d{4}-\d{2}-\d{2}T') {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:dateTime`">$(Esc-Xml "$($dp.value)")</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:dateTime`">$(Esc-XmlText "$($dp.value)")</dcscor:value>"
|
||||
} elseif ($vtype -match '^decimal') {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:decimal`">$(Esc-Xml "$($dp.value)")</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:decimal`">$(Esc-XmlText "$($dp.value)")</dcscor:value>"
|
||||
} elseif ($vtype -match '^string') {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml "$($dp.value)")</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText "$($dp.value)")</dcscor:value>"
|
||||
} elseif ("$($dp.value)" -match '^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or "$($dp.value)" -match '^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml "$($dp.value)")</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-XmlText "$($dp.value)")</dcscor:value>"
|
||||
} else {
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-Xml "$($dp.value)")</dcscor:value>"
|
||||
X "$indent`t`t<dcscor:value xsi:type=`"xs:string`">$(Esc-XmlText "$($dp.value)")</dcscor:value>"
|
||||
}
|
||||
}
|
||||
if ($dp.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-Xml "$($dp.viewMode)")</dcsset:viewMode>" }
|
||||
if ($dp.userSettingID) { $uid = if ("$($dp.userSettingID)" -eq "auto") { New-Guid-String } else { "$($dp.userSettingID)" }; X "$indent`t`t<dcsset:userSettingID>$(Esc-Xml $uid)</dcsset:userSettingID>" }
|
||||
if ($dp.viewMode) { X "$indent`t`t<dcsset:viewMode>$(Esc-XmlText "$($dp.viewMode)")</dcsset:viewMode>" }
|
||||
if ($dp.userSettingID) { $uid = if ("$($dp.userSettingID)" -eq "auto") { New-Guid-String } else { "$($dp.userSettingID)" }; X "$indent`t`t<dcsset:userSettingID>$(Esc-XmlText $uid)</dcsset:userSettingID>" }
|
||||
if ($dp.userSettingPresentation) { Emit-USPresentation -val $dp.userSettingPresentation -tag "dcsset:userSettingPresentation" -indent "$indent`t`t" }
|
||||
X "$indent`t</dcscor:item>"
|
||||
}
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-Xml "$blockViewMode")</dcsset:viewMode>" }
|
||||
if ($null -ne $blockViewMode) { X "$indent`t<dcsset:viewMode>$(Esc-XmlText "$blockViewMode")</dcsset:viewMode>" }
|
||||
X "$indent</dcsset:dataParameters>"
|
||||
}
|
||||
|
||||
@@ -5631,7 +5689,7 @@ function Emit-DLParameter {
|
||||
param($p, $parsed, [string]$indent)
|
||||
X "$indent<Parameter>"
|
||||
$ci = "$indent`t"
|
||||
X "$ci<dcssch:name>$(Esc-Xml $parsed.name)</dcssch:name>"
|
||||
X "$ci<dcssch:name>$(Esc-XmlText $parsed.name)</dcssch:name>"
|
||||
# Title: явный override (shorthand [..] / объект title/presentation) или авто из имени.
|
||||
$title = $null
|
||||
if ($parsed.title) { $title = $parsed.title }
|
||||
@@ -5665,7 +5723,7 @@ function Emit-DLParameter {
|
||||
# expression
|
||||
$expr = $null
|
||||
if ($p -isnot [string] -and (Has-DLProp $p 'expression') -and $p.expression) { $expr = "$($p.expression)" }
|
||||
if ($expr) { X "$ci<dcssch:expression>$(Esc-Xml $expr)</dcssch:expression>" }
|
||||
if ($expr) { X "$ci<dcssch:expression>$(Esc-XmlText $expr)</dcssch:expression>" }
|
||||
# availableValues
|
||||
if ($p -isnot [string] -and (Has-DLProp $p 'availableValues') -and $p.availableValues) {
|
||||
foreach ($av in @($p.availableValues)) { Emit-DLAvailableValue -av $av -type $parsed.type -indent $ci }
|
||||
@@ -5684,7 +5742,7 @@ function Emit-DLParameter {
|
||||
# use
|
||||
$useVal = $null
|
||||
if ($p -isnot [string] -and (Has-DLProp $p 'use') -and $p.use) { $useVal = "$($p.use)" }
|
||||
if ($useVal) { X "$ci<dcssch:use>$(Esc-Xml $useVal)</dcssch:use>" }
|
||||
if ($useVal) { X "$ci<dcssch:use>$(Esc-XmlText $useVal)</dcssch:use>" }
|
||||
X "$indent</Parameter>"
|
||||
}
|
||||
|
||||
@@ -5804,7 +5862,7 @@ function Emit-Attributes {
|
||||
}
|
||||
if ($saveFields.Count -gt 0) {
|
||||
X "$inner<Save>"
|
||||
foreach ($f in $saveFields) { X "$inner`t<Field>$(Esc-Xml $f)</Field>" }
|
||||
foreach ($f in $saveFields) { X "$inner`t<Field>$(Esc-XmlText $f)</Field>" }
|
||||
X "$inner</Save>"
|
||||
}
|
||||
}
|
||||
@@ -5868,9 +5926,17 @@ function Emit-Attributes {
|
||||
}
|
||||
if ($hasAddCols) {
|
||||
foreach ($ac in @($attr.additionalColumns)) {
|
||||
# Пустой список колонок задаётся ЯВНО (`"columns": []`) — это законная форма,
|
||||
# платформа так пишет таблицу, у которой доп. колонок нет. А вот отсутствие ключа
|
||||
# — недосказанность автора: «доп. колонки есть», а какие, не указано. Раньше на
|
||||
# этом PS падал с «Не удается индексировать в массив NULL» (@($null).Count = 1).
|
||||
if ($null -eq $ac.PSObject.Properties['columns'] -or $null -eq $ac.columns) {
|
||||
Write-Error "additionalColumns group for table '$($ac.table)': key 'columns' is missing — list the columns, or pass an empty array for a table without extra columns"
|
||||
exit 1
|
||||
}
|
||||
$acCols = @($ac.columns)
|
||||
if ($acCols.Count -eq 0) {
|
||||
# Пустая группа доп.колонок (table-ref без колонок) → self-closing (как платформа)
|
||||
# Явно пустая группа → self-closing (как платформа)
|
||||
X "$inner`t<AdditionalColumns table=`"$($ac.table)`"/>"
|
||||
continue
|
||||
}
|
||||
@@ -5905,7 +5971,7 @@ function Emit-Attributes {
|
||||
X "$si<DynamicDataRead>$ddr</DynamicDataRead>"
|
||||
if ($hasQuery) {
|
||||
$qtext = Resolve-QueryValue "$($st.query)" $script:queryBaseDir
|
||||
X "$si<QueryText>$(Esc-Xml $qtext)</QueryText>"
|
||||
X "$si<QueryText>$(Esc-XmlText $qtext)</QueryText>"
|
||||
}
|
||||
# Явные поля набора (редко): override title/dataPath
|
||||
if ($st.fields) {
|
||||
@@ -5920,8 +5986,8 @@ function Emit-Attributes {
|
||||
if ($null -ne (Get-Prop $fld 'dataPath')) { $dp = "$($fld.dataPath)" }
|
||||
elseif ($isFolder) { $dp = "" }
|
||||
else { $dp = "$($fld.field)" }
|
||||
if ($dp -eq "") { X "$si`t<dcssch:dataPath/>" } else { X "$si`t<dcssch:dataPath>$(Esc-Xml "$dp")</dcssch:dataPath>" }
|
||||
if (-not $isFolder) { X "$si`t<dcssch:field>$(Esc-Xml "$($fld.field)")</dcssch:field>" }
|
||||
if ($dp -eq "") { X "$si`t<dcssch:dataPath/>" } else { X "$si`t<dcssch:dataPath>$(Esc-XmlText "$dp")</dcssch:dataPath>" }
|
||||
if (-not $isFolder) { X "$si`t<dcssch:field>$(Esc-XmlText "$($fld.field)")</dcssch:field>" }
|
||||
if ($fld.title) {
|
||||
X "$si`t<dcssch:title xsi:type=`"v8:LocalStringType`">"
|
||||
Emit-MLItems -val $fld.title -indent "$si`t`t"
|
||||
@@ -5931,7 +5997,7 @@ function Emit-Attributes {
|
||||
Emit-RestrictBlock 'useRestriction' $fld.useRestriction "$si`t"
|
||||
Emit-RestrictBlock 'attributeUseRestriction' $fld.attributeUseRestriction "$si`t"
|
||||
# presentationExpression поля — перед valueType (порядок исходника)
|
||||
if ($fld.presentationExpression) { X "$si`t<dcssch:presentationExpression>$(Esc-Xml "$($fld.presentationExpression)")</dcssch:presentationExpression>" }
|
||||
if ($fld.presentationExpression) { X "$si`t<dcssch:presentationExpression>$(Esc-XmlText "$($fld.presentationExpression)")</dcssch:presentationExpression>" }
|
||||
# valueType поля набора (тип значения; вычисляемые/кастомные поля)
|
||||
if ($fld.valueType) { Emit-DLValueType -typeStr "$($fld.valueType)" -indent "$si`t" }
|
||||
# appearance поля (формат/оформление) — после valueType (порядок исходника)
|
||||
@@ -5951,8 +6017,8 @@ function Emit-Attributes {
|
||||
Emit-DLParameters -params $st.parameters -indent $si
|
||||
# Ключ набора (query-based список без MainTable): KeyType (RowNumber/FieldValue/RowKey)
|
||||
# + KeyField* — после Parameter*, до MainTable. Захват/эмит факт. значений.
|
||||
if ($st.keyType) { X "$si<KeyType>$(Esc-Xml "$($st.keyType)")</KeyType>" }
|
||||
if ($st.keyFields) { foreach ($kf in @($st.keyFields)) { X "$si<KeyField>$(Esc-Xml "$kf")</KeyField>" } }
|
||||
if ($st.keyType) { X "$si<KeyType>$(Esc-XmlText "$($st.keyType)")</KeyType>" }
|
||||
if ($st.keyFields) { foreach ($kf in @($st.keyFields)) { X "$si<KeyField>$(Esc-XmlText "$kf")</KeyField>" } }
|
||||
if ($st.mainTable) { X "$si<MainTable>$(Normalize-MetaTypeRef "$($st.mainTable)")</MainTable>" }
|
||||
# GetInvisibleFieldPresentations — после MainTable (дефолт true; эмитим только при заданном ключе = отклонении false).
|
||||
if ($null -ne $st.getInvisibleFieldPresentations) { X "$si<GetInvisibleFieldPresentations>$(if ($st.getInvisibleFieldPresentations){'true'}else{'false'})</GetInvisibleFieldPresentations>" }
|
||||
@@ -6089,7 +6155,7 @@ function Emit-Commands {
|
||||
if (-not $cmdTable) { $cmdTable = $cmd.associatedTableElementId }
|
||||
if (-not $cmdTable) { $cmdTable = $cmd.используемаяТаблица }
|
||||
if ($cmdTable) {
|
||||
X "$inner<AssociatedTableElementId xsi:type=`"xs:string`">$(Esc-Xml "$cmdTable")</AssociatedTableElementId>"
|
||||
X "$inner<AssociatedTableElementId xsi:type=`"xs:string`">$(Esc-XmlText "$cmdTable")</AssociatedTableElementId>"
|
||||
}
|
||||
|
||||
if ($cmd.shortcut) {
|
||||
@@ -6181,10 +6247,10 @@ function Emit-CommandInterface {
|
||||
# group из дерева побеждает (если задан и непустой); явный group элемента — фолбэк
|
||||
if ($treeGroup) { $grp = $treeGroup }
|
||||
X "$inner`t<Item>"
|
||||
X "$inner`t`t<Command>$(Esc-Xml "$cmd")</Command>"
|
||||
X "$inner`t`t<Command>$(Esc-XmlText "$cmd")</Command>"
|
||||
X "$inner`t`t<Type>$type</Type>"
|
||||
if ($attr) { X "$inner`t`t<Attribute>$(Esc-Xml "$attr")</Attribute>" }
|
||||
if ($grp) { X "$inner`t`t<CommandGroup>$(Esc-Xml "$grp")</CommandGroup>" }
|
||||
if ($attr) { X "$inner`t`t<Attribute>$(Esc-XmlText "$attr")</Attribute>" }
|
||||
if ($grp) { X "$inner`t`t<CommandGroup>$(Esc-XmlText "$grp")</CommandGroup>" }
|
||||
if ($null -ne $idx) { X "$inner`t`t<Index>$idx</Index>" }
|
||||
if ($null -ne $dv) { X "$inner`t`t<DefaultVisible>$(if ($dv){'true'}else{'false'})</DefaultVisible>" }
|
||||
if ($null -ne $vis) { Emit-XrFlag -tag 'Visible' -val $vis -indent "$inner`t`t" }
|
||||
@@ -6460,25 +6526,14 @@ function Compute-MainAcbAutofill {
|
||||
|
||||
# --- 12. Main compilation ---
|
||||
|
||||
# Title
|
||||
if ($def.title) {
|
||||
Emit-MLText -tag "Title" -text $def.title -indent "`t"
|
||||
}
|
||||
|
||||
# Header
|
||||
X '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
X "<Form 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:dcssch=`"http://v8.1c.ru/8.1/data-composition-system/schema`" 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`" version=`"$($script:formatVersion)`">"
|
||||
|
||||
# Oops — Title was emitted before header. Need to fix the order.
|
||||
# Actually, let me restructure: build the body into a separate buffer, then assemble
|
||||
|
||||
# Reset and rebuild properly
|
||||
# Буфер и счётчики — с чистого листа: до этой точки они могли быть тронуты режимом from-object.
|
||||
$script:xml = New-Object System.Text.StringBuilder 8192
|
||||
$script:nextId = 1
|
||||
$script:seenElementNames = @{} # пул имён элементов (глобально по всей форме)
|
||||
|
||||
# Header
|
||||
X '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
X "<Form 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:dcssch=`"http://v8.1c.ru/8.1/data-composition-system/schema`" 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`" version=`"$($script:formatVersion)`">"
|
||||
X "<Form $($script:formNsDecl) version=`"$($script:formatVersion)`">"
|
||||
|
||||
# 12a. Title (from def.title or properties.title — must be multilingual XML)
|
||||
$formTitle = $def.title
|
||||
@@ -6532,7 +6587,7 @@ if ($null -ne $def.mobileCommandBarContent -and @($def.mobileCommandBarContent).
|
||||
X "`t`t`t<xr:CheckState>0</xr:CheckState>"
|
||||
# пустое значение → самозакрывающийся тег (зеркало платформы)
|
||||
if ([string]::IsNullOrEmpty("$nm")) { X "`t`t`t<xr:Value xsi:type=`"xs:string`"/>" }
|
||||
else { X "`t`t`t<xr:Value xsi:type=`"xs:string`">$(Esc-Xml "$nm")</xr:Value>" }
|
||||
else { X "`t`t`t<xr:Value xsi:type=`"xs:string`">$(Esc-XmlText "$nm")</xr:Value>" }
|
||||
X "`t`t</xr:Item>"
|
||||
}
|
||||
X "`t</MobileDeviceCommandBarContent>"
|
||||
@@ -6619,7 +6674,7 @@ if (-not (Test-Path $outDir)) {
|
||||
}
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($outPath, $xml.ToString(), $enc)
|
||||
[System.IO.File]::WriteAllText($outPath, $xml.ToString().TrimEnd("`r", "`n"), $enc)
|
||||
|
||||
# --- 13b. Auto-register form in parent object XML ---
|
||||
|
||||
@@ -6674,11 +6729,26 @@ if ($formsLeaf -eq 'Forms') {
|
||||
$regSettings = New-Object System.Xml.XmlWriterSettings
|
||||
$regSettings.Encoding = $regEnc
|
||||
$regSettings.Indent = $false
|
||||
$regStream = New-Object System.IO.FileStream($objectXmlPath, [System.IO.FileMode]::Create)
|
||||
$regWriter = [System.Xml.XmlWriter]::Create($regStream, $regSettings)
|
||||
$regSettings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$regMem = New-Object System.IO.MemoryStream
|
||||
$regWriter = [System.Xml.XmlWriter]::Create($regMem, $regSettings)
|
||||
$objDoc.Save($regWriter)
|
||||
$regWriter.Close()
|
||||
$regStream.Close()
|
||||
$regWriter.Flush(); $regWriter.Close()
|
||||
|
||||
$regText = [System.Text.Encoding]::UTF8.GetString($regMem.ToArray())
|
||||
$regMem.Close()
|
||||
if ($regText.Length -gt 0 -and $regText[0] -eq [char]0xFEFF) { $regText = $regText.Substring(1) }
|
||||
$regText = $regText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$regText = [regex]::Replace($regText, '(?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 $objectXmlPath) -and ([System.IO.File]::ReadAllText($objectXmlPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$regText = ($regText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objectXmlPath, $regText, $regEnc)
|
||||
|
||||
Write-Host " Registered: <Form>$formName</Form> in $objectName.xml"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# form-decompile v0.149 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||
param(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# form-decompile v0.149 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||
#
|
||||
@@ -13,6 +13,28 @@ import xml.etree.ElementTree as ET
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- 1. Namespaces ---
|
||||
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
||||
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
||||
@@ -81,29 +103,29 @@ def _attr(node, name, ns_uri=None):
|
||||
def convert_string_to_json_literal(s):
|
||||
if s is None:
|
||||
return 'null'
|
||||
sb = ['"']
|
||||
out = ['"']
|
||||
for ch in s:
|
||||
code = ord(ch)
|
||||
if code == 0x22:
|
||||
sb.append('\\"')
|
||||
out.append('\\"')
|
||||
elif code == 0x5C:
|
||||
sb.append('\\\\')
|
||||
out.append('\\\\')
|
||||
elif code == 0x08:
|
||||
sb.append('\\b')
|
||||
out.append('\\b')
|
||||
elif code == 0x09:
|
||||
sb.append('\\t')
|
||||
out.append('\\t')
|
||||
elif code == 0x0A:
|
||||
sb.append('\\n')
|
||||
out.append('\\n')
|
||||
elif code == 0x0C:
|
||||
sb.append('\\f')
|
||||
out.append('\\f')
|
||||
elif code == 0x0D:
|
||||
sb.append('\\r')
|
||||
out.append('\\r')
|
||||
elif code < 0x20:
|
||||
sb.append('\\u%04x' % code)
|
||||
out.append('\\u%04x' % code)
|
||||
else:
|
||||
sb.append(ch)
|
||||
sb.append('"')
|
||||
return ''.join(sb)
|
||||
out.append(ch)
|
||||
out.append('"')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def _num_to_str(obj):
|
||||
@@ -3115,7 +3137,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
||||
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
||||
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
output_path = args.OutputPath
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-edit v1.5 — Edit 1C managed form elements
|
||||
# form-edit v1.14 — Edit 1C managed form elements (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -271,15 +271,24 @@ function X {
|
||||
|
||||
function Esc-Xml {
|
||||
param([string]$s)
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
|
||||
function Esc-XmlText {
|
||||
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
||||
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
||||
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
||||
param([string]$s)
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
function Emit-MLText {
|
||||
param([string]$tag, [string]$text, [string]$indent)
|
||||
X "$indent<$tag>"
|
||||
X "$indent`t<v8:item>"
|
||||
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
||||
X "$indent`t`t<v8:content>$(Esc-Xml $text)</v8:content>"
|
||||
X "$indent`t`t<v8:content>$(Esc-XmlText $text)</v8:content>"
|
||||
X "$indent`t</v8:item>"
|
||||
X "$indent</$tag>"
|
||||
}
|
||||
@@ -308,24 +317,49 @@ $script:formTypeSynonyms["бизнеспроцессссылка"] = "
|
||||
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
||||
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
||||
|
||||
# Алиас на локальный словарь: тело Resolve-TypeStr ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
$script:typeSynonyms = $script:formTypeSynonyms
|
||||
|
||||
function Resolve-TypeStr {
|
||||
param([string]$typeStr)
|
||||
if (-not $typeStr) { return $typeStr }
|
||||
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if ($typeStr.StartsWith('cfg:')) {
|
||||
$typeStr = $typeStr.Substring(4)
|
||||
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||
}
|
||||
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||
$base = $Matches[1].Trim(); $params = $Matches[2]
|
||||
$r = $script:formTypeSynonyms[$base.ToLower()]
|
||||
if ($r) { return "$r($params)" }
|
||||
$baseName = $Matches[1].Trim()
|
||||
$params = $Matches[2]
|
||||
$resolved = $script:typeSynonyms[$baseName.ToLower()]
|
||||
if ($resolved) { return "$resolved($params)" }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
if ($typeStr.Contains('.')) {
|
||||
$i = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $i); $suffix = $typeStr.Substring($i)
|
||||
$r = $script:formTypeSynonyms[$prefix.ToLower()]
|
||||
if ($r) { return "$r$suffix" }
|
||||
$dotIdx = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||
if ($resolved) { return "$resolved$suffix" }
|
||||
return $typeStr
|
||||
}
|
||||
$r = $script:formTypeSynonyms[$typeStr.ToLower()]
|
||||
if ($r) { return $r }
|
||||
|
||||
# Простое имя
|
||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||
if ($resolved) { return $resolved }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
@@ -589,7 +623,7 @@ function Emit-Label {
|
||||
X "$inner<Title formatted=`"$formatted`">"
|
||||
X "$inner`t<v8:item>"
|
||||
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
||||
X "$inner`t`t<v8:content>$(Esc-Xml "$($el.title)")</v8:content>"
|
||||
X "$inner`t`t<v8:content>$(Esc-XmlText "$($el.title)")</v8:content>"
|
||||
X "$inner`t</v8:item>"
|
||||
X "$inner</Title>"
|
||||
}
|
||||
@@ -1384,8 +1418,16 @@ if ($def.elementEvents -and $def.elementEvents.Count -gt 0) {
|
||||
$content = $xmlDoc.OuterXml
|
||||
# Ensure encoding declaration is uppercase UTF-8
|
||||
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$content = [regex]::Replace($content, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedFormPath) -and ([System.IO.File]::ReadAllText($resolvedFormPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$content = ($content -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
||||
|
||||
# === 14. Summary ===
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-edit v1.5 — Edit 1C managed form elements (Python port)
|
||||
# form-edit v1.14 — Edit 1C managed form elements (Python port) (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
@@ -11,6 +11,65 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с 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
|
||||
@@ -192,7 +251,7 @@ def assert_edit_allowed(target_path, require):
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-FormPath", "-Path", required=True)
|
||||
parser.add_argument("-JsonPath", required=True)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
json_path = args.JsonPath
|
||||
@@ -226,9 +285,16 @@ def local_name(node):
|
||||
# ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
def esc_xml(s):
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||||
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
# ── 1. Load Form.xml ────────────────────────────────────────
|
||||
|
||||
if not os.path.exists(form_path):
|
||||
@@ -252,7 +318,7 @@ root = tree.getroot()
|
||||
# ── 2. Load JSON ────────────────────────────────────────────
|
||||
|
||||
with open(json_path, "r", encoding="utf-8-sig") as f:
|
||||
defn = json.load(f)
|
||||
defn = ci_json(json.load(f))
|
||||
|
||||
# ── 3. Form name + header ───────────────────────────────────
|
||||
|
||||
@@ -384,23 +450,48 @@ _FORM_TYPE_SYNONYMS = {
|
||||
}
|
||||
|
||||
|
||||
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
TYPE_SYNONYMS = _FORM_TYPE_SYNONYMS
|
||||
|
||||
|
||||
def resolve_type_str(type_str):
|
||||
if not type_str:
|
||||
return type_str
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if type_str.startswith('cfg:'):
|
||||
type_str = type_str[4:]
|
||||
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||
type_str = type_str[type_str.index(':') + 1:]
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||
if m:
|
||||
base, params = m.group(1).strip(), m.group(2)
|
||||
r = _FORM_TYPE_SYNONYMS.get(base.lower())
|
||||
return f"{r}({params})" if r else type_str
|
||||
base_name = m.group(1).strip()
|
||||
params = m.group(2)
|
||||
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||
if resolved:
|
||||
return f'{resolved}({params})'
|
||||
return type_str
|
||||
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||
if '.' in type_str:
|
||||
i = type_str.index('.')
|
||||
prefix, suffix = type_str[:i], type_str[i:]
|
||||
r = _FORM_TYPE_SYNONYMS.get(prefix.lower())
|
||||
return f"{r}{suffix}" if r else type_str
|
||||
r = _FORM_TYPE_SYNONYMS.get(type_str.lower())
|
||||
return r if r else type_str
|
||||
|
||||
|
||||
dot_idx = type_str.index('.')
|
||||
prefix = type_str[:dot_idx]
|
||||
suffix = type_str[dot_idx:] # includes the dot
|
||||
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||
if resolved:
|
||||
return f'{resolved}{suffix}'
|
||||
return type_str
|
||||
# Простое имя
|
||||
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||
if resolved:
|
||||
return resolved
|
||||
return type_str
|
||||
def emit_type(type_str, indent):
|
||||
if not type_str:
|
||||
X(f"{indent}<Type/>")
|
||||
@@ -494,7 +585,7 @@ def emit_mltext(tag, text, indent):
|
||||
X(f"{indent}<{tag}>")
|
||||
X(f"{indent}\t<v8:item>")
|
||||
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
||||
X(f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>")
|
||||
X(f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>")
|
||||
X(f"{indent}\t</v8:item>")
|
||||
X(f"{indent}</{tag}>")
|
||||
|
||||
@@ -722,7 +813,7 @@ def emit_label(el, name, _id, indent):
|
||||
X(f'{inner}<Title formatted="{formatted}">')
|
||||
X(f"{inner}\t<v8:item>")
|
||||
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
||||
X(f"{inner}\t\t<v8:content>{esc_xml(str(el['title']))}</v8:content>")
|
||||
X(f"{inner}\t\t<v8:content>{esc_xml_text(str(el['title']))}</v8:content>")
|
||||
X(f"{inner}\t</v8:item>")
|
||||
X(f"{inner}</Title>")
|
||||
emit_common_flags(el, inner)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# form-info v1.7 — Analyze 1C managed form structure (+единое имя хелпера состояния поддержки)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# form-info v1.7 — Analyze 1C managed form structure (+единое имя хелпера состояния поддержки)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ 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)
|
||||
|
||||
|
||||
# --- Namespace map ---
|
||||
|
||||
NSMAP = {
|
||||
@@ -353,7 +375,7 @@ def get_support_status_for_path(target_path):
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
def is_external_root(xml_path):
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
@@ -367,14 +389,14 @@ def get_support_status_for_path(target_path):
|
||||
rp = os.path.abspath(target_path)
|
||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||
elem_uuid = root_uuid(rp)
|
||||
if is_external_root(rp):
|
||||
if _sg_is_external_root(rp):
|
||||
return None
|
||||
bin_path = None
|
||||
d = os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if is_external_root(d + ".xml"):
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return None
|
||||
if not elem_uuid:
|
||||
elem_uuid = root_uuid(d + ".xml")
|
||||
@@ -433,7 +455,7 @@ def main():
|
||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||
parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
|
||||
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
limit = args.Limit
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-remove v1.4 — Remove form from 1C object
|
||||
# form-remove v1.9 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -64,6 +64,10 @@ foreach ($node in $formNodes) {
|
||||
$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 }
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -74,7 +78,9 @@ foreach ($node in $formNodes) {
|
||||
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
|
||||
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
|
||||
if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) {
|
||||
$node.InnerText = ""
|
||||
# IsEmpty, а не InnerText="": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
$node.IsEmpty = $true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +89,26 @@ $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
|
||||
|
||||
$stream = New-Object System.IO.FileStream($rootXmlFull.Path, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$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 $rootXmlFull.Path) -and ([System.IO.File]::ReadAllText($rootXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($rootXmlFull.Path, $xmlText, $encBom)
|
||||
|
||||
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# remove-form v1.4 — Remove form from 1C object
|
||||
# form-remove v1.9 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ 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"}
|
||||
|
||||
|
||||
@@ -30,21 +52,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (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"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #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 — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -67,7 +90,7 @@ def main():
|
||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||
parser.add_argument("-FormName", required=True)
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_name = args.ObjectName
|
||||
form_name = args.FormName
|
||||
@@ -119,6 +142,10 @@ def main():
|
||||
if parent.text and parent.text.strip() == "":
|
||||
parent.text = ""
|
||||
parent.remove(node)
|
||||
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||||
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||||
if len(parent) == 0 and not (parent.text or "").strip():
|
||||
parent.text = None
|
||||
break
|
||||
|
||||
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
||||
@@ -129,7 +156,9 @@ def main():
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
|
||||
el.text = ""
|
||||
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
el.text = None
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, root_xml_full)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-validate v1.8 — Validate 1C managed form
|
||||
# form-validate v1.17 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -56,9 +56,23 @@ try {
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
||||
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
|
||||
$root = $xmlDoc.DocumentElement
|
||||
|
||||
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||
# support-guard: is_external_root, авторитет — cf-edit).
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
|
||||
# --- Detect context: config vs EPF/ERF ---
|
||||
# Walk up from FormPath looking for Configuration.xml → config context
|
||||
# No Configuration.xml → external data processor / report (EPF/ERF)
|
||||
@@ -66,13 +80,56 @@ $script:isConfigContext = $false
|
||||
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
||||
# Порядок проверок тот же, что у Detect-FormatVersion: сначала корень автономной обработки,
|
||||
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||
# версию конфигурации.
|
||||
$extRoot = "$walkDir.xml"
|
||||
if (-not $script:versionAnchor) {
|
||||
if (Test-ExternalObjectRoot $extRoot) {
|
||||
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||
$script:versionAnchor = $extRoot
|
||||
break
|
||||
}
|
||||
}
|
||||
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
||||
$script:isConfigContext = $true
|
||||
$script:configXmlPath = Join-Path $walkDir "Configuration.xml"
|
||||
if (-not $script:versionAnchor) { $script:versionAnchor = $script:configXmlPath }
|
||||
break
|
||||
}
|
||||
$walkDir = Split-Path $walkDir
|
||||
}
|
||||
|
||||
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# --- Counters ---
|
||||
|
||||
$errors = 0
|
||||
@@ -101,6 +158,19 @@ function Report-Warn {
|
||||
Write-Host "[WARN] $msg"
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Form name from path ---
|
||||
|
||||
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
||||
@@ -127,12 +197,17 @@ if ($root.LocalName -ne "Form") {
|
||||
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
||||
} else {
|
||||
$version = $root.GetAttribute("version")
|
||||
if ($version -eq "2.17" -or $version -eq "2.20") {
|
||||
Report-OK "Root element: Form version=$version"
|
||||
} elseif ($version) {
|
||||
Report-Warn "Form version='$version' (expected 2.17 or 2.20)"
|
||||
} else {
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "Form version attribute missing"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} else {
|
||||
Report-OK "Root element: Form version=$version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +218,15 @@ if (-not $stopped) {
|
||||
if ($acb) {
|
||||
$acbName = $acb.GetAttribute("name")
|
||||
$acbId = $acb.GetAttribute("id")
|
||||
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||
# предупреждение; ошибка — только если id вовсе не число.
|
||||
if ($acbId -eq "-1") {
|
||||
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
||||
} elseif ($acbId -match '^-?\d+$') {
|
||||
Report-Warn "AutoCommandBar id='$acbId', usually '-1'"
|
||||
} else {
|
||||
Report-Error "AutoCommandBar id='$acbId', expected '-1'"
|
||||
Report-Error "AutoCommandBar id='$acbId' is not a number"
|
||||
}
|
||||
} else {
|
||||
Report-Error "AutoCommandBar element missing"
|
||||
@@ -426,11 +506,19 @@ if (-not $stopped) {
|
||||
$segments = $cleanPath -split '\.'
|
||||
$rootAttr = $segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if ($rootAttr -eq 'Items') {
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||
$itemsHops = 0
|
||||
$itemsBroken = $false
|
||||
while ($rootAttr -eq 'Items') {
|
||||
$itemsHops++
|
||||
if ($itemsHops -gt 10) { $itemsBroken = $true; break } # страховка от кольца ссылок
|
||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableName = $segments[1]
|
||||
$tableEl = $null
|
||||
@@ -443,17 +531,21 @@ if (-not $stopped) {
|
||||
if (-not $tableEl) {
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
||||
$pathErrors++
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||
# Table without DataPath — can't resolve further, accept silently
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||
$rootAttr = ($tableDp -split '\.')[0]
|
||||
$segments = $tableDp -split '\.'
|
||||
$rootAttr = $segments[0]
|
||||
}
|
||||
if ($itemsBroken) { continue }
|
||||
|
||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
||||
@@ -568,13 +660,18 @@ if (-not $stopped) {
|
||||
$actionErrors = 0
|
||||
$actionChecked = 0
|
||||
|
||||
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||
foreach ($cmd in $cmdNodes) {
|
||||
if ($stopped) { break }
|
||||
$cmdName = $cmd.GetAttribute("name")
|
||||
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
||||
$actionChecked++
|
||||
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
||||
Report-Error "Command '$cmdName': missing or empty Action"
|
||||
Report-Warn "Command '$cmdName': no Action — handler must be assigned at runtime, otherwise the command does nothing"
|
||||
$actionErrors++
|
||||
}
|
||||
}
|
||||
@@ -741,6 +838,41 @@ if (-not $stopped -and $isExtension) {
|
||||
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
||||
}
|
||||
}
|
||||
|
||||
# 11d. Пути на основной реквизит, которого форма не объявляет.
|
||||
# Check 5 такое пропускает: у заимствованной формы он не проверяет базовые элементы (id < 1000000),
|
||||
# а привязки в <xr:Link> вообще вне его списка тегов. Между тем это ровно тот случай, на котором
|
||||
# платформа отвергает загрузку: «Неверный путь к полю - Объект.X». Правило: если основной реквизит
|
||||
# не объявлен в <Attributes> формы, любой путь с его корнем не разрешится.
|
||||
# Корень берётся из основного реквизита BaseForm: «Объект» он только у формы объекта, у формы
|
||||
# списка это «Список», у формы записи регистра «Запись». С зашитым «Объект» проверка на таких
|
||||
# формах молча не срабатывала — валидатор рапортовал «чисто» на форме, которую платформа не примет.
|
||||
$mainAttrDeclared = $false
|
||||
foreach ($attr in $attrNodes) {
|
||||
$maNode = $attr.SelectSingleNode("f:MainAttribute", $nsMgr)
|
||||
if ($maNode -and $maNode.InnerText.Trim() -eq "true") { $mainAttrDeclared = $true; break }
|
||||
}
|
||||
|
||||
if (-not $mainAttrDeclared) {
|
||||
# Значения привязок ищем текстом: интересуют и обычные теги, и <xr:DataPath> внутри
|
||||
# <ChoiceParameterLinks>, а те живут в чужом пространстве имён.
|
||||
$rawForm = [System.IO.File]::ReadAllText($FormPath, [System.Text.Encoding]::UTF8)
|
||||
$mainBase = $baseFormNode.SelectSingleNode("f:Attributes/f:Attribute[f:MainAttribute='true']", $bfNs)
|
||||
$rootName = if ($mainBase -and $mainBase.GetAttribute("name")) { $mainBase.GetAttribute("name") } else { "Объект" }
|
||||
$rootPat = [regex]::Escape($rootName)
|
||||
$danglingPaths = @{}
|
||||
foreach ($m in [regex]::Matches($rawForm, "<(?:\w+:)?\w*DataPath[^>]*>(${rootPat}\.[^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||
$danglingPaths[$m.Groups[1].Value] = $true
|
||||
}
|
||||
if ($danglingPaths.Count -gt 0) {
|
||||
$shown = @($danglingPaths.Keys | Sort-Object)
|
||||
$sample = ($shown | Select-Object -First 3) -join ", "
|
||||
$suffix = if ($shown.Count -gt 3) { " (и ещё $($shown.Count - 3))" } else { "" }
|
||||
Report-Error "Path(s) rooted at '${rootName}' but the form declares no MainAttribute: $sample$suffix"
|
||||
} elseif ($mainBase) {
|
||||
Report-OK "Object paths: none dangling (MainAttribute not declared)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check callType without BaseForm (structural warning)
|
||||
@@ -843,6 +975,71 @@ if (-not $stopped) {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||
# считаем по узлу (GetNamespaceOfPrefix), а не по корню: локальная xmlns на элементе законна.
|
||||
|
||||
if (-not $stopped) {
|
||||
$prefixErrors = 0
|
||||
$prefixChecked = 0
|
||||
|
||||
$prefixPattern = '^([A-Za-z_][A-Za-z0-9_.-]*):.+$'
|
||||
# Значения, где префикс обязан резолвиться: тип реквизита/колонки и xsi:type
|
||||
# Только листовые узлы: под local-name()='Type' подходит и обёртка <Type>, и вложенный <v8:Type>,
|
||||
# а InnerText обёртки — то же значение, иначе одна ошибка сообщалась бы дважды.
|
||||
foreach ($node in $xmlDoc.SelectNodes("//*[local-name()='Type' or local-name()='TypeSet']", $nsMgr)) {
|
||||
if ($node.SelectSingleNode("*")) { continue }
|
||||
$val = $node.InnerText.Trim()
|
||||
if (-not $val) { continue }
|
||||
$m = [regex]::Match($val, $prefixPattern)
|
||||
if (-not $m.Success) { continue }
|
||||
$prefixChecked++
|
||||
$pfx = $m.Groups[1].Value
|
||||
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||
Report-Error "13. Type '$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||
$prefixErrors++
|
||||
}
|
||||
}
|
||||
foreach ($node in $xmlDoc.SelectNodes("//*[@xsi:type]", $nsMgr)) {
|
||||
$val = $node.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
$m = [regex]::Match($val, $prefixPattern)
|
||||
if (-not $m.Success) { continue }
|
||||
$prefixChecked++
|
||||
$pfx = $m.Groups[1].Value
|
||||
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||
Report-Error "13. xsi:type='$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||
$prefixErrors++
|
||||
}
|
||||
}
|
||||
|
||||
if ($prefixChecked -eq 0) {
|
||||
Report-OK "13. Namespace prefixes: nothing to check"
|
||||
} elseif ($prefixErrors -eq 0) {
|
||||
Report-OK "13. Namespace prefixes: $prefixChecked values, all declared"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||
|
||||
if (-not $stopped -and $script:versionAnchor) {
|
||||
$formVer = $root.GetAttribute("version")
|
||||
$dumpVer = Detect-FormatVersion (Split-Path (Resolve-Path $FormPath) -Parent)
|
||||
|
||||
if (-not $formVer) {
|
||||
Report-OK "14. Format version: not comparable"
|
||||
} elseif ($formVer -ne $dumpVer) {
|
||||
Report-Error "14. Format version $formVer differs from the dump ($dumpVer) — a dump carries one version, the platform refuses a file it cannot read"
|
||||
} else {
|
||||
Report-OK "14. Format version: $formVer, matches the dump"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
$checks = $script:okCount + $errors + $warnings
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-validate v1.8 — Validate 1C managed form
|
||||
# form-validate v1.17 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ 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)
|
||||
|
||||
|
||||
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
@@ -49,6 +71,64 @@ VALID_CFG_PREFIXES = {
|
||||
}
|
||||
|
||||
|
||||
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||
# support-guard: is_external_root, авторитет — cf-edit).
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -60,7 +140,7 @@ def main():
|
||||
parser.add_argument("-FormPath", "-Path", required=True)
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
detailed = args.Detailed
|
||||
@@ -106,13 +186,29 @@ def main():
|
||||
|
||||
# Detect context: config vs EPF/ERF
|
||||
is_config_context = False
|
||||
config_xml_path = ''
|
||||
version_anchor = ''
|
||||
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
||||
for _ in range(15):
|
||||
parent = os.path.dirname(walk_dir)
|
||||
if parent == walk_dir:
|
||||
break
|
||||
# Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки,
|
||||
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||
# версию конфигурации.
|
||||
ext_root = walk_dir + '.xml'
|
||||
if not version_anchor:
|
||||
if _sg_is_external_root(ext_root):
|
||||
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||
version_anchor = ext_root
|
||||
break
|
||||
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
||||
is_config_context = True
|
||||
config_xml_path = os.path.join(walk_dir, 'Configuration.xml')
|
||||
if not version_anchor:
|
||||
version_anchor = config_xml_path
|
||||
break
|
||||
walk_dir = parent
|
||||
|
||||
@@ -161,12 +257,19 @@ def main():
|
||||
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
||||
else:
|
||||
version = root.get("version", "")
|
||||
if version in ("2.17", "2.20"):
|
||||
report_ok(f"Root element: Form version={version}")
|
||||
elif version:
|
||||
report_warn(f"Form version='{version}' (expected 2.17 or 2.20)")
|
||||
else:
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
report_warn("Form version attribute missing")
|
||||
elif version_rank == 0:
|
||||
report_error(f"Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
report_warn(f"Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
report_warn(f"Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
else:
|
||||
report_ok(f"Root element: Form version={version}")
|
||||
|
||||
# --- Check 2: AutoCommandBar ---
|
||||
if not stopped:
|
||||
@@ -174,10 +277,15 @@ def main():
|
||||
if acb is not None:
|
||||
acb_name = acb.get("name", "")
|
||||
acb_id = acb.get("id", "")
|
||||
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||
# предупреждение; ошибка — только если id вовсе не число.
|
||||
if acb_id == "-1":
|
||||
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
||||
elif re.match(r'^-?\d+$', acb_id):
|
||||
report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'")
|
||||
else:
|
||||
report_error(f"AutoCommandBar id='{acb_id}', expected '-1'")
|
||||
report_error(f"AutoCommandBar id='{acb_id}' is not a number")
|
||||
else:
|
||||
report_error("AutoCommandBar element missing")
|
||||
|
||||
@@ -429,11 +537,21 @@ def main():
|
||||
segments = clean_path.split(".")
|
||||
root_attr = segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if root_attr == 'Items':
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||
items_hops = 0
|
||||
items_broken = False
|
||||
while root_attr == 'Items':
|
||||
items_hops += 1
|
||||
if items_hops > 10: # страховка от кольца ссылок
|
||||
items_broken = True
|
||||
break
|
||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_name = segments[1]
|
||||
table_el = None
|
||||
for candidate in all_elements:
|
||||
@@ -443,14 +561,19 @@ def main():
|
||||
if table_el is None:
|
||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
||||
path_errors += 1
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||
if table_dp.startswith('~'):
|
||||
table_dp = table_dp[1:]
|
||||
root_attr = table_dp.split(".")[0]
|
||||
segments = table_dp.split(".")
|
||||
root_attr = segments[0]
|
||||
if items_broken:
|
||||
continue
|
||||
|
||||
if root_attr not in attr_map:
|
||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
||||
@@ -464,6 +587,8 @@ def main():
|
||||
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
||||
if path_errors == 0 and path_msg:
|
||||
report_ok(f"Data bindings: {path_msg}")
|
||||
elif path_errors == 0:
|
||||
report_ok("Data bindings: none")
|
||||
|
||||
# --- Check 6: Button command references ---
|
||||
if not stopped:
|
||||
@@ -498,6 +623,8 @@ def main():
|
||||
|
||||
if cmd_errors == 0 and cmd_checked > 0:
|
||||
report_ok(f"Command references: {cmd_checked} buttons checked")
|
||||
elif cmd_checked == 0:
|
||||
report_ok("Command references: none")
|
||||
|
||||
# --- Check 7: Events have handler names ---
|
||||
if not stopped:
|
||||
@@ -537,12 +664,19 @@ def main():
|
||||
|
||||
if event_errors == 0 and event_checked > 0:
|
||||
report_ok(f"Event handlers: {event_checked} events checked")
|
||||
elif event_checked == 0:
|
||||
report_ok("Event handlers: none")
|
||||
|
||||
# --- Check 8: Command actions ---
|
||||
if not stopped:
|
||||
action_errors = 0
|
||||
action_checked = 0
|
||||
|
||||
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||
for cmd in cmd_nodes:
|
||||
if stopped:
|
||||
break
|
||||
@@ -550,11 +684,13 @@ def main():
|
||||
action_node = cmd.find(f"{{{F_NS}}}Action")
|
||||
action_checked += 1
|
||||
if action_node is None or not (action_node.text or "").strip():
|
||||
report_error(f"Command '{cmd_name}': missing or empty Action")
|
||||
report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing")
|
||||
action_errors += 1
|
||||
|
||||
if action_errors == 0 and action_checked > 0:
|
||||
report_ok(f"Command actions: {action_checked} commands checked")
|
||||
elif action_checked == 0:
|
||||
report_ok("Command actions: none")
|
||||
|
||||
# --- Check 9: MainAttribute count ---
|
||||
if not stopped:
|
||||
@@ -685,6 +821,39 @@ def main():
|
||||
if (ext_attr_count + ext_cmd_count) > 0:
|
||||
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
||||
|
||||
# 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442.
|
||||
# Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000),
|
||||
# \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 <xr:Link> \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c
|
||||
# \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442
|
||||
# \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 <Attributes> \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u0435\u0433\u043e \u043a\u043e\u0440\u043d\u0435\u043c \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f.
|
||||
# \u041a\u043e\u0440\u0435\u043d\u044c \u0431\u0435\u0440\u0451\u0442\u0441\u044f \u0438\u0437 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 BaseForm: \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043e\u043d \u0442\u043e\u043b\u044c\u043a\u043e \u0443 \u0444\u043e\u0440\u043c\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430, \u0443 \u0444\u043e\u0440\u043c\u044b
|
||||
# \u0441\u043f\u0438\u0441\u043a\u0430 \u044d\u0442\u043e \u00ab\u0421\u043f\u0438\u0441\u043e\u043a\u00bb, \u0443 \u0444\u043e\u0440\u043c\u044b \u0437\u0430\u043f\u0438\u0441\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u00ab\u0417\u0430\u043f\u0438\u0441\u044c\u00bb. \u0421 \u0437\u0430\u0448\u0438\u0442\u044b\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0430\u043a\u0438\u0445
|
||||
# \u0444\u043e\u0440\u043c\u0430\u0445 \u043c\u043e\u043b\u0447\u0430 \u043d\u0435 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u0430.
|
||||
main_attr_declared = False
|
||||
for attr in attr_nodes:
|
||||
ma_node = attr.find(f"{{{F_NS}}}MainAttribute")
|
||||
if ma_node is not None and (ma_node.text or "").strip() == "true":
|
||||
main_attr_declared = True
|
||||
break
|
||||
|
||||
if not main_attr_declared:
|
||||
# \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 <xr:DataPath> \u0432\u043d\u0443\u0442\u0440\u0438
|
||||
# <ChoiceParameterLinks>, \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d.
|
||||
with open(form_path, "r", encoding="utf-8-sig") as fh:
|
||||
raw_form = fh.read()
|
||||
main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']")
|
||||
root_name = main_base.get("name") if main_base is not None and main_base.get("name") else "\u041e\u0431\u044a\u0435\u043a\u0442"
|
||||
root_pat = re.escape(root_name)
|
||||
dangling_paths = set(re.findall(
|
||||
r'<(?:\w+:)?\w*DataPath[^>]*>(' + root_pat + r'\.[^<]+)</(?:\w+:)?\w*DataPath>', raw_form))
|
||||
if dangling_paths:
|
||||
shown = sorted(dangling_paths)
|
||||
sample = ", ".join(shown[:3])
|
||||
suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else ""
|
||||
report_error(f"Path(s) rooted at '{root_name}' but the form declares no MainAttribute: {sample}{suffix}")
|
||||
elif main_base is not None:
|
||||
report_ok("Object paths: none dangling (MainAttribute not declared)")
|
||||
|
||||
# Check callType without BaseForm
|
||||
if not stopped and not is_extension:
|
||||
call_type_without_base = False
|
||||
@@ -747,6 +916,62 @@ def main():
|
||||
else:
|
||||
report_ok('12. Types: no type values to check')
|
||||
|
||||
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||
# считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна.
|
||||
if not stopped:
|
||||
prefix_errors = 0
|
||||
prefix_checked = 0
|
||||
prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$')
|
||||
|
||||
for node in root.iter():
|
||||
if not isinstance(node.tag, str):
|
||||
continue
|
||||
ln = localname(node)
|
||||
values = []
|
||||
if ln in ('Type', 'TypeSet'):
|
||||
values.append((node.text or '').strip())
|
||||
xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type')
|
||||
if xsi_type:
|
||||
values.append(xsi_type.strip())
|
||||
for val in values:
|
||||
if not val:
|
||||
continue
|
||||
m = prefix_re.match(val)
|
||||
if not m:
|
||||
continue
|
||||
prefix_checked += 1
|
||||
pfx = m.group(1)
|
||||
if pfx not in node.nsmap:
|
||||
kind = "xsi:type" if val == xsi_type else "Type"
|
||||
report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared "
|
||||
"— the platform cannot read the file (XDTO)")
|
||||
prefix_errors += 1
|
||||
|
||||
if prefix_checked == 0:
|
||||
report_ok('13. Namespace prefixes: nothing to check')
|
||||
elif prefix_errors == 0:
|
||||
report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared')
|
||||
|
||||
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||
if not stopped and version_anchor:
|
||||
form_ver = root.get('version', '')
|
||||
dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path)))
|
||||
|
||||
if not form_ver:
|
||||
report_ok('14. Format version: not comparable')
|
||||
elif form_ver != dump_ver:
|
||||
report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) '
|
||||
'— a dump carries one version, the platform refuses a file it cannot read')
|
||||
else:
|
||||
report_ok(f'14. Format version: {form_ver}, matches the dump')
|
||||
|
||||
# --- Finalize ---
|
||||
checks = ok_count + errors + warnings
|
||||
if errors == 0 and warnings == 0 and not detailed:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# help-add v1.9 — Add built-in help to 1C object
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -149,10 +149,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
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) {
|
||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
||||
$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
|
||||
@@ -195,7 +205,18 @@ $helpXml = @"
|
||||
</Help>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $helpXmlPath $helpXml $encBom
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
@@ -255,11 +276,26 @@ if (Test-Path $formsDir) {
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$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)"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-help v1.9 — Add built-in help to 1C object
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ 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"}
|
||||
|
||||
|
||||
@@ -191,6 +213,16 @@ def assert_edit_allowed(target_path, require):
|
||||
|
||||
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:
|
||||
@@ -222,21 +254,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (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"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #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 — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -252,10 +285,24 @@ def save_xml_with_bom(tree, path):
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
def 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():
|
||||
@@ -265,7 +312,7 @@ def main():
|
||||
parser.add_argument("-ObjectName", required=True)
|
||||
parser.add_argument("-Lang", default="ru")
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_name = args.ObjectName
|
||||
lang = args.Lang
|
||||
@@ -301,7 +348,7 @@ def main():
|
||||
'</Help>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_xml_path, help_xml)
|
||||
write_xml_file(help_xml_path, help_xml)
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
@@ -324,7 +371,7 @@ def main():
|
||||
'</html>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_html_path, help_html)
|
||||
write_utf8_bom(help_html_path, help_html)
|
||||
|
||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# img-grid v1.1 — Overlay numbered grid on image
|
||||
# img-grid v1.2 — Overlay numbered grid on image
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
||||
|
||||
@@ -16,6 +16,28 @@ import os
|
||||
import sys
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
MARGIN_TOP = 20
|
||||
MARGIN_LEFT = 24
|
||||
|
||||
@@ -30,7 +52,7 @@ def main():
|
||||
parser.add_argument("-r", "--rows", type=int, default=0,
|
||||
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
||||
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
if args.cols <= 0:
|
||||
parser.error("--cols must be greater than 0")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# interface-edit v1.8 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.18 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||
@@ -162,9 +162,20 @@ Assert-EditAllowed $CIPath 'editable'
|
||||
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) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$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
|
||||
@@ -199,7 +210,12 @@ if (-not (Test-Path $CIPath)) {
|
||||
</CommandInterface>
|
||||
"@
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI, $utf8Bom)
|
||||
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF, без перевода строки в конце.
|
||||
# (Правка существующего файла, наоборот, наследует его стиль — это делает
|
||||
# основной путь сохранения ниже.) Нормализация нужна потому, что here-string
|
||||
# берёт переводы строк из самого .ps1, а он в репозитории хранится с LF.
|
||||
$emptyCI = ($emptyCI -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
||||
} else {
|
||||
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
||||
@@ -380,6 +396,10 @@ $script:typeNormMap = @{
|
||||
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
||||
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
||||
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
||||
"РегистрРасчёта"="CalculationRegister"; "РегистрРасчета"="CalculationRegister"
|
||||
"ПланВидовРасчёта"="ChartOfCalculationTypes"; "ПланВидовРасчета"="ChartOfCalculationTypes"
|
||||
"Роль"="Role"; "ОбщийМакет"="CommonTemplate"; "ЭлементСтиля"="StyleItem"
|
||||
"ОбщийРеквизит"="CommonAttribute"; "ГруппаКоманд"="CommandGroup"
|
||||
# Russian plural
|
||||
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
||||
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
||||
@@ -389,6 +409,10 @@ $script:typeNormMap = @{
|
||||
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
||||
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
||||
"Подсистемы"="Subsystem"
|
||||
"РегистрыРасчёта"="CalculationRegister"; "РегистрыРасчета"="CalculationRegister"
|
||||
"ПланыВидовРасчёта"="ChartOfCalculationTypes"; "ПланыВидовРасчета"="ChartOfCalculationTypes"
|
||||
"Роли"="Role"; "ОбщиеМакеты"="CommonTemplate"; "ЭлементыСтиля"="StyleItem"
|
||||
"ОбщиеРеквизиты"="CommonAttribute"; "ГруппыКоманд"="CommandGroup"
|
||||
}
|
||||
|
||||
function Normalize-CmdName([string]$name) {
|
||||
@@ -671,8 +695,16 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
Info "Saved: $resolvedPath"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-edit v1.8 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.18 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,65 @@ 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
|
||||
@@ -189,6 +248,16 @@ def assert_edit_allowed(target_path, require):
|
||||
|
||||
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:
|
||||
@@ -282,7 +351,7 @@ def import_ci_fragment(xml_string):
|
||||
def parse_value_list(val):
|
||||
val = val.strip()
|
||||
if val.startswith("["):
|
||||
arr = json.loads(val)
|
||||
arr = ci_json(json.loads(val))
|
||||
return [str(item) for item in arr]
|
||||
return [val]
|
||||
|
||||
@@ -304,21 +373,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (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"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #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 — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -357,6 +427,10 @@ TYPE_NORM_MAP = {
|
||||
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
||||
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
||||
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
||||
'РегистрРасчёта': 'CalculationRegister', 'РегистрРасчета': 'CalculationRegister',
|
||||
'ПланВидовРасчёта': 'ChartOfCalculationTypes', 'ПланВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роль': 'Role', 'ОбщийМакет': 'CommonTemplate', 'ЭлементСтиля': 'StyleItem',
|
||||
'ОбщийРеквизит': 'CommonAttribute', 'ГруппаКоманд': 'CommandGroup',
|
||||
# Russian plural
|
||||
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
||||
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
||||
@@ -366,6 +440,10 @@ TYPE_NORM_MAP = {
|
||||
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
||||
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
||||
'Подсистемы': 'Subsystem',
|
||||
'РегистрыРасчёта': 'CalculationRegister', 'РегистрыРасчета': 'CalculationRegister',
|
||||
'ПланыВидовРасчёта': 'ChartOfCalculationTypes', 'ПланыВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роли': 'Role', 'ОбщиеМакеты': 'CommonTemplate', 'ЭлементыСтиля': 'StyleItem',
|
||||
'ОбщиеРеквизиты': 'CommonAttribute', 'ГруппыКоманд': 'CommandGroup',
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +479,7 @@ def main():
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-CreateIfMissing", action="store_true")
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Mode validation ---
|
||||
if args.DefinitionFile and args.Operation:
|
||||
@@ -438,7 +516,12 @@ def main():
|
||||
f'\tversion="{format_version}">\n'
|
||||
f'</CommandInterface>'
|
||||
)
|
||||
with open(ci_path, "w", encoding="utf-8-sig") as fh:
|
||||
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF в разделителях. (Правка
|
||||
# существующего файла, наоборот, наследует его стиль — это делает
|
||||
# save_xml_bom через _detect_xml_style.) newline="" обязателен: без него
|
||||
# текстовый режим дал бы CRLF на Windows и LF на macOS.
|
||||
empty_ci = empty_ci.replace("\r\n", "\n").replace("\n", "\r\n").rstrip("\r\n")
|
||||
with open(ci_path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||
fh.write(empty_ci)
|
||||
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
||||
else:
|
||||
@@ -564,7 +647,7 @@ def main():
|
||||
|
||||
def do_place(json_val):
|
||||
nonlocal add_count, modify_count
|
||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else json.loads(json_val))
|
||||
cmd_name = normalize_cmd_name(str(defn["command"]))
|
||||
group_name = str(defn["group"])
|
||||
if not cmd_name or not group_name:
|
||||
@@ -592,7 +675,7 @@ def main():
|
||||
|
||||
def do_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else json.loads(json_val))
|
||||
group_name = str(defn["group"])
|
||||
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
||||
if not group_name or not commands:
|
||||
@@ -626,7 +709,7 @@ def main():
|
||||
|
||||
def do_subsystem_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else json.loads(json_val))
|
||||
subsystems = [str(s) for s in parsed]
|
||||
if not subsystems:
|
||||
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
||||
@@ -651,7 +734,7 @@ def main():
|
||||
|
||||
def do_group_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else json.loads(json_val))
|
||||
groups = [str(g) for g in parsed]
|
||||
if not groups:
|
||||
print("group-order requires array of group names", file=sys.stderr)
|
||||
@@ -681,7 +764,7 @@ def main():
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
||||
ops = json.loads(fh.read())
|
||||
ops = ci_json(json.loads(fh.read()))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
@@ -691,19 +774,21 @@ def main():
|
||||
|
||||
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_name == "hide":
|
||||
if op_key == "hide":
|
||||
do_hide(parse_value_list(op_value))
|
||||
elif op_name == "show":
|
||||
elif op_key == "show":
|
||||
do_show(parse_value_list(op_value))
|
||||
elif op_name == "place":
|
||||
elif op_key == "place":
|
||||
do_place(op_value)
|
||||
elif op_name == "order":
|
||||
elif op_key == "order":
|
||||
do_order(op_value)
|
||||
elif op_name == "subsystem-order":
|
||||
elif op_key == "subsystem-order":
|
||||
do_subsystem_order(op_value)
|
||||
elif op_name == "group-order":
|
||||
elif op_key == "group-order":
|
||||
do_group_order(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
||||
# interface-validate v1.3 — Validate 1C CommandInterface.xml structure (+Report-*: общий эталон вывода валидаторов)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||
@@ -51,16 +51,21 @@ $script:output = New-Object System.Text.StringBuilder 8192
|
||||
$script:allCommandNames = @()
|
||||
|
||||
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
||||
function Report-OK([string]$msg) {
|
||||
function Report-OK {
|
||||
param([string]$msg)
|
||||
$script:okCount++
|
||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||
}
|
||||
function Report-Error([string]$msg) {
|
||||
function Report-Error {
|
||||
param([string]$msg)
|
||||
$script:errors++
|
||||
Out-Line "[ERROR] $msg"
|
||||
if ($script:errors -ge $MaxErrors) { $script:stopped = $true }
|
||||
if ($script:errors -ge $MaxErrors) {
|
||||
$script:stopped = $true
|
||||
}
|
||||
}
|
||||
function Report-Warn([string]$msg) {
|
||||
function Report-Warn {
|
||||
param([string]$msg)
|
||||
$script:warnings++
|
||||
Out-Line "[WARN] $msg"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
||||
# interface-validate v1.3 — Validate 1C CommandInterface.xml structure (+Report-*: общий эталон вывода валидаторов)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
||||
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
||||
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||
@@ -83,7 +105,7 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
ci_path = args.CIPath
|
||||
detailed = args.Detailed
|
||||
|
||||
@@ -125,6 +125,7 @@ shorthand — вместо строки задаётся объект:
|
||||
| `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` — стандартный реквизит НомерСтроки
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||
| `owners` | `[]` | массив ссылок-владельцев: `["CatalogRef.Контрагенты"]` |
|
||||
| `owners` | `[]` | массив владельцев: `["Catalog.Контрагенты"]` |
|
||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||
| `codeType` | `String` | `String` / `Number` |
|
||||
|
||||
@@ -7,16 +7,21 @@
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||
|
||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||
- строка — URL-путь без методов: `"/health"`;
|
||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `methods` — `{ "ИмяМетода": "HTTPMethod" }`.
|
||||
- объект: `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",
|
||||
@@ -31,21 +36,29 @@ HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `namespace` | пусто | URI пространства имён WSDL |
|
||||
| `xdtoPackages` | пусто | XDTO-пакеты |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||
| `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),
|
||||
`handler` (имя процедуры, по умолчанию = имя операции), `parameters`.
|
||||
`procedureName` (имя процедуры, по умолчанию = имя операции; синоним ключа — `handler`),
|
||||
`dataLockControlMode` (по умолчанию `Managed`), `synonym`, `comment`, `parameters`.
|
||||
|
||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||
- строка — XDTO-тип (`direction` = `In`);
|
||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`), `direction` (`In` / `Out` / `InOut`).
|
||||
- объект: `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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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