mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-20 00:45:54 +03:00
Compare commits
309
Commits
@@ -1,4 +1,4 @@
|
|||||||
# cf-edit v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||||
@@ -44,6 +44,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -80,10 +90,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -150,10 +163,17 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
Assert-EditAllowed $resolvedPath 'editable'
|
Assert-EditAllowed $resolvedPath 'editable'
|
||||||
|
|
||||||
# --- Load XML with PreserveWhitespace ---
|
# --- Load XML with PreserveWhitespace ---
|
||||||
|
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
|
||||||
|
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
|
||||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||||
$script:xmlDoc.PreserveWhitespace = $true
|
$script:xmlDoc.PreserveWhitespace = $true
|
||||||
$script:xmlDoc.Load($resolvedPath)
|
$script:xmlDoc.Load($resolvedPath)
|
||||||
|
|
||||||
|
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||||
|
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||||
|
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
|
||||||
|
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
|
||||||
|
|
||||||
$script:addCount = 0
|
$script:addCount = 0
|
||||||
$script:removeCount = 0
|
$script:removeCount = 0
|
||||||
$script:modifyCount = 0
|
$script:modifyCount = 0
|
||||||
@@ -673,7 +693,9 @@ $bodyBlock$declarations
|
|||||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||||
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom)
|
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||||
|
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
Info "Wrote panel layout: $caiPath"
|
Info "Wrote panel layout: $caiPath"
|
||||||
}
|
}
|
||||||
@@ -851,7 +873,7 @@ function Do-SetHomePage($valArg) {
|
|||||||
|
|
||||||
$hpXml = @"
|
$hpXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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>
|
<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
|
||||||
$leftXml
|
$leftXml
|
||||||
$rightXml
|
$rightXml
|
||||||
@@ -862,7 +884,9 @@ $rightXml
|
|||||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||||
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom)
|
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||||
|
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
Info "Wrote home page layout: $hpPath"
|
Info "Wrote home page layout: $hpPath"
|
||||||
}
|
}
|
||||||
@@ -964,6 +988,14 @@ $memStream.Close()
|
|||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#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)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-edit v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -12,6 +12,65 @@ import uuid as _uuid
|
|||||||
from html import escape as html_escape
|
from html import escape as html_escape
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
class CIDict(dict):
|
||||||
|
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||||
|
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||||
|
# сохраняется — от него зависит порядок эмиссии.
|
||||||
|
def _actual(self, key):
|
||||||
|
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||||
|
return key
|
||||||
|
ci = self.__dict__.get('_ci')
|
||||||
|
if ci is None or len(ci) != len(self):
|
||||||
|
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||||
|
self.__dict__['_ci'] = ci
|
||||||
|
return ci.get(key.lower(), key)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return dict.__getitem__(self, self._actual(key))
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return dict.__contains__(self, self._actual(key))
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, self._actual(key), default)
|
||||||
|
|
||||||
|
def pop(self, key, *default):
|
||||||
|
return dict.pop(self, self._actual(key), *default)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||||
|
dict.__setitem__(self, self._actual(key), value)
|
||||||
|
|
||||||
|
def ci_json(obj):
|
||||||
|
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [ci_json(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
@@ -33,6 +92,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -72,6 +143,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -79,6 +153,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -307,12 +383,49 @@ def parse_batch_value(val):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def save_xml_bom(tree, path):
|
def _detect_xml_style(path):
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
if not xml_bytes.endswith(b"\n"):
|
try:
|
||||||
|
raw = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
body = raw[3:] if bom else raw
|
||||||
|
crlf = b"\r\n" in body
|
||||||
|
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||||
|
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||||
|
final_nl = body.endswith(b"\n")
|
||||||
|
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
|
xml_bytes = xml_bytes.replace(
|
||||||
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||||
|
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||||
|
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||||
|
want_final_nl = style["final_nl"] if style else False
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if want_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
|
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||||
|
if (style["crlf"] if style else True):
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
return xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def save_xml_bom(tree, path):
|
||||||
|
style = _detect_xml_style(path)
|
||||||
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
|
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
|
if style is None or style["bom"]:
|
||||||
f.write(b"\xef\xbb\xbf")
|
f.write(b"\xef\xbb\xbf")
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
@@ -326,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("-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("-Value", default=None)
|
||||||
parser.add_argument("-NoValidate", action="store_true")
|
parser.add_argument("-NoValidate", action="store_true")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
if args.DefinitionFile and args.Operation:
|
if args.DefinitionFile and args.Operation:
|
||||||
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
||||||
@@ -357,6 +470,10 @@ def main():
|
|||||||
tree = etree.parse(resolved_path, xml_parser)
|
tree = etree.parse(resolved_path, xml_parser)
|
||||||
xml_root = tree.getroot()
|
xml_root = tree.getroot()
|
||||||
|
|
||||||
|
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||||
|
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||||
|
format_version = xml_root.get('version') or '2.17'
|
||||||
|
|
||||||
add_count = 0
|
add_count = 0
|
||||||
remove_count = 0
|
remove_count = 0
|
||||||
modify_count = 0
|
modify_count = 0
|
||||||
@@ -705,7 +822,7 @@ def main():
|
|||||||
layout = value
|
layout = value
|
||||||
if isinstance(layout, str):
|
if isinstance(layout, str):
|
||||||
try:
|
try:
|
||||||
layout = json.loads(layout)
|
layout = ci_json(json.loads(layout))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print(f"set-panels value must be valid JSON object", file=sys.stderr)
|
print(f"set-panels value must be valid JSON object", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -860,7 +977,7 @@ def main():
|
|||||||
layout = value
|
layout = value
|
||||||
if isinstance(layout, str):
|
if isinstance(layout, str):
|
||||||
try:
|
try:
|
||||||
layout = json.loads(layout)
|
layout = ci_json(json.loads(layout))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
print("set-home-page value must be valid JSON object", file=sys.stderr)
|
print("set-home-page value must be valid JSON object", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -906,7 +1023,7 @@ def main():
|
|||||||
'<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
|
'<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
|
||||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">\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'\t<WorkingAreaTemplate>{tmpl}</WorkingAreaTemplate>\r\n'
|
||||||
f'{left_xml}\r\n'
|
f'{left_xml}\r\n'
|
||||||
f'{right_xml}\r\n'
|
f'{right_xml}\r\n'
|
||||||
@@ -928,7 +1045,7 @@ def main():
|
|||||||
if not os.path.isabs(def_file):
|
if not os.path.isabs(def_file):
|
||||||
def_file = os.path.join(os.getcwd(), def_file)
|
def_file = os.path.join(os.getcwd(), def_file)
|
||||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
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):
|
if isinstance(ops, list):
|
||||||
operations = ops
|
operations = ops
|
||||||
else:
|
else:
|
||||||
@@ -938,23 +1055,25 @@ def main():
|
|||||||
|
|
||||||
for op in operations:
|
for op in operations:
|
||||||
op_name = op.get("operation", args.Operation or "")
|
op_name = op.get("operation", args.Operation or "")
|
||||||
|
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||||
|
op_key = str(op_name).lower()
|
||||||
op_value = op.get("value", args.Value or "")
|
op_value = op.get("value", args.Value or "")
|
||||||
|
|
||||||
if op_name == "modify-property":
|
if op_key == "modify-property":
|
||||||
do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
|
do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "add-childObject":
|
elif op_key == "add-childobject":
|
||||||
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "remove-childObject":
|
elif op_key == "remove-childobject":
|
||||||
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "add-defaultRole":
|
elif op_key == "add-defaultrole":
|
||||||
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "remove-defaultRole":
|
elif op_key == "remove-defaultrole":
|
||||||
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "set-defaultRoles":
|
elif op_key == "set-defaultroles":
|
||||||
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
|
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
|
||||||
elif op_name == "set-panels":
|
elif op_key == "set-panels":
|
||||||
do_set_panels(op_value)
|
do_set_panels(op_value)
|
||||||
elif op_name == "set-home-page":
|
elif op_key == "set-home-page":
|
||||||
do_set_home_page(op_value)
|
do_set_home_page(op_value)
|
||||||
else:
|
else:
|
||||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
# cf-info v1.5 — Compact summary of 1C configuration root
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -12,6 +12,28 @@ from lxml import etree
|
|||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- Argument parsing ---
|
# --- Argument parsing ---
|
||||||
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
||||||
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
||||||
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
|
|||||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||||
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
||||||
parser.add_argument("-OutFile", default="", help="Write output to file")
|
parser.add_argument("-OutFile", default="", help="Write output to file")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
# --- Output helper (collect all, paginate at the end) ---
|
# --- Output helper (collect all, paginate at the end) ---
|
||||||
lines_buf = []
|
lines_buf = []
|
||||||
|
|||||||
@@ -22,6 +22,21 @@ allowed-tools:
|
|||||||
| `Version` | Версия конфигурации |
|
| `Version` | Версия конфигурации |
|
||||||
| `Vendor` | Поставщик |
|
| `Vendor` | Поставщик |
|
||||||
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
||||||
|
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
|
||||||
|
|
||||||
|
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
|
||||||
|
разным правилам.
|
||||||
|
|
||||||
|
`FormatVersion` — **не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
|
||||||
|
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
|
||||||
|
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
|
||||||
|
|
||||||
|
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
|
||||||
|
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
|
||||||
|
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
|
||||||
|
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
|
||||||
|
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
|
||||||
|
не будет.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
||||||
@@ -36,8 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name
|
|||||||
# С версией и поставщиком
|
# С версией и поставщиком
|
||||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||||
|
|
||||||
# Другой режим совместимости
|
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||||
... -Name TestCfg -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -7,12 +7,56 @@ param(
|
|||||||
[string]$OutputDir = "src",
|
[string]$OutputDir = "src",
|
||||||
[string]$Version,
|
[string]$Version,
|
||||||
[string]$Vendor,
|
[string]$Vendor,
|
||||||
[string]$CompatibilityMode = "Version8_3_24"
|
[string]$CompatibilityMode = "Version8_3_24",
|
||||||
|
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
|
||||||
|
# совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||||
|
[string]$FormatVersion = "2.17"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||||
|
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||||
|
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||||
|
# на нечисловое значение: это опечатка, а не версия.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
$formatRank = Get-FormatRank $FormatVersion
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||||
|
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||||
|
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||||
|
if ($formatRank -eq 0) {
|
||||||
|
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||||
|
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||||
|
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||||
|
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||||
|
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||||
|
# расхождение портов началось бы прямо здесь.
|
||||||
|
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
|
||||||
|
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
|
||||||
|
}
|
||||||
|
|
||||||
# --- Resolve output dir ---
|
# --- Resolve output dir ---
|
||||||
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
||||||
$OutputDir = Join-Path (Get-Location).Path $OutputDir
|
$OutputDir = Join-Path (Get-Location).Path $OutputDir
|
||||||
@@ -38,6 +82,11 @@ $co6 = [guid]::NewGuid().ToString()
|
|||||||
$co7 = [guid]::NewGuid().ToString()
|
$co7 = [guid]::NewGuid().ToString()
|
||||||
|
|
||||||
# --- Mobile functionalities ---
|
# --- Mobile functionalities ---
|
||||||
|
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||||
|
$is221 = ($formatRank -ge 221)
|
||||||
|
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||||
|
$is218 = ($formatRank -ge 218)
|
||||||
|
|
||||||
$mobileFuncs = @(
|
$mobileFuncs = @(
|
||||||
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
|
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
|
||||||
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
|
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
|
||||||
@@ -54,6 +103,12 @@ $mobileFuncs = @(
|
|||||||
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
|
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
|
||||||
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
|
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
|
||||||
)
|
)
|
||||||
|
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||||
|
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||||
|
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||||
|
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||||
|
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||||
|
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
|
||||||
|
|
||||||
$mobileXml = ""
|
$mobileXml = ""
|
||||||
foreach ($mf in $mobileFuncs) {
|
foreach ($mf in $mobileFuncs) {
|
||||||
@@ -63,17 +118,43 @@ foreach ($mf in $mobileFuncs) {
|
|||||||
# --- Synonym XML ---
|
# --- Synonym XML ---
|
||||||
$synonymXml = ""
|
$synonymXml = ""
|
||||||
if ($Synonym) {
|
if ($Synonym) {
|
||||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Optional properties ---
|
# --- Optional properties ---
|
||||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||||
|
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||||
|
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||||
|
|
||||||
|
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
|
||||||
|
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
|
||||||
|
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
|
||||||
|
# на своё место, а не в конец.
|
||||||
|
$nl = "`r`n"
|
||||||
|
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
|
||||||
|
$palNs = ""
|
||||||
|
if ($is221) {
|
||||||
|
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||||
|
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
|
||||||
|
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
|
||||||
|
$f221AuxForms = $nl + ((@(
|
||||||
|
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||||
|
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||||
|
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||||
|
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
|
||||||
|
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
|
||||||
|
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
|
||||||
|
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
|
||||||
|
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
|
||||||
|
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
|
||||||
|
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Configuration.xml ---
|
# --- Configuration.xml ---
|
||||||
$cfgXml = @"
|
$cfgXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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">
|
<Configuration uuid="$uuidCfg">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -106,7 +187,7 @@ $cfgXml = @"
|
|||||||
</xr:ContainedObject>
|
</xr:ContainedObject>
|
||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
<Name>$(Esc-XmlText ($Name))</Name>
|
||||||
<Synonym>$synonymXml</Synonym>
|
<Synonym>$synonymXml</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
<NamePrefix/>
|
<NamePrefix/>
|
||||||
@@ -117,8 +198,8 @@ $cfgXml = @"
|
|||||||
</UsePurposes>
|
</UsePurposes>
|
||||||
<ScriptVariant>Russian</ScriptVariant>
|
<ScriptVariant>Russian</ScriptVariant>
|
||||||
<DefaultRoles/>
|
<DefaultRoles/>
|
||||||
<Vendor>$vendorXml</Vendor>
|
$vendorEl
|
||||||
<Version>$versionXml</Version>
|
$versionEl
|
||||||
<UpdateCatalogAddress/>
|
<UpdateCatalogAddress/>
|
||||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||||
@@ -140,15 +221,15 @@ $cfgXml = @"
|
|||||||
<DefaultDataHistoryChangeHistoryForm/>
|
<DefaultDataHistoryChangeHistoryForm/>
|
||||||
<DefaultDataHistoryVersionDataForm/>
|
<DefaultDataHistoryVersionDataForm/>
|
||||||
<DefaultDataHistoryVersionDifferencesForm/>
|
<DefaultDataHistoryVersionDifferencesForm/>
|
||||||
<DefaultCollaborationSystemUsersChoiceForm/>
|
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
|
||||||
<RequiredMobileApplicationPermissions/>
|
<RequiredMobileApplicationPermissions/>
|
||||||
<UsedMobileApplicationFunctionalities>$mobileXml
|
<UsedMobileApplicationFunctionalities>$mobileXml
|
||||||
</UsedMobileApplicationFunctionalities>
|
</UsedMobileApplicationFunctionalities>
|
||||||
<StandaloneConfigurationRestrictionRoles/>
|
<StandaloneConfigurationRestrictionRoles/>
|
||||||
<MobileApplicationURLs/>
|
<MobileApplicationURLs/>
|
||||||
<AllowedIncomingShareRequestTypes/>
|
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
|
||||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
|
||||||
<DefaultInterface/>
|
<DefaultInterface/>$f221Captions
|
||||||
<DefaultStyle/>
|
<DefaultStyle/>
|
||||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
<BriefInformation/>
|
<BriefInformation/>
|
||||||
@@ -160,7 +241,7 @@ $cfgXml = @"
|
|||||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
|
||||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
||||||
<DefaultConstantsForm/>
|
<DefaultConstantsForm/>
|
||||||
@@ -175,7 +256,7 @@ $cfgXml = @"
|
|||||||
# --- Languages/Русский.xml ---
|
# --- Languages/Русский.xml ---
|
||||||
$langXml = @"
|
$langXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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">
|
<Language uuid="$uuidLang">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>Русский</Name>
|
<Name>Русский</Name>
|
||||||
@@ -235,11 +316,20 @@ if (-not (Test-Path $extDir)) {
|
|||||||
# --- Write files with UTF-8 BOM ---
|
# --- Write files with UTF-8 BOM ---
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile $cfgFile $cfgXml $enc
|
||||||
$langFile = Join-Path $langDir "Русский.xml"
|
$langFile = Join-Path $langDir "Русский.xml"
|
||||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
Write-XmlFile $langFile $langXml $enc
|
||||||
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||||
[System.IO.File]::WriteAllText($caiFile, $caiXml, $enc)
|
Write-XmlFile $caiFile $caiXml $enc
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
Write-Host "[OK] Создана конфигурация: $Name"
|
Write-Host "[OK] Создана конфигурация: $Name"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-init v1.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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Generates minimal XML source files for a 1C configuration."""
|
"""Generates minimal XML source files for a 1C configuration."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, argparse, re, uuid
|
||||||
|
|
||||||
def esc_xml(s):
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
def new_uuid():
|
def new_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
def write_utf8_bom(path, content):
|
def write_utf8_bom(path, content):
|
||||||
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -24,7 +70,32 @@ def main():
|
|||||||
parser.add_argument('-Version', dest='Version', default='')
|
parser.add_argument('-Version', dest='Version', default='')
|
||||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||||
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
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -49,6 +120,11 @@ def main():
|
|||||||
co = [new_uuid() for _ in range(7)]
|
co = [new_uuid() for _ in range(7)]
|
||||||
|
|
||||||
# --- Mobile functionalities ---
|
# --- Mobile functionalities ---
|
||||||
|
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||||
|
is_221 = format_rank_value >= 221
|
||||||
|
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||||
|
is_218 = format_rank_value >= 218
|
||||||
|
|
||||||
mobile_funcs = [
|
mobile_funcs = [
|
||||||
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
||||||
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
||||||
@@ -65,6 +141,13 @@ def main():
|
|||||||
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
|
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
|
||||||
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
|
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
|
||||||
]
|
]
|
||||||
|
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||||
|
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||||
|
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||||
|
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||||
|
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||||
|
if is_218:
|
||||||
|
mobile_funcs.append(("TextToSpeech", "false"))
|
||||||
|
|
||||||
mobile_xml = ""
|
mobile_xml = ""
|
||||||
for func_name, func_use in mobile_funcs:
|
for func_name, func_use in mobile_funcs:
|
||||||
@@ -73,10 +156,12 @@ def main():
|
|||||||
# --- Synonym XML ---
|
# --- Synonym XML ---
|
||||||
synonym_xml = ""
|
synonym_xml = ""
|
||||||
if synonym:
|
if synonym:
|
||||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||||
|
|
||||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||||
version_xml = esc_xml(version) if version else ""
|
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||||
|
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||||
|
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||||
|
|
||||||
class_ids = [
|
class_ids = [
|
||||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||||
@@ -88,6 +173,28 @@ def main():
|
|||||||
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
|
||||||
|
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
|
||||||
|
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
|
||||||
|
pal_ns = ""
|
||||||
|
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
|
||||||
|
if is_221:
|
||||||
|
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||||
|
f221_aux_forms = "\r\n" + "\r\n".join(
|
||||||
|
f"\t\t\t{t}" for t in (
|
||||||
|
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||||
|
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||||
|
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||||
|
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
|
||||||
|
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
|
||||||
|
"</MainClientApplicationWindowInterfaceVariant>"
|
||||||
|
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
|
||||||
|
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
|
||||||
|
"</ClientApplicationWindowsOpenVariant>")
|
||||||
|
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||||
|
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
|
||||||
|
"</Version85InterfaceMigrationMode>")
|
||||||
|
|
||||||
contained_objects = ""
|
contained_objects = ""
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||||
@@ -96,12 +203,12 @@ def main():
|
|||||||
\t\t\t</xr:ContainedObject>\n"""
|
\t\t\t</xr:ContainedObject>\n"""
|
||||||
|
|
||||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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<Configuration uuid="{uuid_cfg}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
{contained_objects}\t\t</InternalInfo>
|
{contained_objects}\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
\t\t\t<NamePrefix/>
|
\t\t\t<NamePrefix/>
|
||||||
@@ -112,8 +219,8 @@ def main():
|
|||||||
\t\t\t</UsePurposes>
|
\t\t\t</UsePurposes>
|
||||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||||
\t\t\t<DefaultRoles/>
|
\t\t\t<DefaultRoles/>
|
||||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
\t\t\t{vendor_el}
|
||||||
\t\t\t<Version>{version_xml}</Version>
|
\t\t\t{version_el}
|
||||||
\t\t\t<UpdateCatalogAddress/>
|
\t\t\t<UpdateCatalogAddress/>
|
||||||
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||||
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||||
@@ -135,15 +242,15 @@ def main():
|
|||||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>
|
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
|
||||||
\t\t\t<RequiredMobileApplicationPermissions/>
|
\t\t\t<RequiredMobileApplicationPermissions/>
|
||||||
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
||||||
\t\t\t</UsedMobileApplicationFunctionalities>
|
\t\t\t</UsedMobileApplicationFunctionalities>
|
||||||
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
||||||
\t\t\t<MobileApplicationURLs/>
|
\t\t\t<MobileApplicationURLs/>
|
||||||
\t\t\t<AllowedIncomingShareRequestTypes/>
|
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
|
||||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
|
||||||
\t\t\t<DefaultInterface/>
|
\t\t\t<DefaultInterface/>{f221_captions}
|
||||||
\t\t\t<DefaultStyle/>
|
\t\t\t<DefaultStyle/>
|
||||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
\t\t\t<BriefInformation/>
|
\t\t\t<BriefInformation/>
|
||||||
@@ -155,7 +262,7 @@ def main():
|
|||||||
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||||
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
||||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
|
||||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
||||||
\t\t\t<DefaultConstantsForm/>
|
\t\t\t<DefaultConstantsForm/>
|
||||||
@@ -168,7 +275,7 @@ def main():
|
|||||||
|
|
||||||
# --- Languages/Русский.xml ---
|
# --- Languages/Русский.xml ---
|
||||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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<Language uuid="{uuid_lang}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>Русский</Name>
|
\t\t\t<Name>Русский</Name>
|
||||||
@@ -217,11 +324,11 @@ def main():
|
|||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_dir, exist_ok=True)
|
||||||
|
|
||||||
# --- Write files ---
|
# --- Write files ---
|
||||||
write_utf8_bom(cfg_file, cfg_xml)
|
write_xml_file(cfg_file, cfg_xml)
|
||||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||||
write_utf8_bom(lang_file, lang_xml)
|
write_xml_file(lang_file, lang_xml)
|
||||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||||
write_utf8_bom(cai_file, cai_xml)
|
write_xml_file(cai_file, cai_xml)
|
||||||
|
|
||||||
print(f"[OK] Создана конфигурация: {name}")
|
print(f"[OK] Создана конфигурация: {name}")
|
||||||
print(f" Каталог: {output_dir}")
|
print(f" Каталог: {output_dir}")
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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 ---
|
# --- Reference tables ---
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||||
@@ -203,10 +216,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
} elseif ($versionRank -eq 0) {
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
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
|
# Must have Configuration child
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NS = {
|
NS = {
|
||||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||||
@@ -110,6 +132,20 @@ VALID_ENUM_VALUES = {
|
|||||||
|
|
||||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
class Reporter:
|
class Reporter:
|
||||||
def __init__(self, max_errors, detailed=False):
|
def __init__(self, max_errors, detailed=False):
|
||||||
@@ -170,7 +206,7 @@ def main():
|
|||||||
parser.add_argument('-Detailed', action='store_true')
|
parser.add_argument('-Detailed', action='store_true')
|
||||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
config_path = args.ConfigPath
|
config_path = args.ConfigPath
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
@@ -230,10 +266,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get('version', '')
|
version = root.get('version', '')
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
r.warn('1. Missing version attribute on MetaDataObject')
|
r.warn('1. Missing version attribute on MetaDataObject')
|
||||||
elif version not in ('2.17', '2.20', '2.21'):
|
elif version_rank == 0:
|
||||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
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
|
# Must have Configuration child
|
||||||
cfg_node = None
|
cfg_node = None
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ allowed-tools:
|
|||||||
- `Enum.ВидыОплат` — перечисление
|
- `Enum.ВидыОплат` — перечисление
|
||||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||||
Поддерживаются все 44 типа объектов конфигурации.
|
|
||||||
|
|
||||||
### Заимствование форм
|
### Заимствование форм
|
||||||
|
|
||||||
@@ -71,31 +70,33 @@ allowed-tools:
|
|||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Заимствовать один объект
|
# Заимствовать один объект
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||||
|
|
||||||
# Заимствовать форму (автоматически заимствует родительский объект)
|
# Заимствовать форму (автоматически заимствует родительский объект)
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||||
|
|
||||||
# Несколько объектов за раз
|
# Несколько объектов за раз
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||||
|
|
||||||
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
|
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||||
|
|
||||||
# Заимствовать форму с ВСЕМИ реквизитами объекта
|
# Заимствовать форму с ВСЕМИ реквизитами объекта
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
```
|
```
|
||||||
/cfe-validate <ExtensionPath>
|
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# cfe-borrow v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][string]$ExtensionPath,
|
[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
|
# 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
|
# attribute is borrowed into the form's <Attributes>; otherwise it must be stripped or the
|
||||||
# platform rejects the form with "Неверный путь к данным" on load.
|
# 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.
|
# 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.
|
# Strip data-binding tags whose root attribute isn't borrowed.
|
||||||
# $keepObjekt=$true (BorrowMainAttribute): keep Объект.* data bindings, strip the rest.
|
# $mainAttrName задан (BorrowMainAttribute): оставить привязки от его имени, остальные снять.
|
||||||
# $keepObjekt=$false (default skeleton): strip all bindings. Picture-path tags are always stripped.
|
# Пусто (скелет без основного реквизита): снять все. Картиночные пути снимаются всегда.
|
||||||
function Strip-FormBindings {
|
function Strip-FormBindings {
|
||||||
param([string]$xml, [bool]$keepObjekt)
|
param([string]$xml, [string]$mainAttrName)
|
||||||
foreach ($tag in $script:formBindingDataTags) {
|
foreach ($tag in $script:formBindingDataTags) {
|
||||||
if ($keepObjekt) {
|
if ($mainAttrName) {
|
||||||
$xml = [regex]::Replace($xml, "\s*<$tag>(?!Объект\.)[^<]*</$tag>", '')
|
# Оставить и «Список.Поле», и путь ровно на сам реквизит («Список» у таблицы формы)
|
||||||
|
$root = [regex]::Escape($mainAttrName)
|
||||||
|
$xml = [regex]::Replace($xml, "\s*<$tag>(?!$root(\.|<))[^<]*</$tag>", '')
|
||||||
} else {
|
} else {
|
||||||
$xml = [regex]::Replace($xml, "\s*<$tag>[^<]*</$tag>", '')
|
$xml = [regex]::Replace($xml, "\s*<$tag>[^<]*</$tag>", '')
|
||||||
}
|
}
|
||||||
@@ -38,6 +58,119 @@ function Strip-FormBindings {
|
|||||||
return $xml
|
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 ---
|
# --- 1. Resolve paths ---
|
||||||
if (-not [System.IO.Path]::IsPathRooted($ExtensionPath)) {
|
if (-not [System.IO.Path]::IsPathRooted($ExtensionPath)) {
|
||||||
$ExtensionPath = Join-Path (Get-Location).Path $ExtensionPath
|
$ExtensionPath = Join-Path (Get-Location).Path $ExtensionPath
|
||||||
@@ -125,7 +258,7 @@ $childTypeDirMap = @{
|
|||||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"
|
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"
|
||||||
"HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
"HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"
|
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "Bot"="Bots"; "Language"="Languages"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 4b. Russian synonym → English type ---
|
# --- 4b. Russian synonym → English type ---
|
||||||
@@ -153,7 +286,7 @@ $synonymMap = @{
|
|||||||
$script:typeOrder = @(
|
$script:typeOrder = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||||
@@ -210,6 +343,7 @@ $script:generatedTypes = @{
|
|||||||
)
|
)
|
||||||
"AccountingRegister" = @(
|
"AccountingRegister" = @(
|
||||||
@{ prefix = "AccountingRegisterRecord"; category = "Record" }
|
@{ prefix = "AccountingRegisterRecord"; category = "Record" }
|
||||||
|
@{ prefix = "AccountingRegisterExtDimensions"; category = "ExtDimensions" }
|
||||||
@{ prefix = "AccountingRegisterManager"; category = "Manager" }
|
@{ prefix = "AccountingRegisterManager"; category = "Manager" }
|
||||||
@{ prefix = "AccountingRegisterSelection"; category = "Selection" }
|
@{ prefix = "AccountingRegisterSelection"; category = "Selection" }
|
||||||
@{ prefix = "AccountingRegisterList"; category = "List" }
|
@{ prefix = "AccountingRegisterList"; category = "List" }
|
||||||
@@ -223,6 +357,7 @@ $script:generatedTypes = @{
|
|||||||
@{ prefix = "CalculationRegisterList"; category = "List" }
|
@{ prefix = "CalculationRegisterList"; category = "List" }
|
||||||
@{ prefix = "CalculationRegisterRecordSet"; category = "RecordSet" }
|
@{ prefix = "CalculationRegisterRecordSet"; category = "RecordSet" }
|
||||||
@{ prefix = "CalculationRegisterRecordKey"; category = "RecordKey" }
|
@{ prefix = "CalculationRegisterRecordKey"; category = "RecordKey" }
|
||||||
|
@{ prefix = "RecalculationsManager"; category = "Recalcs" }
|
||||||
)
|
)
|
||||||
"ChartOfAccounts" = @(
|
"ChartOfAccounts" = @(
|
||||||
@{ prefix = "ChartOfAccountsObject"; category = "Object" }
|
@{ prefix = "ChartOfAccountsObject"; category = "Object" }
|
||||||
@@ -230,12 +365,15 @@ $script:generatedTypes = @{
|
|||||||
@{ prefix = "ChartOfAccountsSelection"; category = "Selection" }
|
@{ prefix = "ChartOfAccountsSelection"; category = "Selection" }
|
||||||
@{ prefix = "ChartOfAccountsList"; category = "List" }
|
@{ prefix = "ChartOfAccountsList"; category = "List" }
|
||||||
@{ prefix = "ChartOfAccountsManager"; category = "Manager" }
|
@{ prefix = "ChartOfAccountsManager"; category = "Manager" }
|
||||||
|
@{ prefix = "ChartOfAccountsExtDimensionTypes"; category = "ExtDimensionTypes" }
|
||||||
|
@{ prefix = "ChartOfAccountsExtDimensionTypesRow"; category = "ExtDimensionTypesRow" }
|
||||||
)
|
)
|
||||||
"ChartOfCharacteristicTypes" = @(
|
"ChartOfCharacteristicTypes" = @(
|
||||||
@{ prefix = "ChartOfCharacteristicTypesObject"; category = "Object" }
|
@{ prefix = "ChartOfCharacteristicTypesObject"; category = "Object" }
|
||||||
@{ prefix = "ChartOfCharacteristicTypesRef"; category = "Ref" }
|
@{ prefix = "ChartOfCharacteristicTypesRef"; category = "Ref" }
|
||||||
@{ prefix = "ChartOfCharacteristicTypesSelection"; category = "Selection" }
|
@{ prefix = "ChartOfCharacteristicTypesSelection"; category = "Selection" }
|
||||||
@{ prefix = "ChartOfCharacteristicTypesList"; category = "List" }
|
@{ prefix = "ChartOfCharacteristicTypesList"; category = "List" }
|
||||||
|
@{ prefix = "Characteristic"; category = "Characteristic" }
|
||||||
@{ prefix = "ChartOfCharacteristicTypesManager"; category = "Manager" }
|
@{ prefix = "ChartOfCharacteristicTypesManager"; category = "Manager" }
|
||||||
)
|
)
|
||||||
"ChartOfCalculationTypes" = @(
|
"ChartOfCalculationTypes" = @(
|
||||||
@@ -245,8 +383,11 @@ $script:generatedTypes = @{
|
|||||||
@{ prefix = "ChartOfCalculationTypesList"; category = "List" }
|
@{ prefix = "ChartOfCalculationTypesList"; category = "List" }
|
||||||
@{ prefix = "ChartOfCalculationTypesManager"; category = "Manager" }
|
@{ prefix = "ChartOfCalculationTypesManager"; category = "Manager" }
|
||||||
@{ prefix = "DisplacingCalculationTypes"; category = "DisplacingCalculationTypes" }
|
@{ prefix = "DisplacingCalculationTypes"; category = "DisplacingCalculationTypes" }
|
||||||
|
@{ prefix = "DisplacingCalculationTypesRow"; category = "DisplacingCalculationTypesRow" }
|
||||||
@{ prefix = "BaseCalculationTypes"; category = "BaseCalculationTypes" }
|
@{ prefix = "BaseCalculationTypes"; category = "BaseCalculationTypes" }
|
||||||
|
@{ prefix = "BaseCalculationTypesRow"; category = "BaseCalculationTypesRow" }
|
||||||
@{ prefix = "LeadingCalculationTypes"; category = "LeadingCalculationTypes" }
|
@{ prefix = "LeadingCalculationTypes"; category = "LeadingCalculationTypes" }
|
||||||
|
@{ prefix = "LeadingCalculationTypesRow"; category = "LeadingCalculationTypesRow" }
|
||||||
)
|
)
|
||||||
"BusinessProcess" = @(
|
"BusinessProcess" = @(
|
||||||
@{ prefix = "BusinessProcessObject"; category = "Object" }
|
@{ prefix = "BusinessProcessObject"; category = "Object" }
|
||||||
@@ -254,6 +395,7 @@ $script:generatedTypes = @{
|
|||||||
@{ prefix = "BusinessProcessSelection"; category = "Selection" }
|
@{ prefix = "BusinessProcessSelection"; category = "Selection" }
|
||||||
@{ prefix = "BusinessProcessList"; category = "List" }
|
@{ prefix = "BusinessProcessList"; category = "List" }
|
||||||
@{ prefix = "BusinessProcessManager"; category = "Manager" }
|
@{ prefix = "BusinessProcessManager"; category = "Manager" }
|
||||||
|
@{ prefix = "BusinessProcessRoutePointRef"; category = "RoutePointRef" }
|
||||||
)
|
)
|
||||||
"Task" = @(
|
"Task" = @(
|
||||||
@{ prefix = "TaskObject"; category = "Object" }
|
@{ prefix = "TaskObject"; category = "Object" }
|
||||||
@@ -285,19 +427,51 @@ $script:generatedTypes = @{
|
|||||||
"DefinedType" = @(
|
"DefinedType" = @(
|
||||||
@{ prefix = "DefinedType"; category = "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 = @(
|
$typesWithChildObjects = @(
|
||||||
"Catalog","Document","ExchangePlan","ChartOfAccounts",
|
"Catalog","Document","ExchangePlan","ChartOfAccounts",
|
||||||
"ChartOfCharacteristicTypes","ChartOfCalculationTypes",
|
"ChartOfCharacteristicTypes","ChartOfCalculationTypes",
|
||||||
"BusinessProcess","Task","Enum",
|
"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
|
# CommonModule properties to copy from source
|
||||||
$commonModuleProps = @("Global","ClientManagedApplication","Server","ExternalConnection","ClientOrdinaryApplication","ServerCall")
|
$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
|
# Standard system fields to skip when collecting DataPath references
|
||||||
$script:standardFields = @("Code","Description","Ref","Parent","DeletionMark","Predefined","IsFolder","LineNumber","RowsCount","PredefinedDataName")
|
$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) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
$cfgPath = Join-Path $d "Configuration.xml"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$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] }
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
}
|
}
|
||||||
$parent = Split-Path $d -Parent
|
$parent = Split-Path $d -Parent
|
||||||
@@ -363,6 +548,20 @@ $script:formatVersion = Detect-FormatVersion $extDir
|
|||||||
# --- 8. Namespaces declaration for object XML ---
|
# --- 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"'
|
$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 ---
|
# --- 9. Parse -Object into items ---
|
||||||
$items = @()
|
$items = @()
|
||||||
foreach ($part in $Object.Split(";;")) {
|
foreach ($part in $Object.Split(";;")) {
|
||||||
@@ -393,6 +592,37 @@ if ($BorrowMainAttribute) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- 10. Helper: read source object XML ---
|
# --- 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 {
|
function Read-SourceObject {
|
||||||
param([string]$typeName, [string]$objName)
|
param([string]$typeName, [string]$objName)
|
||||||
|
|
||||||
@@ -452,7 +682,23 @@ function Read-SourceObject {
|
|||||||
$srcProps["__TypeXml"] = [regex]::Replace($typeNode.OuterXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
$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 @{
|
return @{
|
||||||
Uuid = $srcUuid
|
Uuid = $srcUuid
|
||||||
@@ -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.
|
# (e.g. a 2.13 form inside a 2.17 extension). The platform itself upgrades the form to the root version.
|
||||||
$formVersion = $script:formatVersion
|
$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
|
$srcAutoCmd = $null
|
||||||
$srcChildItems = $null
|
$srcChildItems = $null
|
||||||
$formProps = @()
|
$formProps = @()
|
||||||
$reachedVisual = $false
|
|
||||||
foreach ($fc in $srcFormEl.ChildNodes) {
|
foreach ($fc in $srcFormEl.ChildNodes) {
|
||||||
if ($fc.NodeType -ne 'Element') { continue }
|
if ($fc.NodeType -ne 'Element') { continue }
|
||||||
if ($fc.LocalName -eq 'AutoCommandBar' -and -not $srcAutoCmd) {
|
if ($fc.LocalName -eq 'AutoCommandBar' -and -not $srcAutoCmd) {
|
||||||
$reachedVisual = $true; $srcAutoCmd = $fc; continue
|
$srcAutoCmd = $fc; continue
|
||||||
}
|
}
|
||||||
if ($fc.LocalName -eq 'ChildItems' -and -not $srcChildItems) {
|
if ($fc.LocalName -eq 'ChildItems' -and -not $srcChildItems) {
|
||||||
$reachedVisual = $true; $srcChildItems = $fc; continue
|
$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 ($script:formStructuralSections -ccontains $fc.LocalName) { continue }
|
||||||
if (-not $reachedVisual) {
|
# Свойства, значение которых — имя реквизита формы. Реквизиты в заимствованную форму не
|
||||||
|
# переносятся, поэтому Конфигуратор такие свойства выбрасывает (проверено на форме отчёта:
|
||||||
|
# ReportResult и DetailsData выброшены, CustomSettingsFolder — имя элемента — сохранён).
|
||||||
|
if ($script:formAttributeRefProps -ccontains $fc.LocalName) { continue }
|
||||||
$formProps += $fc.OuterXml
|
$formProps += $fc.OuterXml
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Get OuterXml and strip redundant namespace redeclarations (they're on root <Form>)
|
# Get OuterXml and strip redundant namespace redeclarations (they're on root <Form>)
|
||||||
$nsStripPattern = '\s+xmlns(?::\w+)?="[^"]*"'
|
$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
|
# AutoCommandBar: keep ChildItems (buttons with CommandName→0), Autofill→false
|
||||||
$autoCmdXml = ""
|
$autoCmdXml = ""
|
||||||
if ($srcAutoCmd) {
|
if ($srcAutoCmd) {
|
||||||
@@ -600,10 +872,13 @@ function Borrow-Form {
|
|||||||
$autoCmdXml = [regex]::Replace($autoCmdXml, $nsStripPattern, '')
|
$autoCmdXml = [regex]::Replace($autoCmdXml, $nsStripPattern, '')
|
||||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
$autoCmdXml = [regex]::Replace($autoCmdXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
||||||
$autoCmdXml = $autoCmdXml -replace '<Autofill>true</Autofill>', '<Autofill>false</Autofill>'
|
$autoCmdXml = $autoCmdXml -replace '<Autofill>true</Autofill>', '<Autofill>false</Autofill>'
|
||||||
# Strip ExcludedCommand (references to standard commands invalid in extension)
|
# Вложенный CommandSet выбрасывается целиком, а не опустошается: Конфигуратор в заимствованной
|
||||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
# форме оставляет только корневой (тот идёт свойством формы, здесь его нет).
|
||||||
|
$autoCmdXml = [regex]::Replace($autoCmdXml, '(?s)\s*<CommandSet>.*?</CommandSet>', '')
|
||||||
|
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<CommandSet/>', '')
|
||||||
# Strip data-binding tags whose root attribute isn't borrowed
|
# 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
|
# 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>')
|
$childItemsXml = [regex]::Replace($childItemsXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
||||||
# Strip data-binding tags whose root attribute isn't borrowed
|
# Strip data-binding tags whose root attribute isn't borrowed
|
||||||
# (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*/RowPicture*)
|
# (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*/RowPicture*)
|
||||||
$childItemsXml = Strip-FormBindings $childItemsXml ([bool]$BorrowMainAttr)
|
$childItemsXml = Strip-FormBindings $childItemsXml $mainAttrName
|
||||||
# Strip ExcludedCommand in nested AutoCommandBars (references to standard commands invalid in extension)
|
$childItemsXml = Rewrite-ChoiceParameterLinks $childItemsXml $srcAttrUuids $formAttrIds $srcMainAttrName ([bool]$mainAttrInfo)
|
||||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
# Вложенные 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)
|
# 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>', '')
|
$childItemsXml = [regex]::Replace($childItemsXml, '(?s)\s*<TypeLink>\s*<xr:DataPath>Items\.[^<]*</xr:DataPath>.*?</TypeLink>', '')
|
||||||
# Strip element-level Events (base form handlers not in extension)
|
# 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"?>'
|
$xmlDecl = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
$formTag = "<Form version=`"${formVersion}`">"
|
$formTag = "<Form version=`"${formVersion}`">"
|
||||||
if ($srcFormContent -match '(?s)^(<\?xml[^?]*\?>)') { $xmlDecl = $Matches[1] }
|
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
|
# Build output Form.xml
|
||||||
$formXmlSb = New-Object System.Text.StringBuilder
|
$formXmlSb = New-Object System.Text.StringBuilder
|
||||||
@@ -823,17 +1111,9 @@ function Borrow-Form {
|
|||||||
$formXmlSb.Append("`r`n") | Out-Null
|
$formXmlSb.Append("`r`n") | Out-Null
|
||||||
}
|
}
|
||||||
# Attributes: empty or with MainAttribute when BorrowMainAttr
|
# Attributes: empty or with MainAttribute when BorrowMainAttr
|
||||||
if ($BorrowMainAttr) {
|
if ($BorrowMainAttr -and $mainAttrInfo) {
|
||||||
$objTypePrefix = ""
|
|
||||||
$gtList = $script:generatedTypes[$typeName]
|
|
||||||
if ($gtList) { foreach ($g in $gtList) { if ($g.category -eq "Object") { $objTypePrefix = $g.prefix; break } } }
|
|
||||||
$mainAttrType = "cfg:${objTypePrefix}.${objName}"
|
|
||||||
$formXmlSb.Append("`t<Attributes>`r`n") | Out-Null
|
$formXmlSb.Append("`t<Attributes>`r`n") | Out-Null
|
||||||
$formXmlSb.Append("`t`t<Attribute name=`"Объект`" id=`"1000001`">`r`n") | Out-Null
|
$formXmlSb.Append("`t`t$($mainAttrInfo.Xml)`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</Attributes>") | Out-Null
|
$formXmlSb.Append("`t</Attributes>") | Out-Null
|
||||||
} else {
|
} else {
|
||||||
$formXmlSb.Append("`t<Attributes/>") | Out-Null
|
$formXmlSb.Append("`t<Attributes/>") | Out-Null
|
||||||
@@ -867,13 +1147,15 @@ function Borrow-Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# BaseForm Attributes: same as main section
|
# 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<Attributes>`r`n") | Out-Null
|
||||||
$formXmlSb.Append("`t`t`t<Attribute name=`"Объект`" id=`"1000001`">`r`n") | Out-Null
|
# В BaseForm та же секция на уровень глубже — приём переиндентации тот же, что у ChildItems
|
||||||
$formXmlSb.Append("`t`t`t`t<Type><v8:Type>${mainAttrType}</v8:Type></Type>`r`n") | Out-Null
|
$maLines = $mainAttrInfo.Xml -split "`r?`n"
|
||||||
$formXmlSb.Append("`t`t`t`t<MainAttribute>true</MainAttribute>`r`n") | Out-Null
|
for ($li = 0; $li -lt $maLines.Count; $li++) {
|
||||||
$formXmlSb.Append("`t`t`t`t<SavedData>true</SavedData>`r`n") | Out-Null
|
if ($li -eq 0) { $formXmlSb.Append("`t`t`t$($maLines[$li])") | Out-Null }
|
||||||
$formXmlSb.Append("`t`t`t</Attribute>`r`n") | Out-Null
|
else { $formXmlSb.Append("`t$($maLines[$li])") | Out-Null }
|
||||||
|
$formXmlSb.Append("`r`n") | Out-Null
|
||||||
|
}
|
||||||
$formXmlSb.Append("`t`t</Attributes>") | Out-Null
|
$formXmlSb.Append("`t`t</Attributes>") | Out-Null
|
||||||
} else {
|
} else {
|
||||||
$formXmlSb.Append("`t`t<Attributes/>") | Out-Null
|
$formXmlSb.Append("`t`t<Attributes/>") | Out-Null
|
||||||
@@ -889,8 +1171,21 @@ function Borrow-Form {
|
|||||||
New-Item -ItemType Directory -Path $formXmlDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $formXmlDir -Force | Out-Null
|
||||||
}
|
}
|
||||||
$formXmlFile = Join-Path $formXmlDir "Form.xml"
|
$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"
|
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
|
# 6. Create empty Module.bsl — but NEVER overwrite an existing one (re-borrow must
|
||||||
# not clobber user code added to the form module).
|
# not clobber user code added to the form module).
|
||||||
@@ -995,8 +1290,16 @@ function Register-FormInObject {
|
|||||||
$text2 = [System.Text.Encoding]::UTF8.GetString($bytes2)
|
$text2 = [System.Text.Encoding]::UTF8.GetString($bytes2)
|
||||||
if ($text2.Length -gt 0 -and $text2[0] -eq [char]0xFEFF) { $text2 = $text2.Substring(1) }
|
if ($text2.Length -gt 0 -and $text2[0] -eq [char]0xFEFF) { $text2 = $text2.Substring(1) }
|
||||||
$text2 = $text2.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$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)
|
$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)
|
[System.IO.File]::WriteAllText($objFile, $text2, $utf8Bom2)
|
||||||
Info " Registered form in: $objFile"
|
Info " Registered form in: $objFile"
|
||||||
}
|
}
|
||||||
@@ -1043,8 +1346,46 @@ function Build-InternalInfoXml {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- 11b. Collect DataPath references from source Form.xml ---
|
# --- 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 {
|
function Collect-FormDataPaths {
|
||||||
param([string]$formXmlPath)
|
param([string]$formXmlPath, [string]$mainAttrName)
|
||||||
|
|
||||||
|
# Корень путей — имя основного реквизита формы: «Объект» у формы объекта, «Список» у формы
|
||||||
|
# списка, «Запись» у формы записи регистра. Зашитый «Объект» не находил ничего у необъектных
|
||||||
|
# форм, и в оболочку не заимствовалось ни одного дочернего объекта.
|
||||||
|
$root = [regex]::Escape($mainAttrName)
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
$content = [System.IO.File]::ReadAllText($formXmlPath, $enc)
|
$content = [System.IO.File]::ReadAllText($formXmlPath, $enc)
|
||||||
@@ -1055,7 +1396,7 @@ function Collect-FormDataPaths {
|
|||||||
# Scan every data-binding tag (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*)
|
# Scan every data-binding tag (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*)
|
||||||
# for Объект.* references — picture-path tags carry picture indices, not data attributes.
|
# for Объект.* references — picture-path tags carry picture indices, not data attributes.
|
||||||
foreach ($tag in $script:formBindingDataTags) {
|
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) {
|
foreach ($m in $bms) {
|
||||||
$path = $m.Groups[1].Value
|
$path = $m.Groups[1].Value
|
||||||
$segments = $path.Split(".")
|
$segments = $path.Split(".")
|
||||||
@@ -1073,7 +1414,7 @@ function Collect-FormDataPaths {
|
|||||||
|
|
||||||
# Also scan <Field>Объект.X</Field> — object attributes referenced by filter/conditional-appearance
|
# 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.
|
# 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) {
|
foreach ($m in $fieldMatches) {
|
||||||
$path = $m.Groups[1].Value
|
$path = $m.Groups[1].Value
|
||||||
$segments = $path.Split(".")
|
$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
|
# Deduplicate deep paths
|
||||||
$seen = @{}
|
$seen = @{}
|
||||||
$uniqueDeep = @()
|
$uniqueDeep = @()
|
||||||
@@ -1137,7 +1502,11 @@ function Resolve-SourceAttributes {
|
|||||||
foreach ($child in $childObjs.ChildNodes) {
|
foreach ($child in $childObjs.ChildNodes) {
|
||||||
if ($child.NodeType -ne 'Element') { continue }
|
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)
|
$nameNode = $child.SelectSingleNode("md:Properties/md:Name", $srcNs)
|
||||||
if (-not $nameNode) { continue }
|
if (-not $nameNode) { continue }
|
||||||
$attrName = $nameNode.InnerText
|
$attrName = $nameNode.InnerText
|
||||||
@@ -1149,7 +1518,7 @@ function Resolve-SourceAttributes {
|
|||||||
# Strip namespace declarations from Type
|
# Strip namespace declarations from Type
|
||||||
$typeXml = [regex]::Replace($typeXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
$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') {
|
elseif ($child.LocalName -eq 'TabularSection') {
|
||||||
$nameNode = $child.SelectSingleNode("md:Properties/md:Name", $srcNs)
|
$nameNode = $child.SelectSingleNode("md:Properties/md:Name", $srcNs)
|
||||||
@@ -1199,8 +1568,16 @@ function Resolve-SourceAttributes {
|
|||||||
$extraProps = [ordered]@{}
|
$extraProps = [ordered]@{}
|
||||||
$propsNode = $srcEl.SelectSingleNode("md:Properties", $srcNs)
|
$propsNode = $srcEl.SelectSingleNode("md:Properties", $srcNs)
|
||||||
if ($propsNode) {
|
if ($propsNode) {
|
||||||
$propsToExtract = @("Hierarchical","FoldersOnTop","CodeLength","DescriptionLength","CodeType","CodeAllowedLength",
|
# NumberPeriodicity сюда НЕ входит: платформа считает его модификацией настроек нумерации и
|
||||||
"NumberType","NumberLength","NumberAllowedLength","NumberPeriodicity")
|
# тогда требует объявить ещё и <Numerator/>, иначе /UpdateDBCfg падает — «отключать
|
||||||
|
# контролируемость свойства "Нумератор" недопустимо». Конфигуратор его не переносит
|
||||||
|
# (эталон заимствования документа: NumberType/NumberLength/NumberAllowedLength и всё).
|
||||||
|
# Загрузку это не ломает, ошибка вылезает только на обновлении конфигурации БД.
|
||||||
|
# FoldersOnTop сюда НЕ входит: платформа его у заимствованной оболочки не хранит — при
|
||||||
|
# загрузке молча выбрасывает (проверено раундтрипом: записали, выгрузили обратно, свойства
|
||||||
|
# нет). Конфигуратор его тоже не переносит. Остальные из списка сохраняются.
|
||||||
|
$propsToExtract = @("Hierarchical","CodeLength","DescriptionLength","CodeType","CodeAllowedLength",
|
||||||
|
"NumberType","NumberLength","NumberAllowedLength")
|
||||||
foreach ($pName in $propsToExtract) {
|
foreach ($pName in $propsToExtract) {
|
||||||
$pNode = $propsNode.SelectSingleNode("md:${pName}", $srcNs)
|
$pNode = $propsNode.SelectSingleNode("md:${pName}", $srcNs)
|
||||||
if ($pNode) { $extraProps[$pName] = $pNode.InnerText }
|
if ($pNode) { $extraProps[$pName] = $pNode.InnerText }
|
||||||
@@ -1212,11 +1589,11 @@ function Resolve-SourceAttributes {
|
|||||||
|
|
||||||
# --- 11d. Build adopted attribute XML ---
|
# --- 11d. Build adopted attribute XML ---
|
||||||
function Build-AdoptedAttributeXml {
|
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()
|
$newUuid = [guid]::NewGuid().ToString()
|
||||||
$sb = New-Object System.Text.StringBuilder
|
$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<InternalInfo/>") | Out-Null
|
||||||
$sb.AppendLine("${indent}`t<Properties>") | Out-Null
|
$sb.AppendLine("${indent}`t<Properties>") | Out-Null
|
||||||
$sb.AppendLine("${indent}`t`t<ObjectBelonging>Adopted</ObjectBelonging>") | 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<ExtendedConfigurationObject>${sourceUuid}</ExtendedConfigurationObject>") | Out-Null
|
||||||
$sb.AppendLine("${indent}`t`t${typeXml}") | Out-Null
|
$sb.AppendLine("${indent}`t`t${typeXml}") | Out-Null
|
||||||
$sb.AppendLine("${indent}`t</Properties>") | Out-Null
|
$sb.AppendLine("${indent}`t</Properties>") | Out-Null
|
||||||
$sb.Append("${indent}</Attribute>") | Out-Null
|
$sb.Append("${indent}</${kind}>") | Out-Null
|
||||||
return $sb.ToString()
|
return $sb.ToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1348,14 +1725,6 @@ function Merge-AttributesIntoObject {
|
|||||||
$added = 0
|
$added = 0
|
||||||
foreach ($attr in $attrsToAdd) {
|
foreach ($attr in $attrsToAdd) {
|
||||||
if ($existingNames.ContainsKey($attr.Name)) { continue }
|
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++
|
$added++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,7 +1733,8 @@ function Merge-AttributesIntoObject {
|
|||||||
$allAttrXml = ""
|
$allAttrXml = ""
|
||||||
foreach ($attr in $attrsToAdd) {
|
foreach ($attr in $attrsToAdd) {
|
||||||
if ($existingNames.ContainsKey($attr.Name)) { continue }
|
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
|
# 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) }
|
if ($text3.Length -gt 0 -and $text3[0] -eq [char]0xFEFF) { $text3 = $text3.Substring(1) }
|
||||||
$text3 = $text3.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$text3 = $text3.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
|
||||||
# Insert attributes before </ChildObjects>
|
# Самозакрытый элемент раскрывается текстом, а не пробельным узлом в DOM: тот давал
|
||||||
$text3 = $text3 -replace '</ChildObjects>', "${allAttrXml}`r`n`t`t</ChildObjects>"
|
# лишнюю строку с табуляцией перед первым <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)
|
$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)
|
[System.IO.File]::WriteAllText($objFile, $text3, $utf8Bom3)
|
||||||
Info " Merged $added attribute(s) into: $objFile"
|
Info " Merged $added attribute(s) into: $objFile"
|
||||||
}
|
}
|
||||||
@@ -1407,7 +1788,16 @@ function Borrow-MainAttribute {
|
|||||||
Write-Error "Source Form.xml not found: $srcFormXmlPath"
|
Write-Error "Source Form.xml not found: $srcFormXmlPath"
|
||||||
exit 1
|
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
|
$firstLevelNames = $dp.FirstLevel
|
||||||
$deepPaths = $dp.DeepPaths
|
$deepPaths = $dp.DeepPaths
|
||||||
Info " Collected $($firstLevelNames.Count) first-level DataPath references, $($deepPaths.Count) deep paths"
|
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)))
|
$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)
|
# Dedup: skip attributes/TS already present in object's ChildObjects (idempotent re-borrow)
|
||||||
$existingChildNames = @{}
|
$existingChildNames = Get-OwnChildObjectNames $objFile
|
||||||
if ($objContent -match '(?s)<ChildObjects>(.*?)</ChildObjects>') {
|
|
||||||
foreach ($nm in [regex]::Matches($Matches[1], '<Name>(\w+)</Name>')) {
|
|
||||||
$existingChildNames[$nm.Groups[1].Value] = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$insertAttrs = @($srcAttrs | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
$insertAttrs = @($srcAttrs | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||||
$insertTS = @($srcTS | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
$insertTS = @($srcTS | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||||
|
|
||||||
# Generate full object XML with attributes and TS
|
# Generate full object XML with attributes and TS
|
||||||
$contentSb = New-Object System.Text.StringBuilder
|
$contentSb = New-Object System.Text.StringBuilder
|
||||||
foreach ($attr in $insertAttrs) {
|
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
|
$contentSb.AppendLine($attrXml) | Out-Null
|
||||||
}
|
}
|
||||||
foreach ($ts in $insertTS) {
|
foreach ($ts in $insertTS) {
|
||||||
@@ -1470,17 +1856,9 @@ function Borrow-MainAttribute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Replace empty ChildObjects with adopted content
|
# Добавить заимствованное содержимое в ChildObjects объекта (там уже может лежать <Form>)
|
||||||
if ($adoptedContent) {
|
if ($adoptedContent) {
|
||||||
# Handle <ChildObjects/> (self-closing)
|
$objContent = Insert-IntoOwnChildObjects $objContent "`r`n${adoptedContent}"
|
||||||
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>"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||||
@@ -1493,6 +1871,21 @@ function Borrow-MainAttribute {
|
|||||||
foreach ($ts in $srcTS) {
|
foreach ($ts in $srcTS) {
|
||||||
foreach ($tsa in $ts.Attributes) { $allTypeXmls += $tsa.TypeXml }
|
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
|
$refTypes = Collect-ReferenceTypes $allTypeXmls
|
||||||
Info " Reference types to borrow: $($refTypes.Count)"
|
Info " Reference types to borrow: $($refTypes.Count)"
|
||||||
|
|
||||||
@@ -1666,10 +2059,20 @@ function Build-BorrowedObjectXml {
|
|||||||
$sb.AppendLine("`t`t`t$($sourceProps['__TypeXml'])") | Out-Null
|
$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
|
$sb.AppendLine("`t`t</Properties>") | Out-Null
|
||||||
|
|
||||||
# ChildObjects (for types that need it)
|
# ChildObjects (for types that need it)
|
||||||
if ($typesWithChildObjects -contains $typeName) {
|
if ($sourceProps["__HasChildObjects"] -or ($typesWithChildObjects -contains $typeName)) {
|
||||||
$sb.AppendLine("`t`t<ChildObjects/>") | Out-Null
|
$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 ---
|
# --- 15. Save modified Configuration.xml ---
|
||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||||
@@ -1849,8 +2293,16 @@ $memStream.Close()
|
|||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $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)
|
[System.IO.File]::WriteAllText($extResolvedPath, $text, $utf8Bom)
|
||||||
Info "Saved: $extResolvedPath"
|
Info "Saved: $extResolvedPath"
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ allowed-tools:
|
|||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||||
```
|
```
|
||||||
|
|
||||||
## Mode A — обзор расширения
|
## Mode A — обзор расширения
|
||||||
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Обзор — что изменено в расширении
|
# Обзор — что изменено в расширении
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||||
|
|
||||||
# Проверка переноса — все ли #Вставка перенесены
|
# Проверка переноса — все ли #Вставка перенесены
|
||||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -49,7 +49,9 @@ $childTypeDirMap = @{
|
|||||||
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
||||||
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
||||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||||
"CommonAttribute"="CommonAttributes"
|
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
|
||||||
|
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
|
"Bot"="Bots"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Parse extension Configuration.xml ---
|
# --- Parse extension Configuration.xml ---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
# cfe-diff v1.2 — Analyze and compare 1C configuration extension (CFE) (+тип Bot; cfe-diff/cfe-borrow: недостающие типы)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -8,6 +8,28 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- Namespace maps ---
|
# --- Namespace maps ---
|
||||||
|
|
||||||
MD_NSMAP = {
|
MD_NSMAP = {
|
||||||
@@ -60,6 +82,12 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
"Sequence": "Sequences",
|
"Sequence": "Sequences",
|
||||||
"IntegrationService": "IntegrationServices",
|
"IntegrationService": "IntegrationServices",
|
||||||
"CommonAttribute": "CommonAttributes",
|
"CommonAttribute": "CommonAttributes",
|
||||||
|
"Style": "Styles",
|
||||||
|
"XDTOPackage": "XDTOPackages",
|
||||||
|
"WebService": "WebServices",
|
||||||
|
"HTTPService": "HTTPServices",
|
||||||
|
"WSReference": "WSReferences",
|
||||||
|
"Bot": "Bots",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +496,7 @@ def main():
|
|||||||
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
||||||
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
||||||
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
extension_path = args.ExtensionPath
|
extension_path = args.ExtensionPath
|
||||||
config_path = args.ConfigPath
|
config_path = args.ConfigPath
|
||||||
|
|||||||
@@ -33,39 +33,39 @@ allowed-tools:
|
|||||||
| `Name` | Имя расширения (обязат.) | — |
|
| `Name` | Имя расширения (обязат.) | — |
|
||||||
| `Synonym` | Синоним | = Name |
|
| `Synonym` | Синоним | = Name |
|
||||||
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
||||||
| `OutputDir` | Каталог для создания | `src` |
|
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
|
||||||
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
||||||
| `Version` | Версия расширения | — |
|
| `Version` | Версия расширения | — |
|
||||||
| `Vendor` | Поставщик | — |
|
| `Vendor` | Поставщик | — |
|
||||||
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` |
|
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
|
||||||
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
||||||
| `NoRole` | Без основной роли | false |
|
| `NoRole` | Без основной роли | false |
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
||||||
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src
|
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
|
||||||
|
|
||||||
# Расширение-исправление с явным режимом совместимости
|
# Расширение-исправление с явным режимом совместимости
|
||||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src
|
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
|
||||||
|
|
||||||
# Расширение-доработка с версией
|
# Расширение-доработка с версией
|
||||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src
|
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
|
||||||
|
|
||||||
# Без роли, с явным префиксом
|
# Без роли, с явным префиксом
|
||||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src
|
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
|
||||||
```
|
```
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|
||||||
```
|
```
|
||||||
/cfe-validate <OutputDir>
|
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -16,6 +16,12 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
# --- Default NamePrefix ---
|
# --- Default NamePrefix ---
|
||||||
@@ -121,20 +127,23 @@ $co7 = [guid]::NewGuid().ToString()
|
|||||||
# --- Synonym XML ---
|
# --- Synonym XML ---
|
||||||
$synonymXml = ""
|
$synonymXml = ""
|
||||||
if ($Synonym) {
|
if ($Synonym) {
|
||||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Optional properties ---
|
# --- Optional properties ---
|
||||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||||
|
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||||
|
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||||
|
|
||||||
# --- Role name ---
|
# --- Role name ---
|
||||||
$roleName = "${NamePrefix}ОсновнаяРоль"
|
$roleName = "${NamePrefix}ОсновнаяРоль"
|
||||||
|
|
||||||
# --- DefaultRoles XML ---
|
# --- DefaultRoles XML ---
|
||||||
$defaultRolesXml = ""
|
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||||
|
$defaultRolesEl = "<DefaultRoles/>"
|
||||||
if (-not $NoRole) {
|
if (-not $NoRole) {
|
||||||
$defaultRolesXml = "`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t"
|
$defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- ChildObjects ---
|
# --- ChildObjects ---
|
||||||
@@ -144,10 +153,32 @@ if (-not $NoRole) {
|
|||||||
}
|
}
|
||||||
$childObjectsXml += "`r`n`t`t"
|
$childObjectsXml += "`r`n`t`t"
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||||
|
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||||
|
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
|
||||||
|
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||||
|
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||||
|
# дописать в конец нельзя.
|
||||||
|
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||||
|
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||||
|
$f221Captions = ""
|
||||||
|
if ((Get-FormatRank $formatVersion) -ge 221) {
|
||||||
|
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Configuration.xml ---
|
# --- Configuration.xml ---
|
||||||
$cfgXml = @"
|
$cfgXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||||
<Configuration uuid="$uuidCfg">
|
<Configuration uuid="$uuidCfg">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -181,21 +212,21 @@ $cfgXml = @"
|
|||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<ObjectBelonging>Adopted</ObjectBelonging>
|
<ObjectBelonging>Adopted</ObjectBelonging>
|
||||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
<Name>$(Esc-XmlText ($Name))</Name>
|
||||||
<Synonym>$synonymXml</Synonym>
|
<Synonym>$synonymXml</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
|
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
|
||||||
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||||
<NamePrefix>$([System.Security.SecurityElement]::Escape($NamePrefix))</NamePrefix>
|
<NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
|
||||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
<UsePurposes>
|
<UsePurposes>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
</UsePurposes>
|
</UsePurposes>
|
||||||
<ScriptVariant>Russian</ScriptVariant>
|
<ScriptVariant>Russian</ScriptVariant>
|
||||||
<DefaultRoles>$defaultRolesXml</DefaultRoles>
|
$defaultRolesEl
|
||||||
<Vendor>$vendorXml</Vendor>
|
$vendorEl
|
||||||
<Version>$versionXml</Version>
|
$versionEl$f221Captions
|
||||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
<BriefInformation/>
|
<BriefInformation/>
|
||||||
<DetailedInformation/>
|
<DetailedInformation/>
|
||||||
@@ -212,7 +243,7 @@ $cfgXml = @"
|
|||||||
# --- Languages/Русский.xml (adopted format) ---
|
# --- Languages/Русский.xml (adopted format) ---
|
||||||
$langXml = @"
|
$langXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||||
<Language uuid="$uuidLang">
|
<Language uuid="$uuidLang">
|
||||||
<InternalInfo/>
|
<InternalInfo/>
|
||||||
<Properties>
|
<Properties>
|
||||||
@@ -229,10 +260,10 @@ $langXml = @"
|
|||||||
# --- Role XML ---
|
# --- Role XML ---
|
||||||
$roleXml = @"
|
$roleXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||||
<Role uuid="$uuidRole">
|
<Role uuid="$uuidRole">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name>
|
<Name>$(Esc-XmlText ($roleName))</Name>
|
||||||
<Synonym/>
|
<Synonym/>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
</Properties>
|
</Properties>
|
||||||
@@ -252,9 +283,18 @@ if (-not (Test-Path $langDir)) {
|
|||||||
# --- Write files with UTF-8 BOM ---
|
# --- Write files with UTF-8 BOM ---
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile $cfgFile $cfgXml $enc
|
||||||
$langFile = Join-Path $langDir "Русский.xml"
|
$langFile = Join-Path $langDir "Русский.xml"
|
||||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
Write-XmlFile $langFile $langXml $enc
|
||||||
|
|
||||||
# --- Role ---
|
# --- Role ---
|
||||||
if (-not $NoRole) {
|
if (-not $NoRole) {
|
||||||
@@ -263,7 +303,7 @@ if (-not $NoRole) {
|
|||||||
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
|
||||||
}
|
}
|
||||||
$roleFile = Join-Path $roleDir "$roleName.xml"
|
$roleFile = Join-Path $roleDir "$roleName.xml"
|
||||||
[System.IO.File]::WriteAllText($roleFile, $roleXml, $enc)
|
Write-XmlFile $roleFile $roleXml $enc
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
|
|||||||
@@ -1,20 +1,62 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
# cfe-init v1.10 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Generates minimal XML source files for a 1C configuration extension."""
|
"""Generates minimal XML source files for a 1C configuration extension."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, re, argparse, uuid
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
def esc_xml(s):
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
def new_uuid():
|
def new_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
def write_utf8_bom(path, content):
|
def write_utf8_bom(path, content):
|
||||||
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -29,7 +71,7 @@ def main():
|
|||||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
|
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
|
||||||
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
|
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
name = args.Name
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -126,18 +168,23 @@ def main():
|
|||||||
# --- Synonym XML ---
|
# --- Synonym XML ---
|
||||||
synonym_xml = ""
|
synonym_xml = ""
|
||||||
if synonym:
|
if synonym:
|
||||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||||
|
|
||||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||||
version_xml = esc_xml(version) if version else ""
|
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||||
|
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||||
|
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||||
|
|
||||||
# --- Role name ---
|
# --- Role name ---
|
||||||
role_name = f"{name_prefix}ОсновнаяРоль"
|
role_name = f"{name_prefix}ОсновнаяРоль"
|
||||||
|
|
||||||
# --- DefaultRoles XML ---
|
# --- DefaultRoles XML ---
|
||||||
default_roles_xml = ""
|
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||||
|
default_roles_el = "<DefaultRoles/>"
|
||||||
if not args.NoRole:
|
if not args.NoRole:
|
||||||
default_roles_xml = f'\r\n\t\t\t\t<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>\r\n\t\t\t'
|
default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
|
||||||
|
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
|
||||||
|
'\r\n\t\t\t</DefaultRoles>')
|
||||||
|
|
||||||
# --- ChildObjects ---
|
# --- ChildObjects ---
|
||||||
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
|
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
|
||||||
@@ -156,6 +203,40 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
contained_objects = ""
|
contained_objects = ""
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||||
|
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||||
|
xmlns_decl = (
|
||||||
|
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||||
|
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||||
|
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||||
|
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
|
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||||
|
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||||
|
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||||
|
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||||
|
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||||
|
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||||
|
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||||
|
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||||
|
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||||
|
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||||
|
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||||
|
# дописать в конец нельзя.
|
||||||
|
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||||
|
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||||
|
f221_captions = ""
|
||||||
|
if format_rank(format_version) >= 221:
|
||||||
|
xmlns_decl = xmlns_decl.replace(
|
||||||
|
' xmlns:style=',
|
||||||
|
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||||
|
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||||
@@ -163,27 +244,27 @@ def main():
|
|||||||
\t\t\t</xr:ContainedObject>\n"""
|
\t\t\t</xr:ContainedObject>\n"""
|
||||||
|
|
||||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<Configuration uuid="{uuid_cfg}">
|
\t<Configuration uuid="{uuid_cfg}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
{contained_objects}\t\t</InternalInfo>
|
{contained_objects}\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
|
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
|
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
|
||||||
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||||
\t\t\t<NamePrefix>{esc_xml(name_prefix)}</NamePrefix>
|
\t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
|
||||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
\t\t\t<UsePurposes>
|
\t\t\t<UsePurposes>
|
||||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
\t\t\t</UsePurposes>
|
\t\t\t</UsePurposes>
|
||||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||||
\t\t\t<DefaultRoles>{default_roles_xml}</DefaultRoles>
|
\t\t\t{default_roles_el}
|
||||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
\t\t\t{vendor_el}
|
||||||
\t\t\t<Version>{version_xml}</Version>
|
\t\t\t{version_el}{f221_captions}
|
||||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||||
\t\t\t<BriefInformation/>
|
\t\t\t<BriefInformation/>
|
||||||
\t\t\t<DetailedInformation/>
|
\t\t\t<DetailedInformation/>
|
||||||
@@ -198,7 +279,7 @@ def main():
|
|||||||
|
|
||||||
# --- Languages/Русский.xml (adopted format) ---
|
# --- Languages/Русский.xml (adopted format) ---
|
||||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<Language uuid="{uuid_lang}">
|
\t<Language uuid="{uuid_lang}">
|
||||||
\t\t<InternalInfo/>
|
\t\t<InternalInfo/>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
@@ -213,10 +294,10 @@ def main():
|
|||||||
|
|
||||||
# --- Role XML ---
|
# --- Role XML ---
|
||||||
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<Role uuid="{uuid_role}">
|
\t<Role uuid="{uuid_role}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(role_name)}</Name>
|
\t\t\t<Name>{esc_xml_text(role_name)}</Name>
|
||||||
\t\t\t<Synonym/>
|
\t\t\t<Synonym/>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
\t\t</Properties>
|
\t\t</Properties>
|
||||||
@@ -229,9 +310,9 @@ def main():
|
|||||||
os.makedirs(lang_dir, exist_ok=True)
|
os.makedirs(lang_dir, exist_ok=True)
|
||||||
|
|
||||||
# --- Write files ---
|
# --- Write files ---
|
||||||
write_utf8_bom(cfg_file, cfg_xml)
|
write_xml_file(cfg_file, cfg_xml)
|
||||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||||
write_utf8_bom(lang_file, lang_xml)
|
write_xml_file(lang_file, lang_xml)
|
||||||
|
|
||||||
# --- Role ---
|
# --- Role ---
|
||||||
role_file = None
|
role_file = None
|
||||||
@@ -239,7 +320,7 @@ def main():
|
|||||||
role_dir = os.path.join(output_dir, "Roles")
|
role_dir = os.path.join(output_dir, "Roles")
|
||||||
os.makedirs(role_dir, exist_ok=True)
|
os.makedirs(role_dir, exist_ok=True)
|
||||||
role_file = os.path.join(role_dir, f"{role_name}.xml")
|
role_file = os.path.join(role_dir, f"{role_name}.xml")
|
||||||
write_utf8_bom(role_file, role_xml)
|
write_xml_file(role_file, role_xml)
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
print(f"[OK] Создано расширение: {name}")
|
print(f"[OK] Создано расширение: {name}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: cfe-patch-method
|
name: cfe-patch-method
|
||||||
description: Генерация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после или вместо оригинального
|
description: Генерация и актуализация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после, вместо оригинала, изменить его тело (ИзменениеИКонтроль) — или актуализировать перехватчик после изменения оригинала
|
||||||
argument-hint: -ExtensionPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
argument-hint: -ExtensionPath <path> -ConfigPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
@@ -10,22 +10,31 @@ allowed-tools:
|
|||||||
|
|
||||||
# /cfe-patch-method — Генерация перехватчика метода
|
# /cfe-patch-method — Генерация перехватчика метода
|
||||||
|
|
||||||
Генерирует `.bsl` файл с декоратором перехвата для заимствованного объекта расширения. Создаёт файл или дописывает в существующий.
|
Читает оригинал метода из конфигурации-источника и генерирует `.bsl`-перехватчик заимствованного объекта: с правильной директивой контекста, полной сигнатурой, обрамляющими инструкциями препроцессора и областями. Для `ИзменениеИКонтроль` копирует всё тело оригинала. Создаёт файл модуля, дописывает в существующий или актуализирует уже заимствованный метод.
|
||||||
|
|
||||||
## Предусловие
|
## Предусловие
|
||||||
|
|
||||||
Объект должен быть заимствован в расширение (`/cfe-borrow`). Скрипт читает `NamePrefix` из `Configuration.xml` расширения для формирования имени процедуры.
|
Объект должен быть заимствован в расширение (`/cfe-borrow`). Нужен доступ к исходникам базовой конфигурации (`-ConfigPath`) — оттуда читается оригинал метода. `NamePrefix` берётся из `Configuration.xml` расширения.
|
||||||
|
|
||||||
|
### Авто-определение ConfigPath
|
||||||
|
|
||||||
|
Если пользователь не указал `-ConfigPath` — попробуй определить автоматически:
|
||||||
|
1. Прочитай `.v8-project.json` из корня проекта
|
||||||
|
2. Разреши целевую базу (по имени, ветке или `default` — алгоритм из `/db-list`)
|
||||||
|
3. Если у базы есть поле `configSrc` — используй как `-ConfigPath`
|
||||||
|
4. Если `configSrc` нет — спроси у пользователя
|
||||||
|
|
||||||
## Параметры
|
## Параметры
|
||||||
|
|
||||||
| Параметр | Описание | По умолчанию |
|
| Параметр | Описание | По умолчанию |
|
||||||
|----------|----------|--------------|
|
|----------|----------|--------------|
|
||||||
| `ExtensionPath` | Путь к расширению (обязат.) | — |
|
| `ExtensionPath` | Путь к расширению (обязат.) | — |
|
||||||
| `ModulePath` | Путь к модулю (обязат.) | — |
|
| `ConfigPath` | Путь к конфигурации-источнику | обязат., кроме случая, когда `ModulePath` — путь к файлу |
|
||||||
| `MethodName` | Имя перехватываемого метода (обязат.) | — |
|
| `ModulePath` | Логическое имя (`Тип.Имя.Модуль`) **или** путь к файлу модуля `.bsl` | обязат. для генерации |
|
||||||
| `InterceptorType` | `Before` / `After` / `ModificationAndControl` (обязат.) | — |
|
| `MethodName` | Имя перехватываемого метода | обязат. для генерации |
|
||||||
| `Context` | Директива контекста | `НаСервере` |
|
| `InterceptorType` | `Before` / `After` / `Instead` / `ModificationAndControl` | обязат. для генерации |
|
||||||
| `IsFunction` | Метод — функция (добавит `Возврат`) | false |
|
| `Check` | Проверить контролируемые методы на дрейф (только отчёт) | — |
|
||||||
|
| `Actualize` | Актуализировать дрейфнувшие контролируемые методы | — |
|
||||||
|
|
||||||
## Формат ModulePath
|
## Формат ModulePath
|
||||||
|
|
||||||
@@ -40,39 +49,97 @@ allowed-tools:
|
|||||||
|
|
||||||
Аналогично для Report, DataProcessor, InformationRegister и других типов.
|
Аналогично для Report, DataProcessor, InformationRegister и других типов.
|
||||||
|
|
||||||
|
Вместо логического имени в `ModulePath` можно передать **путь к файлу** модуля-источника `.bsl` — тогда `-ConfigPath` указывать не нужно (оригинал читается прямо из этого файла). Путь модуля расширения определяется от типовой папки в пути автоматически.
|
||||||
|
|
||||||
## Типы перехвата
|
## Типы перехвата
|
||||||
|
|
||||||
| InterceptorType | Декоратор | Назначение |
|
| InterceptorType | Декоратор | Назначение | Применим к |
|
||||||
|-----------------|-----------|------------|
|
|-----------------|-----------|------------|------------|
|
||||||
| `Before` | `&Перед` | Код до вызова оригинального метода |
|
| `Before` | `&Перед` | Код до вызова оригинального метода | процедуры |
|
||||||
| `After` | `&После` | Код после вызова оригинального метода |
|
| `After` | `&После` | Код после вызова оригинального метода | процедуры |
|
||||||
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела метода с маркерами `#Вставка`/`#Удаление` |
|
| `Instead` | `&Вместо` | Замена метода; в теле — скаффолд `ПродолжитьВызов(...)` | процедуры и функции |
|
||||||
|
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела оригинала для правки маркерами `#Вставка`/`#Удаление` | процедуры и функции |
|
||||||
|
|
||||||
|
## Маркеры `#Вставка` / `#Удаление` (для `ModificationAndControl`)
|
||||||
|
|
||||||
|
`&ИзменениеИКонтроль` вставляет в перехватчик **копию тела оригинала**. Дальше отредактируй тело под свою доработку, **помечая каждое изменение** — платформа так отличает твою правку от неизменного оригинала:
|
||||||
|
|
||||||
|
- **Добавляешь код** → оберни его `#Вставка` … `#КонецВставки`.
|
||||||
|
- **Удаляешь код оригинала** → оберни удаляемые строки `#Удаление` … `#КонецУдаления`, но сами строки **оставь между маркерами** (платформа сверяет их с оригиналом).
|
||||||
|
- **Заменяешь** → `#Удаление` старое `#КонецУдаления` сразу за ним `#Вставка` новое `#КонецВставки`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
```bsl
|
||||||
|
&ИзменениеИКонтроль("ПриЗаписи")
|
||||||
|
Процедура Расш_ПриЗаписи(Отказ)
|
||||||
|
СуммаДокумента = РассчитатьСумму();
|
||||||
|
#Вставка
|
||||||
|
// доработка: округляем
|
||||||
|
СуммаДокумента = Окр(СуммаДокумента, 2);
|
||||||
|
#КонецВставки
|
||||||
|
#Удаление
|
||||||
|
Записать();
|
||||||
|
#КонецУдаления
|
||||||
|
#Вставка
|
||||||
|
ЗаписатьСПроверкой(Отказ);
|
||||||
|
#КонецВставки
|
||||||
|
КонецПроцедуры
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
|
||||||
|
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
|
||||||
|
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
|
||||||
|
|
||||||
|
## Актуализация
|
||||||
|
|
||||||
|
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
|
||||||
|
|
||||||
|
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
|
||||||
|
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
|
||||||
|
|
||||||
|
Статусы в выводе:
|
||||||
|
|
||||||
|
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
|
||||||
|
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
|
||||||
|
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
|
||||||
|
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
|
||||||
|
|
||||||
|
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||||
```
|
```
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Перехват &Перед на сервере
|
# Код перед записью
|
||||||
... -ExtensionPath src -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||||
|
|
||||||
# Перехват &После на клиенте
|
# Перехват После на форме
|
||||||
... -ExtensionPath src -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After -Context "НаКлиенте"
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||||
|
|
||||||
# ИзменениеИКонтроль для функции
|
# Замена функции (ПродолжитьВызов)
|
||||||
... -ExtensionPath src -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType ModificationAndControl -IsFunction
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||||
|
|
||||||
|
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||||
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||||
|
|
||||||
|
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||||
|
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||||
|
|
||||||
|
# Проверить все контролируемые методы расширения на дрейф
|
||||||
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||||
|
|
||||||
|
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||||
|
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||||
```
|
```
|
||||||
|
|
||||||
## Генерируемый код (Before)
|
## Верификация
|
||||||
|
|
||||||
```bsl
|
```
|
||||||
&НаСервере
|
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||||
&Перед("ПриЗаписи")
|
|
||||||
Процедура Расш1_ПриЗаписи()
|
|
||||||
// TODO: код перед вызовом оригинального метода
|
|
||||||
КонецПроцедуры
|
|
||||||
```
|
```
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: cfe-validate
|
name: cfe-validate
|
||||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||||
argument-hint: <ExtensionPath> [-Detailed] [-MaxErrors 30]
|
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash
|
- Bash
|
||||||
- Read
|
- Read
|
||||||
@@ -17,13 +17,24 @@ allowed-tools:
|
|||||||
| Параметр | Обяз. | Умолч. | Описание |
|
| Параметр | Обяз. | Умолч. | Описание |
|
||||||
|---------------|:-----:|---------|-------------------------------------------------|
|
|---------------|:-----:|---------|-------------------------------------------------|
|
||||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||||
|
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||||
| OutFile | нет | — | Записать результат в файл |
|
| OutFile | нет | — | Записать результат в файл |
|
||||||
|
|
||||||
|
### ConfigPath
|
||||||
|
|
||||||
|
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||||
|
|
||||||
|
Если пользователь не указал путь — определи сам:
|
||||||
|
1. Прочитай `.v8-project.json` из корня проекта
|
||||||
|
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||||
|
3. Возьми её поле `configSrc`
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src/Configuration.xml"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -9,7 +9,11 @@ param(
|
|||||||
|
|
||||||
[int]$MaxErrors = 30,
|
[int]$MaxErrors = 30,
|
||||||
|
|
||||||
[string]$OutFile
|
[string]$OutFile,
|
||||||
|
|
||||||
|
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||||
|
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||||
|
[string]$ConfigPath
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$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 ---
|
# --- Reference tables ---
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||||
@@ -108,7 +125,7 @@ $validClassIds = @(
|
|||||||
$childObjectTypes = @(
|
$childObjectTypes = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||||
@@ -122,7 +139,7 @@ $childObjectTypes = @(
|
|||||||
|
|
||||||
# Type -> directory mapping
|
# Type -> directory mapping
|
||||||
$childTypeDirMap = @{
|
$childTypeDirMap = @{
|
||||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"
|
||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||||
@@ -144,6 +161,46 @@ $childTypeDirMap = @{
|
|||||||
"IntegrationService"="IntegrationServices"
|
"IntegrationService"="IntegrationServices"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||||
|
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||||
|
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||||
|
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||||
|
$generatedTypeCategories = @{
|
||||||
|
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Document" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Enum" = @("Ref","Manager","List")
|
||||||
|
"Constant" = @("Manager","ValueManager","ValueKey")
|
||||||
|
"Report" = @("Object","Manager")
|
||||||
|
"DataProcessor" = @("Object","Manager")
|
||||||
|
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"Task" = @("Object","Ref","Selection","List","Manager")
|
||||||
|
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
|
||||||
|
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
|
||||||
|
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
|
||||||
|
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
|
||||||
|
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
|
||||||
|
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
|
||||||
|
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
|
||||||
|
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
|
||||||
|
"DocumentJournal" = @("Selection","List","Manager")
|
||||||
|
"Sequence" = @("Record","Manager","RecordSet")
|
||||||
|
"FilterCriterion" = @("Manager","List")
|
||||||
|
"SettingsStorage" = @("Manager")
|
||||||
|
"IntegrationService" = @("Manager")
|
||||||
|
"WSReference" = @("Manager")
|
||||||
|
"DefinedType" = @("DefinedType")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||||
|
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||||
|
$script:standardObjectFields = @(
|
||||||
|
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
|
||||||
|
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
|
||||||
|
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
|
||||||
|
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
|
||||||
|
"Движения","ВерсияДанных","КоличествоСтрок"
|
||||||
|
)
|
||||||
|
|
||||||
# Valid enum values for extension properties
|
# Valid enum values for extension properties
|
||||||
$validEnumValues = @{
|
$validEnumValues = @{
|
||||||
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
||||||
@@ -195,10 +252,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
} elseif ($versionRank -eq 0) {
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
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
|
# Must have Configuration child
|
||||||
@@ -536,6 +598,7 @@ if ($script:stopped) { & $finalize; exit 1 }
|
|||||||
|
|
||||||
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
||||||
$script:enumValuesIndex = @{}
|
$script:enumValuesIndex = @{}
|
||||||
|
$script:borrowedTSIndex = @{}
|
||||||
$script:formList = @()
|
$script:formList = @()
|
||||||
|
|
||||||
# Helper: check if sub-item has explicit borrowed metadata
|
# Helper: check if sub-item has explicit borrowed metadata
|
||||||
@@ -639,6 +702,25 @@ if ($childObjNode) {
|
|||||||
} else {
|
} else {
|
||||||
$borrowedOk++
|
$borrowedOk++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||||
|
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||||
|
$expectedCats = $generatedTypeCategories[$typeName]
|
||||||
|
if ($expectedCats) {
|
||||||
|
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
|
||||||
|
$foundCats = @{}
|
||||||
|
if ($objInfo) {
|
||||||
|
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
|
||||||
|
$cat = $gt.GetAttribute("category")
|
||||||
|
if ($cat) { $foundCats[$cat] = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
|
||||||
|
if ($missingCats.Count -gt 0) {
|
||||||
|
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
|
||||||
|
$check9Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||||
@@ -666,6 +748,12 @@ if ($childObjNode) {
|
|||||||
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
||||||
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
||||||
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
||||||
|
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||||
|
if ($tsName) {
|
||||||
|
$tsKey = "${typeName}.${childName}"
|
||||||
|
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
|
||||||
|
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
|
||||||
|
}
|
||||||
if (-not $tsInfo) {
|
if (-not $tsInfo) {
|
||||||
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
||||||
$check10Ok = $false
|
$check10Ok = $false
|
||||||
@@ -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) {
|
foreach ($mi in $missingItems) {
|
||||||
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
||||||
$check12Ok = $false
|
$check12Ok = $false
|
||||||
@@ -930,6 +1050,138 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
|||||||
Report-OK "13. TypeLink: clean"
|
Report-OK "13. TypeLink: clean"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||||
|
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||||
|
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||||
|
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
|
||||||
|
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||||
|
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
|
||||||
|
if (-not $ConfigPath) {
|
||||||
|
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
|
||||||
|
} else {
|
||||||
|
$cfgRoot = $ConfigPath
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
|
||||||
|
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
|
||||||
|
|
||||||
|
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
|
||||||
|
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
|
||||||
|
} else {
|
||||||
|
$check14Ok = $true
|
||||||
|
$pathCheckCount = 0
|
||||||
|
|
||||||
|
foreach ($bf in $script:borrowedFormsWithTree) {
|
||||||
|
$raw = $bf.RawText
|
||||||
|
$ctx = $bf.Context
|
||||||
|
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||||
|
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||||
|
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||||
|
if (-not $rootMatch14.Success) { continue }
|
||||||
|
$rootName = $rootMatch14.Groups[1].Value
|
||||||
|
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||||
|
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||||
|
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||||
|
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||||
|
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
|
||||||
|
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||||
|
$ownerParts = $ownerKey -split '\.', 2
|
||||||
|
if ($ownerParts.Count -lt 2) { continue }
|
||||||
|
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
|
||||||
|
$ownerDir = $childTypeDirMap[$ownerType]
|
||||||
|
if (-not $ownerDir) { continue }
|
||||||
|
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
|
||||||
|
if (-not (Test-Path $srcObjFile)) {
|
||||||
|
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||||
|
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||||
|
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||||
|
$srcNames = @{}
|
||||||
|
$srcTSColumns = @{}
|
||||||
|
$srcDoc = New-Object System.Xml.XmlDocument
|
||||||
|
$srcDoc.PreserveWhitespace = $false
|
||||||
|
$srcDoc.Load($srcObjFile)
|
||||||
|
$srcObjEl = $null
|
||||||
|
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
|
||||||
|
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
|
||||||
|
}
|
||||||
|
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
|
||||||
|
if ($srcChildObjects) {
|
||||||
|
foreach ($sub in $srcChildObjects.ChildNodes) {
|
||||||
|
if ($sub.NodeType -ne 'Element') { continue }
|
||||||
|
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
|
||||||
|
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||||
|
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
|
||||||
|
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||||
|
if (-not $nameNode) { continue }
|
||||||
|
$subName = $nameNode.InnerText.Trim()
|
||||||
|
$srcNames[$subName] = $true
|
||||||
|
if ($sub.LocalName -ne 'TabularSection') { continue }
|
||||||
|
$cols = @{}
|
||||||
|
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
|
||||||
|
$cols[$colName.InnerText.Trim()] = $true
|
||||||
|
}
|
||||||
|
$srcTSColumns[$subName] = $cols
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||||
|
$rootPat14 = [regex]::Escape($rootName)
|
||||||
|
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
|
||||||
|
$tbl = $acm.Groups[1].Value
|
||||||
|
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
|
||||||
|
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
|
||||||
|
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$badPaths = @{}
|
||||||
|
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||||
|
$segments = $m.Groups[1].Value -split '\.'
|
||||||
|
$seg0 = $segments[0]
|
||||||
|
$pathCheckCount++
|
||||||
|
if ($script:standardObjectFields -contains $seg0) { continue }
|
||||||
|
if (-not $srcNames.ContainsKey($seg0)) {
|
||||||
|
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||||
|
# он ведёт в чужой объект, и это уже другая проверка.
|
||||||
|
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
|
||||||
|
$seg1 = $segments[1]
|
||||||
|
if ($script:standardObjectFields -contains $seg1) { continue }
|
||||||
|
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||||
|
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
|
||||||
|
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
|
||||||
|
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
|
||||||
|
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
|
||||||
|
$check14Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($check14Ok) {
|
||||||
|
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
|
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||||
|
$extRootDir = Split-Path $resolvedPath -Parent
|
||||||
|
$ctrlCount = 0
|
||||||
|
foreach ($bslFile in (Get-ChildItem -Path $extRootDir -Recurse -Filter *.bsl -File -ErrorAction SilentlyContinue)) {
|
||||||
|
$txt = [System.IO.File]::ReadAllText($bslFile.FullName, [System.Text.Encoding]::UTF8)
|
||||||
|
$ctrlCount += ([regex]::Matches($txt, '(?m)^\s*&ИзменениеИКонтроль\(')).Count
|
||||||
|
}
|
||||||
|
if ($ctrlCount -gt 0) {
|
||||||
|
Out-Line "[INFO] Контролируемых методов (&ИзменениеИКонтроль): $ctrlCount — их актуальность здесь не проверяется. Сверьте: /cfe-patch-method -Check -ExtensionPath <ext> -ConfigPath <cf>"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Final output ---
|
# --- Final output ---
|
||||||
& $finalize
|
& $finalize
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NS = {
|
NS = {
|
||||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||||
@@ -37,7 +59,7 @@ VALID_CLASS_IDS = [
|
|||||||
CHILD_OBJECT_TYPES = [
|
CHILD_OBJECT_TYPES = [
|
||||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||||
@@ -54,6 +76,7 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||||
|
'Bot': 'Bots',
|
||||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||||
@@ -73,6 +96,50 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
'IntegrationService': 'IntegrationServices',
|
'IntegrationService': 'IntegrationServices',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||||
|
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||||
|
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||||
|
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||||
|
GENERATED_TYPE_CATEGORIES = {
|
||||||
|
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Enum': ['Ref', 'Manager', 'List'],
|
||||||
|
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
|
||||||
|
'Report': ['Object', 'Manager'],
|
||||||
|
'DataProcessor': ['Object', 'Manager'],
|
||||||
|
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||||
|
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
|
||||||
|
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
|
||||||
|
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
|
||||||
|
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
|
||||||
|
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
|
||||||
|
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
|
||||||
|
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
|
||||||
|
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
|
||||||
|
'DocumentJournal': ['Selection', 'List', 'Manager'],
|
||||||
|
'Sequence': ['Record', 'Manager', 'RecordSet'],
|
||||||
|
'FilterCriterion': ['Manager', 'List'],
|
||||||
|
'SettingsStorage': ['Manager'],
|
||||||
|
'IntegrationService': ['Manager'],
|
||||||
|
'WSReference': ['Manager'],
|
||||||
|
'DefinedType': ['DefinedType'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||||
|
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||||
|
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
|
||||||
|
MAIN_ATTR_RE = re.compile(
|
||||||
|
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
|
||||||
|
|
||||||
|
STANDARD_OBJECT_FIELDS = {
|
||||||
|
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
|
||||||
|
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
|
||||||
|
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
|
||||||
|
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
|
||||||
|
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
|
||||||
|
}
|
||||||
|
|
||||||
# Valid enum values for extension properties
|
# Valid enum values for extension properties
|
||||||
VALID_ENUM_VALUES = {
|
VALID_ENUM_VALUES = {
|
||||||
'ConfigurationExtensionCompatibilityMode': [
|
'ConfigurationExtensionCompatibilityMode': [
|
||||||
@@ -94,6 +161,20 @@ VALID_ENUM_VALUES = {
|
|||||||
|
|
||||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
class Reporter:
|
class Reporter:
|
||||||
def __init__(self, max_errors, detailed=False):
|
def __init__(self, max_errors, detailed=False):
|
||||||
@@ -154,11 +235,15 @@ def main():
|
|||||||
parser.add_argument('-Detailed', action='store_true')
|
parser.add_argument('-Detailed', action='store_true')
|
||||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||||
args = parser.parse_args()
|
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||||
|
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||||
|
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
|
||||||
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
extension_path = args.ExtensionPath
|
extension_path = args.ExtensionPath
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
out_file = args.OutFile
|
out_file = args.OutFile
|
||||||
|
config_path_arg = args.ConfigPath
|
||||||
|
|
||||||
# --- Resolve path ---
|
# --- Resolve path ---
|
||||||
if not os.path.isabs(extension_path):
|
if not os.path.isabs(extension_path):
|
||||||
@@ -214,10 +299,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get('version', '')
|
version = root.get('version', '')
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
r.warn('1. Missing version attribute on MetaDataObject')
|
r.warn('1. Missing version attribute on MetaDataObject')
|
||||||
elif version not in ('2.17', '2.20', '2.21'):
|
elif version_rank == 0:
|
||||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
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
|
# Must have Configuration child
|
||||||
cfg_node = None
|
cfg_node = None
|
||||||
@@ -536,6 +628,7 @@ def main():
|
|||||||
MD = NS['md']
|
MD = NS['md']
|
||||||
XR = NS['xr']
|
XR = NS['xr']
|
||||||
enum_values_index = {}
|
enum_values_index = {}
|
||||||
|
borrowed_ts_index = {}
|
||||||
form_list = []
|
form_list = []
|
||||||
|
|
||||||
def is_borrowed_sub_item(sub_item):
|
def is_borrowed_sub_item(sub_item):
|
||||||
@@ -635,6 +728,23 @@ def main():
|
|||||||
else:
|
else:
|
||||||
borrowed_ok_count += 1
|
borrowed_ok_count += 1
|
||||||
|
|
||||||
|
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||||
|
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||||
|
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
|
||||||
|
if expected_cats:
|
||||||
|
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
|
||||||
|
found_cats = set()
|
||||||
|
if obj_info is not None:
|
||||||
|
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
|
||||||
|
cat = gt.get('category')
|
||||||
|
if cat:
|
||||||
|
found_cats.add(cat)
|
||||||
|
missing_cats = [c for c in expected_cats if c not in found_cats]
|
||||||
|
if missing_cats:
|
||||||
|
word = 'category' if len(missing_cats) == 1 else 'categories'
|
||||||
|
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
|
||||||
|
check9_ok = False
|
||||||
|
|
||||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||||
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
||||||
if obj_child_objects is not None:
|
if obj_child_objects is not None:
|
||||||
@@ -662,6 +772,9 @@ def main():
|
|||||||
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
||||||
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||||
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
||||||
|
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||||
|
if ts_name_el is not None and ts_name_el.text:
|
||||||
|
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
|
||||||
if ts_info is None:
|
if ts_info is None:
|
||||||
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
||||||
check10_ok = False
|
check10_ok = False
|
||||||
@@ -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'], {}):
|
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
||||||
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
||||||
|
|
||||||
|
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||||
|
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||||
|
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||||
|
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||||
|
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||||
|
# поэтому ошибка.
|
||||||
|
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||||
|
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
|
||||||
|
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
|
||||||
|
root_match = MAIN_ATTR_RE.search(raw)
|
||||||
|
root_name = root_match.group(1) if root_match else ""
|
||||||
|
ac_tables = set()
|
||||||
|
if root_name:
|
||||||
|
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
|
||||||
|
if ac_tables:
|
||||||
|
owner_key = ctx.split('.Form.')[0]
|
||||||
|
owner_ts = borrowed_ts_index.get(owner_key, {})
|
||||||
|
for tbl_name in sorted(ac_tables):
|
||||||
|
dep_check_count += 1
|
||||||
|
if tbl_name not in owner_ts:
|
||||||
|
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
|
||||||
|
check12_ok = False
|
||||||
|
|
||||||
for mi in missing_items:
|
for mi in missing_items:
|
||||||
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
||||||
check12_ok = False
|
check12_ok = False
|
||||||
@@ -885,6 +1021,145 @@ def main():
|
|||||||
elif check13_ok:
|
elif check13_ok:
|
||||||
r.ok('13. TypeLink: clean')
|
r.ok('13. TypeLink: clean')
|
||||||
|
|
||||||
|
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||||
|
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||||
|
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||||
|
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
|
||||||
|
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||||
|
if not r.stopped and borrowed_forms_with_tree:
|
||||||
|
if not config_path_arg:
|
||||||
|
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
|
||||||
|
else:
|
||||||
|
cfg_root = config_path_arg
|
||||||
|
if not os.path.isabs(cfg_root):
|
||||||
|
cfg_root = os.path.join(os.getcwd(), cfg_root)
|
||||||
|
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
|
||||||
|
cfg_root = os.path.dirname(cfg_root)
|
||||||
|
|
||||||
|
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
|
||||||
|
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
|
||||||
|
else:
|
||||||
|
check14_ok = True
|
||||||
|
path_check_count = 0
|
||||||
|
|
||||||
|
for bf in borrowed_forms_with_tree:
|
||||||
|
raw = bf['RawText']
|
||||||
|
ctx = bf['Context']
|
||||||
|
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||||
|
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||||
|
root_match14 = MAIN_ATTR_RE.search(raw)
|
||||||
|
if root_match14 is None:
|
||||||
|
continue
|
||||||
|
root_name14 = root_match14.group(1)
|
||||||
|
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||||
|
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||||
|
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||||
|
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||||
|
if '>cfg:DynamicList<' in root_match14.group(0):
|
||||||
|
continue
|
||||||
|
owner_key = ctx.split('.Form.')[0]
|
||||||
|
owner_parts = owner_key.split('.', 1)
|
||||||
|
if len(owner_parts) < 2:
|
||||||
|
continue
|
||||||
|
owner_type, owner_name = owner_parts
|
||||||
|
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
|
||||||
|
if not owner_dir:
|
||||||
|
continue
|
||||||
|
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
|
||||||
|
if not os.path.isfile(src_obj_file):
|
||||||
|
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||||
|
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||||
|
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||||
|
src_names = set()
|
||||||
|
src_ts_columns = {}
|
||||||
|
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
|
||||||
|
src_obj_el = None
|
||||||
|
for c in src_tree.getroot():
|
||||||
|
if isinstance(c.tag, str):
|
||||||
|
src_obj_el = c
|
||||||
|
break
|
||||||
|
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
|
||||||
|
if src_child_objects is not None:
|
||||||
|
for sub in src_child_objects:
|
||||||
|
if not isinstance(sub.tag, str):
|
||||||
|
continue
|
||||||
|
sub_ln = etree.QName(sub.tag).localname
|
||||||
|
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
|
||||||
|
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||||
|
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
|
||||||
|
continue
|
||||||
|
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||||
|
if name_el is None or not name_el.text:
|
||||||
|
continue
|
||||||
|
sub_name = name_el.text.strip()
|
||||||
|
src_names.add(sub_name)
|
||||||
|
if sub_ln != 'TabularSection':
|
||||||
|
continue
|
||||||
|
cols = set()
|
||||||
|
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
|
||||||
|
if col_name.text:
|
||||||
|
cols.add(col_name.text.strip())
|
||||||
|
src_ts_columns[sub_name] = cols
|
||||||
|
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||||
|
root_pat14 = re.escape(root_name14)
|
||||||
|
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
|
||||||
|
tbl = acm.group(1)
|
||||||
|
cols = src_ts_columns.setdefault(tbl, set())
|
||||||
|
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
|
||||||
|
cols.add(cm.group(1))
|
||||||
|
|
||||||
|
bad_paths = {}
|
||||||
|
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
|
||||||
|
segments = m.group(1).split('.')
|
||||||
|
seg0 = segments[0]
|
||||||
|
path_check_count += 1
|
||||||
|
if seg0 in STANDARD_OBJECT_FIELDS:
|
||||||
|
continue
|
||||||
|
if seg0 not in src_names:
|
||||||
|
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
|
||||||
|
continue
|
||||||
|
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||||
|
# он ведёт в чужой объект, и это уже другая проверка.
|
||||||
|
if len(segments) < 2 or seg0 not in src_ts_columns:
|
||||||
|
continue
|
||||||
|
seg1 = segments[1]
|
||||||
|
if seg1 in STANDARD_OBJECT_FIELDS:
|
||||||
|
continue
|
||||||
|
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||||
|
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
|
||||||
|
continue
|
||||||
|
if seg1 not in src_ts_columns[seg0]:
|
||||||
|
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
|
||||||
|
|
||||||
|
for bad in sorted(bad_paths):
|
||||||
|
r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}")
|
||||||
|
check14_ok = False
|
||||||
|
|
||||||
|
if check14_ok:
|
||||||
|
r.ok(f'14. Object paths vs source config: {path_check_count} checked')
|
||||||
|
|
||||||
|
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):
|
||||||
|
for fn in files:
|
||||||
|
if fn.endswith('.bsl'):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(dp, fn), 'r', encoding='utf-8-sig') as f:
|
||||||
|
for ln in f:
|
||||||
|
if re.match(r'^\s*&ИзменениеИКонтроль\(', ln):
|
||||||
|
ctrl_count += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if ctrl_count > 0:
|
||||||
|
r.out('[INFO] Контролируемых методов (&ИзменениеИКонтроль): %d — их актуальность здесь не проверяется. Сверьте: /cfe-patch-method -Check -ExtensionPath <ext> -ConfigPath <cf>' % ctrl_count)
|
||||||
|
|
||||||
# --- Final output ---
|
# --- Final output ---
|
||||||
r.finalize(out_file)
|
r.finalize(out_file)
|
||||||
sys.exit(1 if r.errors > 0 else 0)
|
sys.exit(1 if r.errors > 0 else 0)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
|
|||||||
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
|
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
|
||||||
| `-AddToList` | нет | Добавить в список баз 1С |
|
| `-AddToList` | нет | Добавить в список баз 1С |
|
||||||
| `-ListName <имя>` | нет | Имя базы в списке |
|
| `-ListName <имя>` | нет | Имя базы в списке |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-create v1.6 — Create 1C information base
|
# db-create v1.11 — Create 1C information base
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -30,6 +30,12 @@
|
|||||||
.PARAMETER ListName
|
.PARAMETER ListName
|
||||||
Имя базы в списке
|
Имя базы в списке
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||||
|
|
||||||
@@ -61,12 +67,163 @@ param(
|
|||||||
[switch]$AddToList,
|
[switch]$AddToList,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$ListName
|
[string]$ListName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::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 ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -111,35 +268,90 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-FileIbCreated {
|
||||||
|
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||||
|
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$IbPath)
|
||||||
|
$f = Join-Path $IbPath "1Cv8.1CD"
|
||||||
|
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -173,16 +385,21 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||||
|
if ($ibMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||||
|
} elseif ($ibMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +407,8 @@ try {
|
|||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
$arguments = @("CREATEINFOBASE")
|
$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) {
|
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||||
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
||||||
} else {
|
} else {
|
||||||
@@ -214,19 +433,26 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "create_log.txt"
|
$outFile = Join-Path $tempDir "create_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||||
|
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||||
|
if ($ibMissing) { $exitCode = 1 }
|
||||||
|
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||||
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
} elseif ($ibMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -239,6 +465,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-create v1.6 — Create 1C information base
|
# db-create v1.11 — Create 1C information base
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
parent = os.path.dirname(p)
|
||||||
@@ -78,6 +257,13 @@ def resolve_v8path(v8path):
|
|||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
|
|
||||||
|
def file_ib_created(ib_path):
|
||||||
|
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||||
|
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||||
|
f = os.path.join(ib_path, "1Cv8.1CD")
|
||||||
|
return os.path.isfile(f) and os.path.getsize(f) > 0
|
||||||
|
|
||||||
|
|
||||||
IBCMD_NOUSER_HINT = (
|
IBCMD_NOUSER_HINT = (
|
||||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||||
@@ -86,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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,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:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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():
|
def main():
|
||||||
@@ -113,11 +371,33 @@ def main():
|
|||||||
parser.add_argument("-UseTemplate", default="")
|
parser.add_argument("-UseTemplate", default="")
|
||||||
parser.add_argument("-AddToList", action="store_true")
|
parser.add_argument("-AddToList", action="store_true")
|
||||||
parser.add_argument("-ListName", default="")
|
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -143,17 +423,25 @@ def main():
|
|||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||||
arguments.append(f"--data={ib_data}")
|
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)
|
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
|
||||||
|
if ib_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||||
|
elif ib_missing:
|
||||||
|
print(
|
||||||
|
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||||
|
"— information base was not created",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f"Error creating information base (code: {result.returncode})", file=sys.stderr)
|
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
print_platform_output(result)
|
||||||
print(result.stdout)
|
sys.exit(exit_code)
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
||||||
@@ -163,44 +451,53 @@ def main():
|
|||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
arguments = ["CREATEINFOBASE"]
|
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:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
# No embedded quotes: subprocess quotes the whole token; 1C's argv parser
|
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||||
# strips outer quotes. Inner quotes get escaped by list2cmdline and break parsing.
|
|
||||||
arguments.append(f'Srvr={args.InfoBaseServer};Ref={args.InfoBaseRef}')
|
|
||||||
else:
|
else:
|
||||||
arguments.append(f'File={args.InfoBasePath}')
|
arguments.append(f'File="{args.InfoBasePath}"')
|
||||||
|
|
||||||
# --- Template ---
|
# --- Template ---
|
||||||
if args.UseTemplate:
|
if args.UseTemplate:
|
||||||
arguments.extend(["/UseTemplate", args.UseTemplate])
|
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||||
|
|
||||||
# --- Add to list ---
|
# --- Add to list ---
|
||||||
if args.AddToList:
|
if args.AddToList:
|
||||||
if args.ListName:
|
if args.ListName:
|
||||||
arguments.extend(["/AddToList", args.ListName])
|
arguments.extend(["/AddToList", f'"{args.ListName}"'])
|
||||||
else:
|
else:
|
||||||
arguments.append("/AddToList")
|
arguments.append("/AddToList")
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||||
|
is_server = bool(args.InfoBaseServer and args.InfoBaseRef)
|
||||||
|
ib_missing = exit_code == 0 and not is_server and not file_ib_created(args.InfoBasePath)
|
||||||
|
if ib_missing:
|
||||||
|
exit_code = 1
|
||||||
|
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if is_server:
|
||||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
||||||
else:
|
else:
|
||||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||||
|
elif ib_missing:
|
||||||
|
print(
|
||||||
|
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||||
|
"— information base was not created",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -214,6 +511,7 @@ def main():
|
|||||||
print("--- End ---")
|
print("--- End ---")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
print_platform_output(result)
|
||||||
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
|||||||
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
|
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
|
||||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-cf v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
.PARAMETER AllExtensions
|
.PARAMETER AllExtensions
|
||||||
Выгрузить все расширения
|
Выгрузить все расширения
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||||
|
|
||||||
@@ -70,12 +76,183 @@ param(
|
|||||||
[string]$Extension,
|
[string]$Extension,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$AllExtensions
|
[switch]$AllExtensions,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -120,35 +297,89 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-OutputNonEmpty {
|
||||||
|
# Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$Path)
|
||||||
|
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -183,16 +414,21 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,15 +458,21 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -243,6 +485,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-cf v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,25 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||||
|
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -115,11 +391,34 @@ def main():
|
|||||||
parser.add_argument("-OutputFile", required=True)
|
parser.add_argument("-OutputFile", required=True)
|
||||||
parser.add_argument("-Extension", default="")
|
parser.add_argument("-Extension", default="")
|
||||||
parser.add_argument("-AllExtensions", action="store_true")
|
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -150,17 +449,20 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {result.returncode})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
sys.exit(exit_code)
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
||||||
@@ -171,40 +473,43 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||||
else:
|
else:
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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 ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments.extend(["-Extension", args.Extension])
|
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -219,6 +524,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <п
|
|||||||
| `-UserName <имя>` | нет | Имя пользователя |
|
| `-UserName <имя>` | нет | Имя пользователя |
|
||||||
| `-Password <пароль>` | нет | Пароль |
|
| `-Password <пароль>` | нет | Пароль |
|
||||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-dt v1.5 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -29,6 +29,12 @@
|
|||||||
.PARAMETER OutputFile
|
.PARAMETER OutputFile
|
||||||
Путь к выходному DT-файлу
|
Путь к выходному DT-файлу
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||||
#>
|
#>
|
||||||
@@ -54,12 +60,183 @@ param(
|
|||||||
[string]$Password,
|
[string]$Password,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$OutputFile
|
[string]$OutputFile,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -104,35 +281,89 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-OutputNonEmpty {
|
||||||
|
# Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$Path)
|
||||||
|
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -163,16 +394,22 @@ try {
|
|||||||
$arguments += "$OutputFile"
|
$arguments += "$OutputFile"
|
||||||
|
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
|
||||||
$__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
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +432,21 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -216,6 +459,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-dt v1.5 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,25 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||||
|
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -113,11 +389,34 @@ def main():
|
|||||||
parser.add_argument("-UserName", default="")
|
parser.add_argument("-UserName", default="")
|
||||||
parser.add_argument("-Password", default="")
|
parser.add_argument("-Password", default="")
|
||||||
parser.add_argument("-OutputFile", required=True)
|
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -143,17 +442,20 @@ def main():
|
|||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping information base (code: {result.returncode})", file=sys.stderr)
|
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
sys.exit(exit_code)
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
||||||
@@ -164,34 +466,37 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||||
else:
|
else:
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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 ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -206,6 +511,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
|||||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-dump-xml v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -48,6 +48,12 @@
|
|||||||
.PARAMETER Format
|
.PARAMETER Format
|
||||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||||
|
|
||||||
@@ -93,12 +99,183 @@ param(
|
|||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[ValidateSet("Hierarchical", "Plain")]
|
[ValidateSet("Hierarchical", "Plain")]
|
||||||
[string]$Format = "Hierarchical"
|
[string]$Format = "Hierarchical",
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -143,35 +320,89 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-DirNonEmpty {
|
||||||
|
# Postcondition: the platform must have written files into the output directory.
|
||||||
|
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$Path)
|
||||||
|
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -224,16 +455,21 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,16 +527,22 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Dump completed successfully" -ForegroundColor Green
|
Write-Host "Dump completed successfully" -ForegroundColor Green
|
||||||
Write-Host "Configuration dumped to: $ConfigDir"
|
Write-Host "Configuration dumped to: $ConfigDir"
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -313,6 +555,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-dump-xml v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,25 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Postcondition: the platform must have written files into the output directory.
|
||||||
|
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||||
|
return os.path.isdir(path) and any(os.scandir(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -128,12 +404,35 @@ def main():
|
|||||||
choices=["Hierarchical", "Plain"],
|
choices=["Hierarchical", "Plain"],
|
||||||
help="Dump format (default: Hierarchical)",
|
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 ---
|
# --- Resolve V8Path ---
|
||||||
v8path = resolve_v8path(args.V8Path)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -181,17 +480,20 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error exporting configuration (code: {result.returncode})", file=sys.stderr)
|
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
sys.exit(exit_code)
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
||||||
@@ -202,16 +504,16 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||||
else:
|
else:
|
||||||
arguments += ["/F", args.InfoBasePath]
|
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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]
|
arguments += ["-Format", args.Format]
|
||||||
|
|
||||||
if args.Mode == "Full":
|
if args.Mode == "Full":
|
||||||
@@ -228,7 +530,7 @@ def main():
|
|||||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||||
f.write("\n".join(object_list))
|
f.write("\n".join(object_list))
|
||||||
|
|
||||||
arguments += ["-listFile", list_file]
|
arguments += ["-listFile", f'"{list_file}"']
|
||||||
print(f"Objects to dump: {len(object_list)}")
|
print(f"Objects to dump: {len(object_list)}")
|
||||||
for obj in object_list:
|
for obj in object_list:
|
||||||
print(f" {obj}")
|
print(f" {obj}")
|
||||||
@@ -238,28 +540,31 @@ def main():
|
|||||||
|
|
||||||
# --- Extensions ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments += ["-Extension", args.Extension]
|
arguments += ["-Extension", f'"{args.Extension}"']
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||||
arguments += ["/Out", out_file]
|
arguments += ["/Out", f'"{out_file}"']
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||||
|
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Dump completed successfully")
|
print("Dump completed successfully")
|
||||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -274,6 +579,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ allowed-tools:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||||
|
"v8args": ["/UseHwLicenses+"],
|
||||||
"databases": [
|
"databases": [
|
||||||
{
|
{
|
||||||
"id": "dev",
|
"id": "dev",
|
||||||
@@ -61,6 +62,8 @@ allowed-tools:
|
|||||||
| Поле | Тип | Описание |
|
| Поле | Тип | Описание |
|
||||||
|------|-----|----------|
|
|------|-----|----------|
|
||||||
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
||||||
|
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
||||||
|
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
||||||
| `databases` | array | Массив баз данных |
|
| `databases` | array | Массив баз данных |
|
||||||
| `default` | string | id базы по умолчанию |
|
| `default` | string | id базы по умолчанию |
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
|||||||
| `-InputFile <путь>` | да | Путь к CF-файлу |
|
| `-InputFile <путь>` | да | Путь к CF-файлу |
|
||||||
| `-Extension <имя>` | нет | Загрузить как расширение |
|
| `-Extension <имя>` | нет | Загрузить как расширение |
|
||||||
| `-AllExtensions` | нет | Загрузить все расширения из архива |
|
| `-AllExtensions` | нет | Загрузить все расширения из архива |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-cf v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
.PARAMETER AllExtensions
|
.PARAMETER AllExtensions
|
||||||
Загрузить все расширения из архива
|
Загрузить все расширения из архива
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||||
|
|
||||||
@@ -70,12 +76,200 @@ param(
|
|||||||
[string]$Extension,
|
[string]$Extension,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$AllExtensions
|
[switch]$AllExtensions,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -120,35 +314,82 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -183,16 +424,17 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,17 +464,18 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Path $outFile) {
|
if (Test-Path $outFile) {
|
||||||
@@ -243,6 +486,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-cf v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,43 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||||
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||||
|
if code is None:
|
||||||
|
return ""
|
||||||
|
win = {
|
||||||
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||||
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||||
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||||
|
}
|
||||||
|
if code in win:
|
||||||
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
if -64 <= code < 0:
|
||||||
|
try:
|
||||||
|
import signal
|
||||||
|
name = signal.Signals(-code).name
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
name = f"signal {-code}"
|
||||||
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||||
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -115,11 +409,34 @@ def main():
|
|||||||
parser.add_argument("-InputFile", required=True)
|
parser.add_argument("-InputFile", required=True)
|
||||||
parser.add_argument("-Extension", default="")
|
parser.add_argument("-Extension", default="")
|
||||||
parser.add_argument("-AllExtensions", action="store_true")
|
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -150,16 +467,13 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {result.returncode})", file=sys.stderr)
|
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)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -171,42 +485,39 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||||
else:
|
else:
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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 ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments.extend(["-Extension", args.Extension])
|
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
@@ -219,6 +530,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <п
|
|||||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-dt v1.5 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
.PARAMETER UnlockCode
|
.PARAMETER UnlockCode
|
||||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||||
#>
|
#>
|
||||||
@@ -67,12 +73,200 @@ param(
|
|||||||
[int]$JobsCount = 0,
|
[int]$JobsCount = 0,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$UnlockCode
|
[string]$UnlockCode,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -117,35 +311,82 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -177,16 +418,18 @@ try {
|
|||||||
$arguments += "$InputFile"
|
$arguments += "$InputFile"
|
||||||
|
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
|
||||||
$__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
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error restoring information base (code: $exitCode)" -ForegroundColor Red
|
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
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,17 +454,18 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error restoring information base (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Path $outFile) {
|
if (Test-Path $outFile) {
|
||||||
@@ -232,6 +476,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-dt v1.5 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,43 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||||
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||||
|
if code is None:
|
||||||
|
return ""
|
||||||
|
win = {
|
||||||
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||||
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||||
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||||
|
}
|
||||||
|
if code in win:
|
||||||
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
if -64 <= code < 0:
|
||||||
|
try:
|
||||||
|
import signal
|
||||||
|
name = signal.Signals(-code).name
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
name = f"signal {-code}"
|
||||||
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||||
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -115,11 +409,34 @@ def main():
|
|||||||
parser.add_argument("-InputFile", required=True)
|
parser.add_argument("-InputFile", required=True)
|
||||||
parser.add_argument("-JobsCount", type=int, default=0)
|
parser.add_argument("-JobsCount", type=int, default=0)
|
||||||
parser.add_argument("-UnlockCode", default="")
|
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -147,16 +464,13 @@ def main():
|
|||||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
print(f"Information base restored successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error restoring information base (code: {result.returncode})", file=sys.stderr)
|
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)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -168,40 +482,37 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||||
else:
|
else:
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"/P{args.Password}")
|
arguments.append(f'/P"{args.Password}"')
|
||||||
if args.UnlockCode:
|
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:
|
if args.JobsCount > 0:
|
||||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Information base restored successfully from: {args.InputFile}")
|
print(f"Information base restored successfully from: {args.InputFile}")
|
||||||
else:
|
else:
|
||||||
print(f"Error restoring information base (code: {exit_code})", file=sys.stderr)
|
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
|
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
@@ -214,6 +525,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
|||||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||||
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
|
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
|
||||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-git v1.11 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -48,6 +48,12 @@
|
|||||||
.PARAMETER DryRun
|
.PARAMETER DryRun
|
||||||
Только показать что будет загружено (без загрузки)
|
Только показать что будет загружено (без загрузки)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||||
|
|
||||||
@@ -102,12 +108,170 @@ param(
|
|||||||
[switch]$DryRun,
|
[switch]$DryRun,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[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
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
||||||
function Get-ObjectXmlFromSubFile {
|
function Get-ObjectXmlFromSubFile {
|
||||||
param([string]$RelativePath)
|
param([string]$RelativePath)
|
||||||
@@ -167,32 +331,110 @@ if (-not $DryRun) {
|
|||||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||||
$engine = "1cv8"
|
$engine = "1cv8"
|
||||||
if (-not $DryRun) {
|
if (-not $DryRun) {
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
@@ -206,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 ---
|
# --- Validate config dir ---
|
||||||
if (-not (Test-Path $ConfigDir)) {
|
if (-not (Test-Path $ConfigDir)) {
|
||||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||||
@@ -372,32 +618,34 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -ne 0) {
|
if ($exitCode -ne 0) {
|
||||||
Write-Host "Error loading changes (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
if ($UpdateDB) {
|
if ($UpdateDB) {
|
||||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||||
if ($Password) { $applyArgs += "--password=$Password" }
|
if ($Password) { $applyArgs += "--password=$Password" }
|
||||||
$applyArgs += "--data=$tempDir"
|
$applyArgs += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($applyArgs -join ' ')"
|
$applyArgs += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $applyArgs
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||||
$applyOut = $__ib.Output
|
$applyOut = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
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
|
exit $exitCode
|
||||||
}
|
}
|
||||||
@@ -442,23 +690,25 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "load_log.txt"
|
$outFile = Join-Path $tempDir "load_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Executing partial configuration load..."
|
Write-Host "Executing partial configuration load..."
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logContent = $null
|
||||||
if (Test-Path $outFile) {
|
if (Test-Path $outFile) {
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||||
if ($logContent) {
|
if ($logContent) {
|
||||||
@@ -467,6 +717,17 @@ try {
|
|||||||
Write-Host "--- End ---"
|
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
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-git v1.11 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""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:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
def get_object_xml_from_subfile(relative_path):
|
||||||
@@ -121,6 +414,39 @@ def run_git(config_dir, git_args):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def describe_exit(code):
|
||||||
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||||
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||||
|
if code is None:
|
||||||
|
return ""
|
||||||
|
win = {
|
||||||
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||||
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||||
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||||
|
}
|
||||||
|
if code in win:
|
||||||
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
if -64 <= code < 0:
|
||||||
|
try:
|
||||||
|
import signal
|
||||||
|
name = signal.Signals(-code).name
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
name = f"signal {-code}"
|
||||||
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||||
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -152,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("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
||||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||||
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) ---
|
# --- Resolve V8Path (skip if DryRun) ---
|
||||||
v8path = None
|
v8path = None
|
||||||
@@ -171,6 +512,18 @@ def main():
|
|||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||||
sys.exit(1)
|
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 ---
|
# --- Validate config dir ---
|
||||||
if not os.path.exists(args.ConfigDir):
|
if not os.path.exists(args.ConfigDir):
|
||||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||||
@@ -307,18 +660,13 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Error loading changes (code: {result.returncode})", file=sys.stderr)
|
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)
|
sys.exit(result.returncode)
|
||||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
if args.UpdateDB:
|
if args.UpdateDB:
|
||||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||||
@@ -327,17 +675,15 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
apply_args.append(f"--password={args.Password}")
|
apply_args.append(f"--password={args.Password}")
|
||||||
apply_args.append(f"--data={ib_data}")
|
apply_args.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(apply_args)}")
|
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))
|
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||||
exit_code = ar.returncode
|
exit_code = ar.returncode
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
if ar.stdout:
|
print_platform_output(ar)
|
||||||
print(ar.stdout)
|
|
||||||
if ar.stderr:
|
|
||||||
print(ar.stderr, file=sys.stderr)
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Write list file (UTF-8 with BOM) ---
|
# --- Write list file (UTF-8 with BOM) ---
|
||||||
@@ -349,24 +695,24 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||||
else:
|
else:
|
||||||
arguments += ["/F", args.InfoBasePath]
|
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"/P{args.Password}")
|
arguments.append(f'/P"{args.Password}"')
|
||||||
|
|
||||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||||
arguments += ["-listFile", list_file]
|
arguments += ["-listFile", f'"{list_file}"']
|
||||||
arguments += ["-Format", args.Format]
|
arguments += ["-Format", args.Format]
|
||||||
arguments.append("-partial")
|
arguments.append("-partial")
|
||||||
arguments.append("-updateConfigDumpInfo")
|
arguments.append("-updateConfigDumpInfo")
|
||||||
|
|
||||||
# --- Extensions ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments += ["-Extension", args.Extension]
|
arguments += ["-Extension", f'"{args.Extension}"']
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
@@ -376,19 +722,16 @@ def main():
|
|||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||||
arguments += ["/Out", out_file]
|
arguments += ["/Out", f'"{out_file}"']
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print("")
|
print("")
|
||||||
print("Executing partial configuration load...")
|
print("Executing partial configuration load...")
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
|
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
@@ -396,8 +739,9 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Load completed successfully")
|
print("Load completed successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
|
|
||||||
|
log_content = ""
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -409,6 +753,22 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <
|
|||||||
| `-AllExtensions` | нет | Загрузить все расширения |
|
| `-AllExtensions` | нет | Загрузить все расширения |
|
||||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-load-xml v1.12 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -48,6 +48,12 @@
|
|||||||
.PARAMETER Format
|
.PARAMETER Format
|
||||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||||
|
|
||||||
@@ -102,12 +108,201 @@ param(
|
|||||||
[switch]$UpdateDB,
|
[switch]$UpdateDB,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[switch]$StrictLog
|
[switch]$StrictLog,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||||
|
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -152,35 +347,117 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -244,33 +521,35 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -ne 0) {
|
if ($exitCode -ne 0) {
|
||||||
Write-Host "Error loading configuration from files (code: $exitCode)" -ForegroundColor Red
|
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
|
exit $exitCode
|
||||||
}
|
}
|
||||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
|
|
||||||
if ($UpdateDB) {
|
if ($UpdateDB) {
|
||||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||||
if ($Password) { $applyArgs += "--password=$Password" }
|
if ($Password) { $applyArgs += "--password=$Password" }
|
||||||
$applyArgs += "--data=$tempDir"
|
$applyArgs += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($applyArgs -join ' ')"
|
$applyArgs += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $applyArgs
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||||
$applyOut = $__ib.Output
|
$applyOut = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
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
|
exit $exitCode
|
||||||
}
|
}
|
||||||
@@ -349,11 +628,12 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "load_log.txt"
|
$outFile = Join-Path $tempDir "load_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Read log ---
|
# --- Read log ---
|
||||||
$logContent = $null
|
$logContent = $null
|
||||||
@@ -362,28 +642,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Scan log for silent rejections ---
|
# --- Scan log for silent rejections ---
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
$silentFailures = @(Find-SilentRejections $logContent)
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
|
||||||
$fatalLogPatterns = @(
|
|
||||||
'Неверное свойство объекта метаданных',
|
|
||||||
'не входит в состав объекта метаданных',
|
|
||||||
'Неизвестное имя типа',
|
|
||||||
'Неизвестный объект метаданных',
|
|
||||||
'Ни один из документов не является регистратором для регистра',
|
|
||||||
'Неверное значение перечисления',
|
|
||||||
'не может быть приведен к типу'
|
|
||||||
)
|
|
||||||
$silentFailures = @()
|
|
||||||
if ($logContent) {
|
|
||||||
foreach ($line in ($logContent -split "`r?`n")) {
|
|
||||||
foreach ($pat in $fatalLogPatterns) {
|
|
||||||
if ($line -match [regex]::Escape($pat)) {
|
|
||||||
$silentFailures += $line.Trim()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||||
@@ -392,7 +651,7 @@ try {
|
|||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($logContent) {
|
if ($logContent) {
|
||||||
@@ -400,11 +659,13 @@ try {
|
|||||||
Write-Host $logContent
|
Write-Host $logContent
|
||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||||
if ($silentFailures.Count -gt 0) {
|
if ($silentFailures.Count -gt 0) {
|
||||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
|
||||||
Write-Host $msg -ForegroundColor Yellow
|
|
||||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-load-xml v1.12 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +386,43 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||||
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||||
|
if code is None:
|
||||||
|
return ""
|
||||||
|
win = {
|
||||||
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||||
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||||
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||||
|
}
|
||||||
|
if code in win:
|
||||||
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
if -64 <= code < 0:
|
||||||
|
try:
|
||||||
|
import signal
|
||||||
|
name = signal.Signals(-code).name
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
name = f"signal {-code}"
|
||||||
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||||
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -135,13 +461,37 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
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 ---
|
# --- Resolve V8Path ---
|
||||||
v8path = resolve_v8path(args.V8Path)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -199,18 +549,13 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Error loading configuration from files (code: {result.returncode})", file=sys.stderr)
|
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)
|
sys.exit(result.returncode)
|
||||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||||
if result.stdout:
|
|
||||||
print(result.stdout)
|
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
if args.UpdateDB:
|
if args.UpdateDB:
|
||||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||||
@@ -219,17 +564,15 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
apply_args.append(f"--password={args.Password}")
|
apply_args.append(f"--password={args.Password}")
|
||||||
apply_args.append(f"--data={ib_data}")
|
apply_args.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(apply_args)}")
|
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))
|
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||||
exit_code = ar.returncode
|
exit_code = ar.returncode
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
if ar.stdout:
|
print_platform_output(ar)
|
||||||
print(ar.stdout)
|
|
||||||
if ar.stderr:
|
|
||||||
print(ar.stderr, file=sys.stderr)
|
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -241,16 +584,16 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||||
else:
|
else:
|
||||||
arguments += ["/F", args.InfoBasePath]
|
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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":
|
if args.Mode == "Full":
|
||||||
print("Executing full configuration load...")
|
print("Executing full configuration load...")
|
||||||
@@ -286,7 +629,7 @@ def main():
|
|||||||
for fl in file_list:
|
for fl in file_list:
|
||||||
print(f" {fl}")
|
print(f" {fl}")
|
||||||
|
|
||||||
arguments += ["-listFile", generated_list_file]
|
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||||
arguments.append("-partial")
|
arguments.append("-partial")
|
||||||
arguments.append("-updateConfigDumpInfo")
|
arguments.append("-updateConfigDumpInfo")
|
||||||
|
|
||||||
@@ -294,7 +637,7 @@ def main():
|
|||||||
|
|
||||||
# --- Extensions ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments += ["-Extension", args.Extension]
|
arguments += ["-Extension", f'"{args.Extension}"']
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
@@ -304,16 +647,13 @@ def main():
|
|||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||||
arguments += ["/Out", out_file]
|
arguments += ["/Out", f'"{out_file}"']
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Read log ---
|
# --- Read log ---
|
||||||
@@ -328,22 +668,7 @@ def main():
|
|||||||
# --- Scan log for silent rejections ---
|
# --- Scan log for silent rejections ---
|
||||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||||
fatal_log_patterns = [
|
silent_failures = find_silent_rejections(log_content)
|
||||||
"Неверное свойство объекта метаданных",
|
|
||||||
"не входит в состав объекта метаданных",
|
|
||||||
"Неизвестное имя типа",
|
|
||||||
"Неизвестный объект метаданных",
|
|
||||||
"Ни один из документов не является регистратором для регистра",
|
|
||||||
"Неверное значение перечисления",
|
|
||||||
"не может быть приведен к типу",
|
|
||||||
]
|
|
||||||
silent_failures = []
|
|
||||||
if log_content:
|
|
||||||
for line in log_content.splitlines():
|
|
||||||
for pat in fatal_log_patterns:
|
|
||||||
if pat in line:
|
|
||||||
silent_failures.append(line.strip())
|
|
||||||
break
|
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||||
@@ -352,22 +677,27 @@ def main():
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Load completed successfully")
|
print("Load completed successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error loading configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
|
|
||||||
if log_content:
|
if log_content:
|
||||||
print("--- Log ---")
|
print("--- Log ---")
|
||||||
print(log_content)
|
print(log_content)
|
||||||
print("--- End ---")
|
print("--- End ---")
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
|
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||||
|
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||||
|
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||||
|
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||||
|
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||||
|
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||||
if silent_failures:
|
if silent_failures:
|
||||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
|
||||||
print(
|
print(
|
||||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
f"[warning] platform reported success, but the log contains "
|
||||||
f"platform loaded config but dropped properties/refs{suffix}",
|
f"{len(silent_failures)} problem(s):"
|
||||||
file=sys.stderr,
|
|
||||||
)
|
)
|
||||||
for f in silent_failures:
|
for f in silent_failures:
|
||||||
print(f" {f}", file=sys.stderr)
|
print(f" {f}")
|
||||||
if args.StrictLog and exit_code == 0:
|
if args.StrictLog and exit_code == 0:
|
||||||
exit_code = 1
|
exit_code = 1
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
|
|||||||
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
|
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
|
||||||
| `-CParam <строка>` | нет | Параметр запуска (/C) |
|
| `-CParam <строка>` | нет | Параметр запуска (/C) |
|
||||||
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
|
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-run v1.2 — Launch 1C:Enterprise
|
# db-run v1.8 — Launch 1C:Enterprise
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
.PARAMETER URL
|
.PARAMETER URL
|
||||||
Навигационная ссылка (e1cib/...)
|
Навигационная ссылка (e1cib/...)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||||
|
|
||||||
@@ -73,12 +79,170 @@ param(
|
|||||||
[string]$CParam,
|
[string]$CParam,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[string]$URL
|
[string]$URL,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -122,6 +286,19 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
exit 1
|
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 ---
|
# --- Validate connection ---
|
||||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||||
@@ -165,7 +342,28 @@ if ($URL) {
|
|||||||
|
|
||||||
$argString += " /DisableStartupDialogs"
|
$argString += " /DisableStartupDialogs"
|
||||||
|
|
||||||
# --- Execute (background, no wait) ---
|
# The display string is built from the same tokens with secret-prone values redacted.
|
||||||
Write-Host "Running: 1cv8.exe $argString"
|
$displayString = $argString
|
||||||
Start-Process -FilePath $V8Path -ArgumentList $argString
|
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
|
||||||
|
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
|
||||||
|
|
||||||
|
# --- Execute (background) ---
|
||||||
|
# Redact the password/user before printing the command line — never leak secrets.
|
||||||
|
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||||
|
Write-Host "Running: 1cv8.exe $displayArg"
|
||||||
|
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||||
|
|
||||||
|
# --- Bounded early-exit check ---
|
||||||
|
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||||
|
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||||
|
# report that honestly instead of a blind "launched".
|
||||||
|
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||||
|
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
|
}
|
||||||
|
if ($proc.HasExited) {
|
||||||
|
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
|
||||||
|
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
|
||||||
|
}
|
||||||
|
Write-Host "PID: $($proc.Id)"
|
||||||
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-run v1.2 — Launch 1C:Enterprise
|
# db-run v1.8 — Launch 1C:Enterprise
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -9,6 +9,29 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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():
|
def _find_project_v8path():
|
||||||
@@ -32,6 +55,181 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
parent = os.path.dirname(p)
|
||||||
@@ -74,6 +272,15 @@ def resolve_v8path(v8path):
|
|||||||
return v8path
|
return v8path
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -90,10 +297,34 @@ def main():
|
|||||||
parser.add_argument("-Execute", default="")
|
parser.add_argument("-Execute", default="")
|
||||||
parser.add_argument("-CParam", default="")
|
parser.add_argument("-CParam", default="")
|
||||||
parser.add_argument("-URL", 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)
|
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 ---
|
# --- Validate connection ---
|
||||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||||
@@ -130,10 +361,25 @@ def main():
|
|||||||
arguments.extend(["/URL", args.URL])
|
arguments.extend(["/URL", args.URL])
|
||||||
|
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(extra_args)
|
||||||
|
|
||||||
# --- Execute (background, no wait) ---
|
# --- Execute (background) ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
# Redact the password/user before printing the command line — never leak secrets.
|
||||||
subprocess.Popen([v8path] + arguments)
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
|
proc = subprocess.Popen([v8path] + arguments)
|
||||||
|
|
||||||
|
# --- Bounded early-exit check ---
|
||||||
|
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||||
|
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||||
|
# report that honestly instead of a blind "launched".
|
||||||
|
deadline = time.monotonic() + 1.5
|
||||||
|
while time.monotonic() < deadline and proc.poll() is None:
|
||||||
|
time.sleep(0.2)
|
||||||
|
rc = proc.poll()
|
||||||
|
if rc is not None:
|
||||||
|
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr)
|
||||||
|
sys.exit(rc if rc and rc > 0 else 1)
|
||||||
|
print(f"PID: {proc.pid}")
|
||||||
print("1C:Enterprise launched")
|
print("1C:Enterprise launched")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
|||||||
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
||||||
| `-Server` | нет | Обновление на стороне сервера |
|
| `-Server` | нет | Обновление на стороне сервера |
|
||||||
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# db-update v1.6 — Update 1C database configuration
|
# db-update v1.15 — Update 1C database configuration
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -42,6 +42,12 @@
|
|||||||
.PARAMETER WarningsAsErrors
|
.PARAMETER WarningsAsErrors
|
||||||
Предупреждения считать ошибками
|
Предупреждения считать ошибками
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||||
|
|
||||||
@@ -83,12 +89,205 @@ param(
|
|||||||
[switch]$Server,
|
[switch]$Server,
|
||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[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
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExitAnnotation {
|
||||||
|
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||||
|
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||||
|
# POSIX signals are handled in the .py port.)
|
||||||
|
param([int]$Code)
|
||||||
|
$win = @{
|
||||||
|
-1073741819 = "0xC0000005 (access violation)"
|
||||||
|
-1073741515 = "0xC0000135 (missing DLL)"
|
||||||
|
-1073740791 = "0xC0000409 (stack overrun)"
|
||||||
|
}
|
||||||
|
if ($win.ContainsKey($Code)) {
|
||||||
|
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -133,35 +332,117 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$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 ---
|
# --- Validate connection ---
|
||||||
if ($engine -eq "ibcmd") {
|
if ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
@@ -191,16 +472,17 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
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
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,19 +523,21 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "update_log.txt"
|
$outFile = Join-Path $tempDir "update_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$logContent = $null
|
||||||
if (Test-Path $outFile) {
|
if (Test-Path $outFile) {
|
||||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||||
if ($logContent) {
|
if ($logContent) {
|
||||||
@@ -262,6 +546,17 @@ try {
|
|||||||
Write-Host "--- End ---"
|
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
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# db-update v1.6 — Update 1C database configuration
|
# db-update v1.15 — Update 1C database configuration
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +386,43 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||||
|
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||||
|
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||||
|
if code is None:
|
||||||
|
return ""
|
||||||
|
win = {
|
||||||
|
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||||
|
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||||
|
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||||
|
}
|
||||||
|
if code in win:
|
||||||
|
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||||
|
if -64 <= code < 0:
|
||||||
|
try:
|
||||||
|
import signal
|
||||||
|
name = signal.Signals(-code).name
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
name = f"signal {-code}"
|
||||||
|
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||||
|
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -117,12 +443,38 @@ def main():
|
|||||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||||
parser.add_argument("-Server", action="store_true")
|
parser.add_argument("-Server", action="store_true")
|
||||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||||
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)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
|
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate connection ---
|
||||||
if engine == "ibcmd":
|
if engine == "ibcmd":
|
||||||
if not args.InfoBasePath:
|
if not args.InfoBasePath:
|
||||||
@@ -151,16 +503,13 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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))
|
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {result.returncode})", file=sys.stderr)
|
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)
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
# --- Temp dir ---
|
# --- Temp dir ---
|
||||||
@@ -172,14 +521,14 @@ def main():
|
|||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||||
else:
|
else:
|
||||||
arguments.extend(["/F", args.InfoBasePath])
|
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"/P{args.Password}")
|
arguments.append(f'/P"{args.Password}"')
|
||||||
|
|
||||||
arguments.append("/UpdateDBCfg")
|
arguments.append("/UpdateDBCfg")
|
||||||
|
|
||||||
@@ -193,30 +542,28 @@ def main():
|
|||||||
|
|
||||||
# --- Extensions ---
|
# --- Extensions ---
|
||||||
if args.Extension:
|
if args.Extension:
|
||||||
arguments.extend(["-Extension", args.Extension])
|
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||||
elif args.AllExtensions:
|
elif args.AllExtensions:
|
||||||
arguments.append("-AllExtensions")
|
arguments.append("-AllExtensions")
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
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.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print("Database configuration updated successfully")
|
print("Database configuration updated successfully")
|
||||||
else:
|
else:
|
||||||
print(f"Error updating database configuration (code: {exit_code})", file=sys.stderr)
|
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||||
|
|
||||||
|
log_content = ""
|
||||||
if os.path.isfile(out_file):
|
if os.path.isfile(out_file):
|
||||||
try:
|
try:
|
||||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -228,6 +575,22 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
|||||||
| `-Password <пароль>` | нет | Пароль |
|
| `-Password <пароль>` | нет | Пароль |
|
||||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||||
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
|
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-build v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
.PARAMETER OutputFile
|
.PARAMETER OutputFile
|
||||||
Путь к выходному EPF/ERF-файлу
|
Путь к выходному EPF/ERF-файлу
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
||||||
|
|
||||||
@@ -64,12 +70,184 @@ param(
|
|||||||
[string]$SourceFile,
|
[string]$SourceFile,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$OutputFile
|
[string]$OutputFile,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Additional platform arguments ---
|
||||||
|
$script:V8OwnedKeys = @(
|
||||||
|
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||||
|
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||||
|
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||||
|
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||||
|
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||||
|
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||||
|
)
|
||||||
|
$script:IbcmdOwnedKeys = @(
|
||||||
|
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||||
|
'--import', '--export', '--apply', '--force', '--create-database',
|
||||||
|
'--user', '--password'
|
||||||
|
)
|
||||||
|
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||||
|
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||||
|
|
||||||
|
function Test-ArgKeyMatch {
|
||||||
|
# A token matches a key when it equals the key, or starts with it and the next
|
||||||
|
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||||
|
# keeping /ClearCache distinct from /C.
|
||||||
|
param([string]$Token, [string]$Key)
|
||||||
|
if ($Token.Length -lt $Key.Length) { return $false }
|
||||||
|
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||||
|
if ($Token.Length -eq $Key.Length) { return $true }
|
||||||
|
return -not [char]::IsLetter($Token[$Key.Length])
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ProjectExtraArgs {
|
||||||
|
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||||
|
param([string]$Name)
|
||||||
|
$dir = (Get-Location).Path
|
||||||
|
while ($dir) {
|
||||||
|
$pf = Join-Path $dir ".v8-project.json"
|
||||||
|
if (Test-Path $pf) {
|
||||||
|
try {
|
||||||
|
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||||
|
} catch {}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
$parent = Split-Path $dir -Parent
|
||||||
|
if (-not $parent -or $parent -eq $dir) { break }
|
||||||
|
$dir = $parent
|
||||||
|
}
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ExtraArgs {
|
||||||
|
# The platform accepts only one batch operation, and a duplicate connection or
|
||||||
|
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||||
|
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||||
|
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||||
|
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||||
|
foreach ($tok in $ExtraArgs) {
|
||||||
|
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||||
|
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
foreach ($k in $owned) {
|
||||||
|
if (Test-ArgKeyMatch $tok $k) {
|
||||||
|
$hint = ''
|
||||||
|
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||||
|
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ExtraArgs {
|
||||||
|
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||||
|
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||||
|
# simply do not apply — a project may describe both engines.
|
||||||
|
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||||
|
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||||
|
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||||
|
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||||
|
# a native array call keeps working. A value containing a comma is not supported.
|
||||||
|
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||||
|
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||||
|
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($Engine -eq 'ibcmd') {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||||
|
} else {
|
||||||
|
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||||
|
}
|
||||||
|
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||||
|
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||||
|
# would nest the array — the tokens would then be glued into one argument.
|
||||||
|
return $extra
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-ArgsForDisplay {
|
||||||
|
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||||
|
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||||
|
# a leaked password does.
|
||||||
|
param([string[]]$ArgList, [string]$Engine)
|
||||||
|
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||||
|
$res = @()
|
||||||
|
$maskNext = $false
|
||||||
|
foreach ($tok in $ArgList) {
|
||||||
|
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||||
|
$hit = $null
|
||||||
|
foreach ($k in $keys) {
|
||||||
|
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||||
|
}
|
||||||
|
if (-not $hit) { $res += $tok; continue }
|
||||||
|
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||||
|
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||||
|
else { $res += ($hit + '***') }
|
||||||
|
}
|
||||||
|
return ,$res
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CleanPath {
|
||||||
|
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||||
|
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||||
|
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||||
|
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||||
|
param([string]$Value, [string]$ParamName)
|
||||||
|
if (-not $Value) { return $Value }
|
||||||
|
$v = $Value.Trim()
|
||||||
|
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||||
|
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||||
|
}
|
||||||
|
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||||
|
if ($v.Contains('"')) {
|
||||||
|
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||||
|
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||||
|
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||||
|
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||||
|
|
||||||
|
function Assert-InfoBaseExists {
|
||||||
|
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||||
|
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||||
|
param([string]$Path)
|
||||||
|
if (-not $Path) { return }
|
||||||
|
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||||
|
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-InfoBaseExists $InfoBasePath
|
||||||
|
|
||||||
# --- Resolve V8Path ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -114,34 +292,88 @@ if (-not (Test-Path $V8Path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-OutputNonEmpty {
|
||||||
|
# Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$Path)
|
||||||
|
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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) {
|
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
|
||||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
@@ -154,8 +386,20 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
|||||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||||
Write-Host "No database specified. Creating temporary stub database..."
|
Write-Host "No database specified. Creating temporary stub database..."
|
||||||
$stubArgs = "-SourceDir `"$sourceDir`" -V8Path `"$V8Path`" -TempBasePath `"$autoBasePath`""
|
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$stubScript`" $stubArgs" -NoNewWindow -Wait -PassThru
|
# 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) {
|
if ($stubProc.ExitCode -ne 0) {
|
||||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||||
exit 1
|
exit 1
|
||||||
@@ -188,16 +432,21 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,15 +469,21 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "build_log.txt"
|
$outFile = Join-Path $tempDir "build_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -241,6 +496,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-build v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,25 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Postcondition: the platform must have produced a non-empty output file.
|
||||||
|
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||||
|
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -114,11 +390,35 @@ def main():
|
|||||||
parser.add_argument("-Password", default="", help="1C user password")
|
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("-SourceFile", required=True, help="Path to root XML source file")
|
||||||
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
|
parser.add_argument("-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 ---
|
# --- Resolve V8Path ---
|
||||||
v8path = resolve_v8path(args.V8Path)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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:
|
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
||||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr)
|
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -130,10 +430,16 @@ def main():
|
|||||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
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")
|
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||||
print("No database specified. Creating temporary stub database...")
|
print("No database specified. Creating temporary stub database...")
|
||||||
result = subprocess.run(
|
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||||
[sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path, "-TempBasePath", auto_base_path],
|
"-TempBasePath", auto_base_path]
|
||||||
capture_output=False,
|
# 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:
|
if result.returncode != 0:
|
||||||
print("Error: failed to create stub database", file=sys.stderr)
|
print("Error: failed to create stub database", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -166,50 +472,56 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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)
|
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error building external data processor/report (code: {result.returncode})", file=sys.stderr)
|
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
sys.exit(exit_code)
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||||
else:
|
else:
|
||||||
arguments += ["/F", args.InfoBasePath]
|
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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 ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||||
arguments += ["/Out", out_file]
|
arguments += ["/Out", f'"{out_file}"']
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||||
|
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Build completed successfully: {args.OutputFile}")
|
print(f"Build completed successfully: {args.OutputFile}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -224,6 +536,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -7,20 +7,191 @@ param(
|
|||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
[string]$V8Path,
|
[string]$V8Path,
|
||||||
|
|
||||||
[string]$TempBasePath
|
[string]$TempBasePath,
|
||||||
|
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[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 ---
|
# --- 1. Scan XML files for reference types ---
|
||||||
|
|
||||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||||
|
|
||||||
|
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
|
||||||
|
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
|
||||||
|
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
|
||||||
|
# запасной вариант, если корень почему-то не попался.
|
||||||
|
$srcRootVersion = ""
|
||||||
|
$srcAnyVersion = ""
|
||||||
|
|
||||||
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
||||||
foreach ($f in $xmlFiles) {
|
foreach ($f in $xmlFiles) {
|
||||||
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
|
||||||
|
$ver = $Matches[1]
|
||||||
|
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
|
||||||
|
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
|
||||||
|
$srcRootVersion = $ver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
||||||
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
||||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||||
@@ -187,7 +358,24 @@ if ($hasRefTypes) {
|
|||||||
$cfgDir = Join-Path $TempBasePath "cfg"
|
$cfgDir = Join-Path $TempBasePath "cfg"
|
||||||
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
||||||
|
|
||||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
# Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||||
|
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||||
|
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||||
|
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||||
|
# формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||||
|
#
|
||||||
|
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
|
||||||
|
$srcRank = Get-FormatRank $srcVersion
|
||||||
|
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
|
||||||
|
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
|
||||||
|
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||||
|
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||||
|
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
|
||||||
|
# формата из docs/1c-configuration-spec.md.
|
||||||
|
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
|
||||||
|
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
|
||||||
|
|
||||||
|
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="' + $stubFormatVersion + '"'
|
||||||
|
|
||||||
# GeneratedType definitions per metadata type
|
# GeneratedType definitions per metadata type
|
||||||
$gtDefs = @{
|
$gtDefs = @{
|
||||||
@@ -371,7 +559,7 @@ if ($hasRefTypes) {
|
|||||||
<Synonym/>
|
<Synonym/>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
<NamePrefix/>
|
<NamePrefix/>
|
||||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
<ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
|
||||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
<UsePurposes>
|
<UsePurposes>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
@@ -422,7 +610,7 @@ if ($hasRefTypes) {
|
|||||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
<CompatibilityMode>$stubCompatMode</CompatibilityMode>
|
||||||
<DefaultConstantsForm/>
|
<DefaultConstantsForm/>
|
||||||
</Properties>
|
</Properties>
|
||||||
<ChildObjects>$childXml
|
<ChildObjects>$childXml
|
||||||
@@ -1253,34 +1441,89 @@ $propsXml </Properties>$childObjLine
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- 5a. Stub via ibcmd (one call: create [--import --apply]) ---
|
# --- 5a. Stub via ibcmd (one call: create [--import --apply]) ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
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" }
|
$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") {
|
if ($stubEngine -eq "ibcmd") {
|
||||||
Write-Host "Creating infobase (ibcmd): $TempBasePath"
|
Write-Host "Creating infobase (ibcmd): $TempBasePath"
|
||||||
$ibData = Join-Path $env:TEMP "stub_data_$(Get-Random)"
|
$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")
|
$ibArgs = @("infobase", "create", "--db-path=$TempBasePath", "--create-database")
|
||||||
if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" }
|
if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" }
|
||||||
$ibArgs += "--data=$ibData"
|
$ibArgs += "--data=$ibData"
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $ibArgs
|
$ibArgs += $extraArgs
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $ibArgs
|
||||||
$ibOut = $__ib.Output
|
$ibOut = $__ib.Output
|
||||||
$ibRc = $__ib.ExitCode
|
$ibRc = $__ib.ExitCode
|
||||||
Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
if ($ibRc -ne 0) {
|
if ($ibRc -ne 0) {
|
||||||
if ($ibOut) { Write-Host ($ibOut | Out-String) }
|
Write-PlatformOutput $ibOut
|
||||||
Write-Error "Failed to create stub infobase (code: $ibRc)"
|
Write-Error "Failed to create stub infobase (code: $ibRc)"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -1305,9 +1549,10 @@ if ($stubEngine -eq "ibcmd") {
|
|||||||
|
|
||||||
# --- 5. Create infobase ---
|
# --- 5. Create infobase ---
|
||||||
Write-Host "Creating infobase: $TempBasePath"
|
Write-Host "Creating infobase: $TempBasePath"
|
||||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs"
|
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs" + $extraArgString
|
||||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $createArgs -NoNewWindow -Wait -PassThru
|
$proc = Invoke-PlatformProcess $V8Path @($createArgs) -PreQuoted
|
||||||
if ($proc.ExitCode -ne 0) {
|
if ($proc.ExitCode -ne 0) {
|
||||||
|
Write-PlatformOutput $proc.Output
|
||||||
Write-Error "Failed to create infobase (code: $($proc.ExitCode))"
|
Write-Error "Failed to create infobase (code: $($proc.ExitCode))"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -1318,10 +1563,11 @@ if ($hasRefTypes) {
|
|||||||
# LoadConfigFromFiles
|
# LoadConfigFromFiles
|
||||||
Write-Host "Loading configuration from files..."
|
Write-Host "Loading configuration from files..."
|
||||||
$loadLog = Join-Path $env:TEMP "stub_load_log.txt"
|
$loadLog = Join-Path $env:TEMP "stub_load_log.txt"
|
||||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs"
|
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs" + $extraArgString
|
||||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $loadArgs -NoNewWindow -Wait -PassThru
|
$proc = Invoke-PlatformProcess $V8Path @($loadArgs) -PreQuoted
|
||||||
if ($proc.ExitCode -ne 0) {
|
if ($proc.ExitCode -ne 0) {
|
||||||
if (Test-Path $loadLog) { Get-Content $loadLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
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))"
|
Write-Error "Failed to load config (code: $($proc.ExitCode))"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -1329,10 +1575,11 @@ if ($hasRefTypes) {
|
|||||||
# UpdateDBCfg
|
# UpdateDBCfg
|
||||||
Write-Host "Updating database configuration..."
|
Write-Host "Updating database configuration..."
|
||||||
$updateLog = Join-Path $env:TEMP "stub_update_log.txt"
|
$updateLog = Join-Path $env:TEMP "stub_update_log.txt"
|
||||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs"
|
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs" + $extraArgString
|
||||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $updateArgs -NoNewWindow -Wait -PassThru
|
$proc = Invoke-PlatformProcess $V8Path @($updateArgs) -PreQuoted
|
||||||
if ($proc.ExitCode -ne 0) {
|
if ($proc.ExitCode -ne 0) {
|
||||||
if (Test-Path $updateLog) { Get-Content $updateLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
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))"
|
Write-Error "Failed to update DB config (code: $($proc.ExitCode))"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""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:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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():
|
def new_uuid():
|
||||||
@@ -110,6 +339,66 @@ def scan_ref_types(source_dir):
|
|||||||
return type_map
|
return type_map
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
def detect_stub_format_version(source_dir):
|
||||||
|
"""Версия формата заглушечной конфигурации.
|
||||||
|
|
||||||
|
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||||
|
одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||||
|
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||||
|
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||||
|
формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||||
|
|
||||||
|
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
|
||||||
|
вложенные файлы — запасной вариант, если корень почему-то не попался.
|
||||||
|
"""
|
||||||
|
root_version = ""
|
||||||
|
any_version = ""
|
||||||
|
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
|
||||||
|
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
|
||||||
|
for dirpath, _, filenames in os.walk(source_dir):
|
||||||
|
for fn in filenames:
|
||||||
|
if not fn.endswith('.xml'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
m = ver_pattern.search(content)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
if not any_version:
|
||||||
|
any_version = m.group(1)
|
||||||
|
if not root_version and root_pattern.search(content):
|
||||||
|
root_version = m.group(1)
|
||||||
|
src_version = root_version or any_version or "2.17"
|
||||||
|
src_rank = format_rank(src_version)
|
||||||
|
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
|
||||||
|
|
||||||
|
|
||||||
|
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
|
||||||
|
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||||
|
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||||
|
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
|
||||||
|
# из docs/1c-configuration-spec.md.
|
||||||
|
COMPAT_BY_FORMAT = {
|
||||||
|
"2.13": "Version8_3_20",
|
||||||
|
"2.14": "Version8_3_21",
|
||||||
|
"2.15": "Version8_3_22",
|
||||||
|
"2.16": "Version8_3_23",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stub_compatibility_mode(format_version):
|
||||||
|
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
|
||||||
|
|
||||||
|
|
||||||
def scan_register_columns(source_dir):
|
def scan_register_columns(source_dir):
|
||||||
"""Scan Form.xml for register record set columns referenced via DataPath.
|
"""Scan Form.xml for register record set columns referenced via DataPath.
|
||||||
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
||||||
@@ -188,7 +477,7 @@ NS = (
|
|||||||
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
||||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
)
|
)
|
||||||
|
|
||||||
CLASS_IDS = [
|
CLASS_IDS = [
|
||||||
@@ -802,11 +1091,24 @@ def main():
|
|||||||
parser.add_argument('-SourceDir', required=True)
|
parser.add_argument('-SourceDir', required=True)
|
||||||
parser.add_argument('-V8Path', required=True)
|
parser.add_argument('-V8Path', required=True)
|
||||||
parser.add_argument('-TempBasePath', default='')
|
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)
|
type_map = scan_ref_types(args.SourceDir)
|
||||||
register_columns = scan_register_columns(args.SourceDir)
|
register_columns = scan_register_columns(args.SourceDir)
|
||||||
has_ref_types = len(type_map) > 0
|
has_ref_types = len(type_map) > 0
|
||||||
|
stub_format_version = detect_stub_format_version(args.SourceDir)
|
||||||
|
stub_compat = stub_compatibility_mode(stub_format_version)
|
||||||
|
ns_decl = f'{NS} version="{stub_format_version}"'
|
||||||
|
|
||||||
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
||||||
|
|
||||||
@@ -838,7 +1140,7 @@ def main():
|
|||||||
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
||||||
|
|
||||||
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<Configuration uuid="{uuid_cfg}">
|
\t<Configuration uuid="{uuid_cfg}">
|
||||||
\t\t<InternalInfo>{co_xml}
|
\t\t<InternalInfo>{co_xml}
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
@@ -847,7 +1149,7 @@ def main():
|
|||||||
\t\t\t<Synonym/>
|
\t\t\t<Synonym/>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
\t\t\t<NamePrefix/>
|
\t\t\t<NamePrefix/>
|
||||||
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
\t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
|
||||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||||
\t\t\t<UsePurposes>
|
\t\t\t<UsePurposes>
|
||||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
@@ -898,7 +1200,7 @@ def main():
|
|||||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||||
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||||
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
\t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
|
||||||
\t\t\t<DefaultConstantsForm/>
|
\t\t\t<DefaultConstantsForm/>
|
||||||
\t\t</Properties>
|
\t\t</Properties>
|
||||||
\t\t<ChildObjects>{child_xml}
|
\t\t<ChildObjects>{child_xml}
|
||||||
@@ -912,7 +1214,7 @@ def main():
|
|||||||
lang_dir = os.path.join(cfg_dir, 'Languages')
|
lang_dir = os.path.join(cfg_dir, 'Languages')
|
||||||
os.makedirs(lang_dir, exist_ok=True)
|
os.makedirs(lang_dir, exist_ok=True)
|
||||||
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<Language uuid="{uuid_lang}">
|
\t<Language uuid="{uuid_lang}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
||||||
@@ -1041,7 +1343,7 @@ def main():
|
|||||||
child_obj_xml = '\n\t\t<ChildObjects/>'
|
child_obj_xml = '\n\t\t<ChildObjects/>'
|
||||||
|
|
||||||
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject {NS}>
|
<MetaDataObject {ns_decl}>
|
||||||
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
{props_xml}
|
{props_xml}
|
||||||
@@ -1057,6 +1359,10 @@ def main():
|
|||||||
|
|
||||||
# Stub via ibcmd (one call: create [--import --apply])
|
# Stub via ibcmd (one call: create [--import --apply])
|
||||||
stub_engine = "ibcmd" if os.path.basename(args.V8Path).lower().startswith("ibcmd") else "1cv8"
|
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":
|
if stub_engine == "ibcmd":
|
||||||
import shutil
|
import shutil
|
||||||
print(f'Creating infobase (ibcmd): {temp_base}')
|
print(f'Creating infobase (ibcmd): {temp_base}')
|
||||||
@@ -1065,6 +1371,7 @@ def main():
|
|||||||
if has_ref_types:
|
if has_ref_types:
|
||||||
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
|
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
|
||||||
ib_args.append(f'--data={ib_data}')
|
ib_args.append(f'--data={ib_data}')
|
||||||
|
ib_args.extend(extra_args)
|
||||||
result = run_ibcmd(ib_args, warn_no_user=False)
|
result = run_ibcmd(ib_args, warn_no_user=False)
|
||||||
shutil.rmtree(ib_data, ignore_errors=True)
|
shutil.rmtree(ib_data, ignore_errors=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@@ -1083,11 +1390,10 @@ def main():
|
|||||||
|
|
||||||
# Create infobase
|
# Create infobase
|
||||||
print(f'Creating infobase: {temp_base}')
|
print(f'Creating infobase: {temp_base}')
|
||||||
result = subprocess.run(
|
result = run_v8(args.V8Path, ['CREATEINFOBASE', f'File="{temp_base}"', '/DisableStartupDialogs']
|
||||||
[args.V8Path, 'CREATEINFOBASE', f'File={temp_base}', '/DisableStartupDialogs'],
|
+ [quote_if_needed(a) for a in extra_args])
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
print_platform_output(result)
|
||||||
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
|
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@@ -1095,21 +1401,18 @@ def main():
|
|||||||
cfg_dir = os.path.join(temp_base, 'cfg')
|
cfg_dir = os.path.join(temp_base, 'cfg')
|
||||||
# LoadConfigFromFiles
|
# LoadConfigFromFiles
|
||||||
print('Loading configuration from files...')
|
print('Loading configuration from files...')
|
||||||
result = subprocess.run(
|
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"',
|
||||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/LoadConfigFromFiles', cfg_dir, '/DisableStartupDialogs'],
|
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
|
print_platform_output(result)
|
||||||
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
|
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# UpdateDBCfg
|
# UpdateDBCfg
|
||||||
print('Updating database configuration...')
|
print('Updating database configuration...')
|
||||||
update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt')
|
update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt')
|
||||||
result = subprocess.run(
|
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/UpdateDBCfg', '/Out', f'"{update_log}"',
|
||||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/UpdateDBCfg', '/Out', update_log, '/DisableStartupDialogs'],
|
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||||
capture_output=True, text=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
if os.path.isfile(update_log):
|
if os.path.isfile(update_log):
|
||||||
try:
|
try:
|
||||||
@@ -1117,6 +1420,7 @@ def main():
|
|||||||
print(f.read())
|
print(f.read())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
print_platform_output(result)
|
||||||
print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr)
|
print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
|||||||
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
|
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
|
||||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-dump v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||||
<#
|
<#
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
.PARAMETER Format
|
.PARAMETER Format
|
||||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalV8Arguments
|
||||||
|
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||||
|
|
||||||
|
.PARAMETER AdditionalIbcmdArguments
|
||||||
|
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
||||||
|
|
||||||
@@ -71,12 +77,177 @@ param(
|
|||||||
|
|
||||||
[Parameter(Mandatory=$false)]
|
[Parameter(Mandatory=$false)]
|
||||||
[ValidateSet("Hierarchical", "Plain")]
|
[ValidateSet("Hierarchical", "Plain")]
|
||||||
[string]$Format = "Hierarchical"
|
[string]$Format = "Hierarchical",
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalV8Arguments = @(),
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string[]]$AdditionalIbcmdArguments = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::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 ---
|
# --- Resolve V8Path ---
|
||||||
function Find-ProjectV8Path {
|
function Find-ProjectV8Path {
|
||||||
$dir = (Get-Location).Path
|
$dir = (Get-Location).Path
|
||||||
@@ -128,34 +299,95 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||||
function Invoke-IbcmdProcess {
|
function ConvertFrom-PlatformBytes {
|
||||||
# Run ibcmd non-interactively: a closed stdin pipe (EOF) makes ibcmd's auth prompt
|
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||||
# fast-fail instead of hanging. Returns @{ Output; ExitCode }. cp866 decodes ibcmd's
|
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||||
# native OEM output. The 1cv8/DESIGNER branch keeps using Start-Process.
|
# one of them outright mangles Cyrillic.
|
||||||
param([string]$Exe, [string[]]$IbArgs)
|
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 = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
$psi.FileName = $Exe
|
$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.UseShellExecute = $false
|
||||||
$psi.CreateNoWindow = $true
|
$psi.CreateNoWindow = $true
|
||||||
$psi.RedirectStandardInput = $true
|
$psi.RedirectStandardInput = $true
|
||||||
$psi.RedirectStandardOutput = $true
|
$psi.RedirectStandardOutput = $true
|
||||||
$psi.RedirectStandardError = $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 = [System.Diagnostics.Process]::Start($psi)
|
||||||
$p.StandardInput.Close()
|
$p.StandardInput.Close()
|
||||||
$out = $p.StandardOutput.ReadToEnd()
|
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||||
$err = $p.StandardError.ReadToEnd()
|
# 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()
|
$p.WaitForExit()
|
||||||
|
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||||
|
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||||
if ($err) { $out += $err }
|
if ($err) { $out += $err }
|
||||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Write-PlatformOutput {
|
||||||
|
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||||
|
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||||
|
param([string]$Text)
|
||||||
|
if (-not $Text) { return }
|
||||||
|
$t = $Text.TrimEnd()
|
||||||
|
if (-not $t) { return }
|
||||||
|
$limit = 65536
|
||||||
|
if ($t.Length -gt $limit) {
|
||||||
|
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||||
|
}
|
||||||
|
Write-Host "--- Вывод платформы ---"
|
||||||
|
Write-Host $t
|
||||||
|
Write-Host "--- End ---"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Test-DirNonEmpty {
|
||||||
|
# Postcondition: the platform must have written files into the output directory.
|
||||||
|
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||||
|
param([string]$Path)
|
||||||
|
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Protect-Secrets {
|
||||||
|
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||||
|
param([string]$Text, [string[]]$Secrets)
|
||||||
|
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||||
|
return $Text
|
||||||
|
}
|
||||||
|
|
||||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
$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 ($engine -eq "ibcmd") {
|
||||||
if (-not $InfoBasePath) {
|
if (-not $InfoBasePath) {
|
||||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||||
@@ -189,16 +421,21 @@ try {
|
|||||||
if ($UserName) { $arguments += "--user=$UserName" }
|
if ($UserName) { $arguments += "--user=$UserName" }
|
||||||
if ($Password) { $arguments += "--password=$Password" }
|
if ($Password) { $arguments += "--password=$Password" }
|
||||||
$arguments += "--data=$tempDir"
|
$arguments += "--data=$tempDir"
|
||||||
Write-Host "Running: ibcmd $($arguments -join ' ')"
|
$arguments += $extraArgs
|
||||||
$__ib = Invoke-IbcmdProcess $V8Path $arguments
|
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
|
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||||
$output = $__ib.Output
|
$output = $__ib.Output
|
||||||
$exitCode = $__ib.ExitCode
|
$exitCode = $__ib.ExitCode
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
if ($output) { Write-Host ($output | Out-String) }
|
Write-PlatformOutput $output
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,15 +459,21 @@ try {
|
|||||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||||
$arguments += "/Out", "`"$outFile`""
|
$arguments += "/Out", "`"$outFile`""
|
||||||
$arguments += "/DisableStartupDialogs"
|
$arguments += "/DisableStartupDialogs"
|
||||||
|
$arguments += $extraArgs
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||||
$exitCode = $process.ExitCode
|
$exitCode = $__v8.ExitCode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||||
|
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||||
|
if ($outMissing) { $exitCode = 1 }
|
||||||
if ($exitCode -eq 0) {
|
if ($exitCode -eq 0) {
|
||||||
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
||||||
|
} elseif ($outMissing) {
|
||||||
|
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
||||||
}
|
}
|
||||||
@@ -243,6 +486,7 @@ try {
|
|||||||
Write-Host "--- End ---"
|
Write-Host "--- End ---"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Write-PlatformOutput $__v8.Output
|
||||||
|
|
||||||
exit $exitCode
|
exit $exitCode
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-dump v1.6 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -14,6 +14,28 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _find_project_v8path():
|
def _find_project_v8path():
|
||||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||||
@@ -36,6 +58,163 @@ def _find_project_v8path():
|
|||||||
d = parent
|
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):
|
def _version_dir(p):
|
||||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||||
parent = os.path.dirname(p)
|
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):
|
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||||
"""Run an ibcmd command non-interactively.
|
"""Run an ibcmd command non-interactively.
|
||||||
|
|
||||||
@@ -96,7 +354,25 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
|||||||
if warn_no_user and os.name == "nt" and not has_username:
|
if warn_no_user and os.name == "nt" and not has_username:
|
||||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||||
sys.stderr.flush()
|
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):
|
||||||
|
"""Postcondition: the platform must have written files into the output directory.
|
||||||
|
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||||
|
return os.path.isdir(path) and any(os.scandir(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(text, *secrets):
|
||||||
|
"""Redact literal secret values (password, user) from a display string —
|
||||||
|
precise, never touches lookalike paths."""
|
||||||
|
for s in secrets:
|
||||||
|
if s:
|
||||||
|
text = text.replace(s, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -120,12 +396,36 @@ def main():
|
|||||||
choices=["Hierarchical", "Plain"],
|
choices=["Hierarchical", "Plain"],
|
||||||
help="Dump format (default: Hierarchical)",
|
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 ---
|
# --- Resolve V8Path ---
|
||||||
v8path = resolve_v8path(args.V8Path)
|
v8path = resolve_v8path(args.V8Path)
|
||||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
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 ---
|
# --- Validate database connection ---
|
||||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
||||||
@@ -163,51 +463,57 @@ def main():
|
|||||||
if args.Password:
|
if args.Password:
|
||||||
arguments.append(f"--password={args.Password}")
|
arguments.append(f"--password={args.Password}")
|
||||||
arguments.append(f"--data={ib_data}")
|
arguments.append(f"--data={ib_data}")
|
||||||
print(f"Running: ibcmd {' '.join(arguments)}")
|
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)
|
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||||
if result.returncode == 0:
|
exit_code = result.returncode
|
||||||
|
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
|
if exit_code == 0:
|
||||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping external data processor/report (code: {result.returncode})", file=sys.stderr)
|
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||||
if result.stdout:
|
sys.exit(exit_code)
|
||||||
print(result.stdout)
|
|
||||||
if result.stderr:
|
|
||||||
print(result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
# --- Build arguments ---
|
# --- Build arguments ---
|
||||||
arguments = ["DESIGNER"]
|
arguments = ["DESIGNER"]
|
||||||
|
|
||||||
if args.InfoBaseServer and args.InfoBaseRef:
|
if args.InfoBaseServer and args.InfoBaseRef:
|
||||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||||
else:
|
else:
|
||||||
arguments += ["/F", args.InfoBasePath]
|
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||||
|
|
||||||
if args.UserName:
|
if args.UserName:
|
||||||
arguments.append(f"/N{args.UserName}")
|
arguments.append(f'/N"{args.UserName}"')
|
||||||
if args.Password:
|
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]
|
arguments += ["-Format", args.Format]
|
||||||
|
|
||||||
# --- Output ---
|
# --- Output ---
|
||||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||||
arguments += ["/Out", out_file]
|
arguments += ["/Out", f'"{out_file}"']
|
||||||
arguments.append("/DisableStartupDialogs")
|
arguments.append("/DisableStartupDialogs")
|
||||||
|
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||||
|
|
||||||
# --- Execute ---
|
# --- Execute ---
|
||||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||||
result = subprocess.run(
|
result = run_v8(v8path, arguments)
|
||||||
[v8path] + arguments,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
exit_code = result.returncode
|
exit_code = result.returncode
|
||||||
|
|
||||||
# --- Result ---
|
# --- Result ---
|
||||||
|
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||||
|
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||||
|
if out_missing:
|
||||||
|
exit_code = 1
|
||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||||
|
elif out_missing:
|
||||||
|
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
||||||
|
|
||||||
@@ -222,6 +528,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
print_platform_output(result)
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -18,19 +18,26 @@ allowed-tools:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/epf-init <Name> [Synonym] [SrcDir]
|
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Обязательный | По умолчанию | Описание |
|
| Параметр | Обязательный | По умолчанию | Описание |
|
||||||
|-----------|:------------:|--------------|-------------------------------------|
|
|---------------|:------------:|--------------|------------------------------------------------|
|
||||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||||
|
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||||
|
|
||||||
|
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||||
|
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||||
|
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||||
|
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||||
|
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"]
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Дальнейшие шаги
|
## Дальнейшие шаги
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -6,21 +6,65 @@ param(
|
|||||||
|
|
||||||
[string]$Synonym = $Name,
|
[string]$Synonym = $Name,
|
||||||
|
|
||||||
[string]$SrcDir = "src"
|
[string]$SrcDir = "src",
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
[string]$FormatVersion = "2.17"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||||
|
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||||
|
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||||
|
# на нечисловое значение: это опечатка, а не версия.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
$formatRank = Get-FormatRank $FormatVersion
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||||
|
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||||
|
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||||
|
if ($formatRank -eq 0) {
|
||||||
|
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||||
|
}
|
||||||
|
|
||||||
$uuid1 = [guid]::NewGuid().ToString()
|
$uuid1 = [guid]::NewGuid().ToString()
|
||||||
$uuid2 = [guid]::NewGuid().ToString()
|
$uuid2 = [guid]::NewGuid().ToString()
|
||||||
$uuid3 = [guid]::NewGuid().ToString()
|
$uuid3 = [guid]::NewGuid().ToString()
|
||||||
$uuid4 = [guid]::NewGuid().ToString()
|
$uuid4 = [guid]::NewGuid().ToString()
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||||
|
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||||
|
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||||
|
if ($formatRank -ge 221) {
|
||||||
|
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
}
|
||||||
|
|
||||||
$xml = @"
|
$xml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<ExternalDataProcessor uuid="$uuid1">
|
<ExternalDataProcessor uuid="$uuid1">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -33,11 +77,11 @@ $xml = @"
|
|||||||
</xr:GeneratedType>
|
</xr:GeneratedType>
|
||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$Name</Name>
|
<Name>$(Esc-XmlText $Name)</Name>
|
||||||
<Synonym>
|
<Synonym>
|
||||||
<v8:item>
|
<v8:item>
|
||||||
<v8:lang>ru</v8:lang>
|
<v8:lang>ru</v8:lang>
|
||||||
<v8:content>$Synonym</v8:content>
|
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||||
</v8:item>
|
</v8:item>
|
||||||
</Synonym>
|
</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
@@ -64,7 +108,16 @@ $extDir = Join-Path $processorDir "Ext"
|
|||||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
|
|
||||||
@@ -83,6 +136,11 @@ $moduleBsl = @"
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||||
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||||
|
# самого скрипта, а он в репозитории хранится с LF.
|
||||||
|
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||||
|
|
||||||
Write-Host "[OK] Создана обработка: $rootFile"
|
Write-Host "[OK] Создана обработка: $rootFile"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Generates minimal XML source files for a 1C external data processor."""
|
"""Generates minimal XML source files for a 1C external data processor."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, re, argparse, uuid
|
||||||
|
|
||||||
def esc_xml(s):
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
def new_uuid():
|
def new_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
def write_utf8_bom(path, content):
|
def write_utf8_bom(path, content):
|
||||||
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -21,7 +67,24 @@ def main():
|
|||||||
parser.add_argument('-Name', dest='Name', required=True)
|
parser.add_argument('-Name', dest='Name', required=True)
|
||||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||||
args = parser.parse_args()
|
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||||
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
|
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||||
|
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||||
|
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||||
|
format_rank_value = format_rank(args.FormatVersion)
|
||||||
|
if format_rank_value == 0:
|
||||||
|
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||||
|
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||||
|
f"but was not verified on that platform", file=sys.stderr)
|
||||||
|
|
||||||
name = args.Name
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -32,8 +95,36 @@ def main():
|
|||||||
uuid3 = new_uuid()
|
uuid3 = new_uuid()
|
||||||
uuid4 = new_uuid()
|
uuid4 = new_uuid()
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||||
|
xmlns_decl = (
|
||||||
|
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||||
|
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||||
|
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||||
|
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
|
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||||
|
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||||
|
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||||
|
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||||
|
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||||
|
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||||
|
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||||
|
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||||
|
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||||
|
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||||
|
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
)
|
||||||
|
format_version = args.FormatVersion
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||||
|
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||||
|
if format_rank(format_version) >= 221:
|
||||||
|
xmlns_decl = xmlns_decl.replace(
|
||||||
|
' xmlns:style=',
|
||||||
|
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||||
|
|
||||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
\t\t\t<xr:ContainedObject>
|
\t\t\t<xr:ContainedObject>
|
||||||
@@ -46,11 +137,11 @@ def main():
|
|||||||
\t\t\t</xr:GeneratedType>
|
\t\t\t</xr:GeneratedType>
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>
|
\t\t\t<Synonym>
|
||||||
\t\t\t\t<v8:item>
|
\t\t\t\t<v8:item>
|
||||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||||
\t\t\t\t</v8:item>
|
\t\t\t\t</v8:item>
|
||||||
\t\t\t</Synonym>
|
\t\t\t</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
@@ -72,7 +163,7 @@ def main():
|
|||||||
ext_dir = os.path.join(processor_dir, "Ext")
|
ext_dir = os.path.join(processor_dir, "Ext")
|
||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_dir, exist_ok=True)
|
||||||
|
|
||||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
module_bsl = """\
|
module_bsl = """\
|
||||||
@@ -89,7 +180,10 @@ def main():
|
|||||||
#КонецОбласти"""
|
#КонецОбласти"""
|
||||||
|
|
||||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||||
write_utf8_bom(module_path, module_bsl)
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без).
|
||||||
|
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||||
|
|
||||||
print(f"[OK] Создана обработка: {root_file}")
|
print(f"[OK] Создана обработка: {root_file}")
|
||||||
print(f" Каталог: {processor_dir}")
|
print(f" Каталог: {processor_dir}")
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||||
param(
|
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 ---
|
# --- Reference tables ---
|
||||||
|
|
||||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||||
@@ -183,10 +196,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
|
$versionRank = Get-FormatRank $version
|
||||||
if (-not $version) {
|
if (-not $version) {
|
||||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
} elseif ($versionRank -eq 0) {
|
||||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
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
|
# Detect type: ExternalDataProcessor or ExternalReport
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||||
|
|
||||||
@@ -10,6 +10,28 @@ import sys
|
|||||||
from io import StringIO
|
from io import StringIO
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||||
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
def localname(el):
|
def localname(el):
|
||||||
return etree.QName(el.tag).localname
|
return etree.QName(el.tag).localname
|
||||||
|
|
||||||
@@ -50,7 +87,7 @@ def main():
|
|||||||
parser.add_argument("-Detailed", action="store_true")
|
parser.add_argument("-Detailed", action="store_true")
|
||||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||||
parser.add_argument("-OutFile", default=None)
|
parser.add_argument("-OutFile", default=None)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
max_errors = args.MaxErrors
|
max_errors = args.MaxErrors
|
||||||
|
|
||||||
@@ -163,10 +200,17 @@ def main():
|
|||||||
check1_ok = False
|
check1_ok = False
|
||||||
|
|
||||||
version = root.get("version", "")
|
version = root.get("version", "")
|
||||||
|
version_rank = format_rank(version)
|
||||||
if not version:
|
if not version:
|
||||||
report_warn("1. Missing version attribute on MetaDataObject")
|
report_warn("1. Missing version attribute on MetaDataObject")
|
||||||
elif version not in ("2.17", "2.20", "2.21"):
|
elif version_rank == 0:
|
||||||
report_warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
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
|
# Detect type
|
||||||
child_elements = []
|
child_elements = []
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
|
|||||||
| `-Password <пароль>` | нет | Пароль |
|
| `-Password <пароль>` | нет | Пароль |
|
||||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||||
| `-OutputFile <путь>` | да | Путь к выходному ERF-файлу |
|
| `-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-файлу |
|
| `-InputFile <путь>` | да | Путь к ERF-файлу |
|
||||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||||
|
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||||
|
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||||
|
|
||||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||||
|
|
||||||
|
|||||||
@@ -18,20 +18,27 @@ allowed-tools:
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
/erf-init <Name> [Synonym] [SrcDir] [--with-skd]
|
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Обязательный | По умолчанию | Описание |
|
| Параметр | Обязательный | По умолчанию | Описание |
|
||||||
|-----------|:------------:|--------------|---------------------------------------|
|
|---------------|:------------:|--------------|---------------------------------------|
|
||||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||||
|
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||||
|
|
||||||
|
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||||
|
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||||
|
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||||
|
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||||
|
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||||
|
|
||||||
## Команда
|
## Команда
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-WithSKD]
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Дальнейшие шаги
|
## Дальнейшие шаги
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# erf-init v1.1 — Init 1C external report scaffold
|
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -8,18 +8,62 @@ param(
|
|||||||
|
|
||||||
[string]$SrcDir = "src",
|
[string]$SrcDir = "src",
|
||||||
|
|
||||||
[switch]$WithSKD
|
[switch]$WithSKD,
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
[string]$FormatVersion = "2.17"
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||||
|
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||||
|
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||||
|
# на нечисловое значение: это опечатка, а не версия.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
$formatRank = Get-FormatRank $FormatVersion
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
param([string]$s)
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||||
|
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||||
|
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||||
|
if ($formatRank -eq 0) {
|
||||||
|
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||||
|
}
|
||||||
|
|
||||||
$uuid1 = [guid]::NewGuid().ToString()
|
$uuid1 = [guid]::NewGuid().ToString()
|
||||||
$uuid2 = [guid]::NewGuid().ToString()
|
$uuid2 = [guid]::NewGuid().ToString()
|
||||||
$uuid3 = [guid]::NewGuid().ToString()
|
$uuid3 = [guid]::NewGuid().ToString()
|
||||||
$uuid4 = [guid]::NewGuid().ToString()
|
$uuid4 = [guid]::NewGuid().ToString()
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||||
|
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||||
|
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||||
|
if ($formatRank -ge 221) {
|
||||||
|
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
}
|
||||||
|
|
||||||
# --- Формируем Properties ---
|
# --- Формируем Properties ---
|
||||||
|
|
||||||
$mainDCSValue = ""
|
$mainDCSValue = ""
|
||||||
@@ -48,7 +92,7 @@ $childObjectsXml = if ($childObjectsContent) {
|
|||||||
|
|
||||||
$xml = @"
|
$xml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<ExternalReport uuid="$uuid1">
|
<ExternalReport uuid="$uuid1">
|
||||||
<InternalInfo>
|
<InternalInfo>
|
||||||
<xr:ContainedObject>
|
<xr:ContainedObject>
|
||||||
@@ -61,11 +105,11 @@ $xml = @"
|
|||||||
</xr:GeneratedType>
|
</xr:GeneratedType>
|
||||||
</InternalInfo>
|
</InternalInfo>
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$Name</Name>
|
<Name>$(Esc-XmlText $Name)</Name>
|
||||||
<Synonym>
|
<Synonym>
|
||||||
<v8:item>
|
<v8:item>
|
||||||
<v8:lang>ru</v8:lang>
|
<v8:lang>ru</v8:lang>
|
||||||
<v8:content>$Synonym</v8:content>
|
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||||
</v8:item>
|
</v8:item>
|
||||||
</Synonym>
|
</Synonym>
|
||||||
<Comment/>
|
<Comment/>
|
||||||
@@ -98,7 +142,16 @@ $extDir = Join-Path $reportDir "Ext"
|
|||||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
|
|
||||||
@@ -117,6 +170,11 @@ $moduleBsl = @"
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||||
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||||
|
# самого скрипта, а он в репозитории хранится с LF.
|
||||||
|
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||||
|
|
||||||
Write-Host "[OK] Создан отчёт: $rootFile"
|
Write-Host "[OK] Создан отчёт: $rootFile"
|
||||||
@@ -136,7 +194,7 @@ if ($WithSKD) {
|
|||||||
|
|
||||||
$skdMetaXml = @"
|
$skdMetaXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||||
<Template uuid="$skdUuid">
|
<Template uuid="$skdUuid">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$skdName</Name>
|
<Name>$skdName</Name>
|
||||||
@@ -153,7 +211,7 @@ if ($WithSKD) {
|
|||||||
</MetaDataObject>
|
</MetaDataObject>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($skdMetaPath, $skdMetaXml, $enc)
|
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||||
|
|
||||||
$skdContent = @"
|
$skdContent = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
@@ -173,7 +231,7 @@ if ($WithSKD) {
|
|||||||
"@
|
"@
|
||||||
|
|
||||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||||
[System.IO.File]::WriteAllText($skdFilePath, $skdContent, $enc)
|
Write-XmlFile $skdFilePath $skdContent $enc
|
||||||
|
|
||||||
Write-Host " СКД: $skdMetaPath"
|
Write-Host " СКД: $skdMetaPath"
|
||||||
Write-Host " Тело: $skdFilePath"
|
Write-Host " Тело: $skdFilePath"
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# erf-init v1.1 — Init 1C external report scaffold
|
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Generates minimal XML source files for a 1C external report."""
|
"""Generates minimal XML source files for a 1C external report."""
|
||||||
import sys, os, argparse, uuid
|
import sys, os, re, argparse, uuid
|
||||||
|
|
||||||
def esc_xml(s):
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
def new_uuid():
|
def new_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
def write_utf8_bom(path, content):
|
def write_utf8_bom(path, content):
|
||||||
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
@@ -21,8 +67,25 @@ def main():
|
|||||||
parser.add_argument('-Name', dest='Name', required=True)
|
parser.add_argument('-Name', dest='Name', required=True)
|
||||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||||
|
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||||
|
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||||
|
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||||
|
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||||
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
|
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||||
|
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||||
|
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||||
|
format_rank_value = format_rank(args.FormatVersion)
|
||||||
|
if format_rank_value == 0:
|
||||||
|
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||||
|
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||||
|
f"but was not verified on that platform", file=sys.stderr)
|
||||||
|
|
||||||
name = args.Name
|
name = args.Name
|
||||||
synonym = args.Synonym if args.Synonym else name
|
synonym = args.Synonym if args.Synonym else name
|
||||||
@@ -33,6 +96,34 @@ def main():
|
|||||||
uuid3 = new_uuid()
|
uuid3 = new_uuid()
|
||||||
uuid4 = new_uuid()
|
uuid4 = new_uuid()
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||||
|
xmlns_decl = (
|
||||||
|
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||||
|
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||||
|
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||||
|
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
|
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||||
|
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||||
|
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||||
|
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||||
|
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||||
|
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||||
|
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||||
|
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||||
|
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||||
|
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||||
|
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
)
|
||||||
|
format_version = args.FormatVersion
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||||
|
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||||
|
if format_rank(format_version) >= 221:
|
||||||
|
xmlns_decl = xmlns_decl.replace(
|
||||||
|
' xmlns:style=',
|
||||||
|
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||||
|
|
||||||
# --- Properties ---
|
# --- Properties ---
|
||||||
main_dcs_value = ""
|
main_dcs_value = ""
|
||||||
child_objects_content = ""
|
child_objects_content = ""
|
||||||
@@ -45,7 +136,7 @@ def main():
|
|||||||
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
||||||
|
|
||||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<ExternalReport uuid="{uuid1}">
|
\t<ExternalReport uuid="{uuid1}">
|
||||||
\t\t<InternalInfo>
|
\t\t<InternalInfo>
|
||||||
\t\t\t<xr:ContainedObject>
|
\t\t\t<xr:ContainedObject>
|
||||||
@@ -58,11 +149,11 @@ def main():
|
|||||||
\t\t\t</xr:GeneratedType>
|
\t\t\t</xr:GeneratedType>
|
||||||
\t\t</InternalInfo>
|
\t\t</InternalInfo>
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||||
\t\t\t<Synonym>
|
\t\t\t<Synonym>
|
||||||
\t\t\t\t<v8:item>
|
\t\t\t\t<v8:item>
|
||||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||||
\t\t\t\t</v8:item>
|
\t\t\t\t</v8:item>
|
||||||
\t\t\t</Synonym>
|
\t\t\t</Synonym>
|
||||||
\t\t\t<Comment/>
|
\t\t\t<Comment/>
|
||||||
@@ -90,7 +181,7 @@ def main():
|
|||||||
ext_dir = os.path.join(report_dir, "Ext")
|
ext_dir = os.path.join(report_dir, "Ext")
|
||||||
os.makedirs(ext_dir, exist_ok=True)
|
os.makedirs(ext_dir, exist_ok=True)
|
||||||
|
|
||||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||||
|
|
||||||
# --- Модуль объекта ---
|
# --- Модуль объекта ---
|
||||||
module_bsl = """\
|
module_bsl = """\
|
||||||
@@ -107,7 +198,10 @@ def main():
|
|||||||
#КонецОбласти"""
|
#КонецОбласти"""
|
||||||
|
|
||||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||||
write_utf8_bom(module_path, module_bsl)
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без).
|
||||||
|
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||||
|
|
||||||
print(f"[OK] Создан отчёт: {root_file}")
|
print(f"[OK] Создан отчёт: {root_file}")
|
||||||
print(f" Каталог: {report_dir}")
|
print(f" Каталог: {report_dir}")
|
||||||
@@ -124,7 +218,7 @@ def main():
|
|||||||
skd_uuid = new_uuid()
|
skd_uuid = new_uuid()
|
||||||
|
|
||||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||||
\t<Template uuid="{skd_uuid}">
|
\t<Template uuid="{skd_uuid}">
|
||||||
\t\t<Properties>
|
\t\t<Properties>
|
||||||
\t\t\t<Name>{skd_name}</Name>
|
\t\t\t<Name>{skd_name}</Name>
|
||||||
@@ -140,7 +234,7 @@ def main():
|
|||||||
\t</Template>
|
\t</Template>
|
||||||
</MetaDataObject>'''
|
</MetaDataObject>'''
|
||||||
|
|
||||||
write_utf8_bom(skd_meta_path, skd_meta_xml)
|
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||||
|
|
||||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||||
@@ -158,7 +252,7 @@ def main():
|
|||||||
</DataCompositionSchema>'''
|
</DataCompositionSchema>'''
|
||||||
|
|
||||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||||
write_utf8_bom(skd_file_path, skd_content)
|
write_xml_file(skd_file_path, skd_content)
|
||||||
|
|
||||||
print(f" СКД: {skd_meta_path}")
|
print(f" СКД: {skd_meta_path}")
|
||||||
print(f" Тело: {skd_file_path}")
|
print(f" Тело: {skd_file_path}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-add v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -33,6 +33,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -69,10 +79,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -141,9 +154,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
$cfgPath = Join-Path $d "Configuration.xml"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$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] }
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
}
|
}
|
||||||
$parent = Split-Path $d -Parent
|
$parent = Split-Path $d -Parent
|
||||||
@@ -153,6 +177,13 @@ function Detect-FormatVersion([string]$dir) {
|
|||||||
return "2.17"
|
return "2.17"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||||
|
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- Фаза 1: Определение типа объекта ---
|
# --- Фаза 1: Определение типа объекта ---
|
||||||
|
|
||||||
# Resolve ObjectPath (directory → .xml)
|
# Resolve ObjectPath (directory → .xml)
|
||||||
@@ -174,7 +205,26 @@ if (-not (Test-Path $ObjectPath)) {
|
|||||||
|
|
||||||
$objectXmlFull = Resolve-Path $ObjectPath
|
$objectXmlFull = Resolve-Path $ObjectPath
|
||||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||||
|
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||||
|
$script:formatVersion = $null
|
||||||
|
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
|
||||||
|
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
|
||||||
|
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
|
||||||
|
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||||
|
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||||
|
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||||
|
# дописать в конец нельзя.
|
||||||
|
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||||
|
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
}
|
||||||
|
|
||||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||||
$xmlDoc.PreserveWhitespace = $true
|
$xmlDoc.PreserveWhitespace = $true
|
||||||
@@ -297,9 +347,16 @@ if ($objectType -in $processorLikeTypes) {
|
|||||||
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||||
|
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||||
|
$useInIfcLine = ""
|
||||||
|
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||||
|
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
|
||||||
|
}
|
||||||
|
|
||||||
$formMetaXml = @"
|
$formMetaXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||||
<Form uuid="$formUuid">
|
<Form uuid="$formUuid">
|
||||||
<Properties>
|
<Properties>
|
||||||
<Name>$FormName</Name>
|
<Name>$FormName</Name>
|
||||||
@@ -315,20 +372,29 @@ $formMetaXml = @"
|
|||||||
<UsePurposes>
|
<UsePurposes>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||||
</UsePurposes>$extPresentationLine
|
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||||
</Properties>
|
</Properties>
|
||||||
</Form>
|
</Form>
|
||||||
</MetaDataObject>
|
</MetaDataObject>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($formMetaPath, $formMetaXml, $encBom)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
#
|
||||||
|
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile $formMetaPath $formMetaXml $encBom
|
||||||
|
|
||||||
# --- 3b. Form.xml ---
|
# --- 3b. Form.xml ---
|
||||||
|
|
||||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||||
|
|
||||||
$formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
||||||
|
|
||||||
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||||
# Динамический список
|
# Динамический список
|
||||||
# MainTable: тип.имя
|
# MainTable: тип.имя
|
||||||
@@ -336,7 +402,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
|||||||
|
|
||||||
$formXml = @"
|
$formXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||||
<Autofill>true</Autofill>
|
<Autofill>true</Autofill>
|
||||||
</AutoCommandBar>
|
</AutoCommandBar>
|
||||||
@@ -361,7 +427,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
|||||||
|
|
||||||
$formXml = @"
|
$formXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||||
<Autofill>true</Autofill>
|
<Autofill>true</Autofill>
|
||||||
</AutoCommandBar>
|
</AutoCommandBar>
|
||||||
@@ -408,7 +474,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
|||||||
|
|
||||||
$formXml = @"
|
$formXml = @"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Form $formNsDecl version="$($script:formatVersion)">
|
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||||
<Autofill>true</Autofill>
|
<Autofill>true</Autofill>
|
||||||
</AutoCommandBar>
|
</AutoCommandBar>
|
||||||
@@ -428,7 +494,7 @@ if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
|||||||
if (Test-Path $formXmlPath) {
|
if (Test-Path $formXmlPath) {
|
||||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||||
} else {
|
} else {
|
||||||
[System.IO.File]::WriteAllText($formXmlPath, $formXml, $encBom)
|
Write-XmlFile $formXmlPath $formXml $encBom
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- 3c. Module.bsl ---
|
# --- 3c. Module.bsl ---
|
||||||
@@ -460,6 +526,11 @@ $moduleBsl = @"
|
|||||||
if (Test-Path $modulePath) {
|
if (Test-Path $modulePath) {
|
||||||
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
||||||
} else {
|
} else {
|
||||||
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||||
|
# самого скрипта, а он в репозитории хранится с LF.
|
||||||
|
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,7 +542,10 @@ if (-not $childObjects) {
|
|||||||
exit 1
|
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 = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
|
||||||
$formElem.InnerText = $FormName
|
$formElem.InnerText = $FormName
|
||||||
|
|
||||||
@@ -525,6 +599,7 @@ if ($insertBefore) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- SetDefault ---
|
# --- SetDefault ---
|
||||||
|
|
||||||
@@ -565,12 +640,27 @@ if ($SetDefault -or $isFirstFormForPurpose) {
|
|||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = $encBom
|
$settings.Encoding = $encBom
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
|
|
||||||
$stream = New-Object System.IO.FileStream($objectXmlFull.Path, [System.IO.FileMode]::Create)
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
$xmlDoc.Save($writer)
|
$xmlDoc.Save($writer)
|
||||||
$writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
$stream.Close()
|
|
||||||
|
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||||
|
$memStream.Close()
|
||||||
|
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||||
|
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
|
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
|
||||||
|
|
||||||
# --- Фаза 5: Вывод ---
|
# --- Фаза 5: Вывод ---
|
||||||
|
|
||||||
@@ -590,7 +680,11 @@ Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
|
|||||||
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
|
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
|
||||||
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
|
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
if ($alreadyRegistered) {
|
||||||
|
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
|
||||||
|
} else {
|
||||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||||
|
}
|
||||||
if ($defaultUpdated) {
|
if ($defaultUpdated) {
|
||||||
Write-Host "${defaultPropName}: $defaultValue"
|
Write-Host "${defaultPropName}: $defaultValue"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-add v1.8 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -11,6 +11,28 @@ import uuid
|
|||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
@@ -32,6 +54,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -71,6 +105,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -78,6 +115,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -179,6 +218,16 @@ NSMAP = {
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while d:
|
while d:
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
ext_path = d + ".xml"
|
||||||
|
if os.path.isfile(ext_path):
|
||||||
|
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
ext_head = f.read(2000)
|
||||||
|
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
cfg_path = os.path.join(d, "Configuration.xml")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -193,21 +242,78 @@ def detect_format_version(d):
|
|||||||
return "2.17"
|
return "2.17"
|
||||||
|
|
||||||
|
|
||||||
def save_xml_with_bom(tree, path):
|
def format_rank(ver):
|
||||||
"""Save XML tree to file with UTF-8 BOM."""
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
if not xml_bytes.endswith(b"\n"):
|
|
||||||
|
|
||||||
|
def _detect_xml_style(path):
|
||||||
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
|
try:
|
||||||
|
raw = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
body = raw[3:] if bom else raw
|
||||||
|
crlf = b"\r\n" in body
|
||||||
|
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||||
|
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||||
|
final_nl = body.endswith(b"\n")
|
||||||
|
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
|
xml_bytes = xml_bytes.replace(
|
||||||
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||||
|
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||||
|
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||||
|
want_final_nl = style["final_nl"] if style else False
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if want_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
|
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||||
|
if (style["crlf"] if style else True):
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
return xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def save_xml_with_bom(tree, path):
|
||||||
|
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||||
|
style = _detect_xml_style(path)
|
||||||
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
|
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
|
if style is None or style["bom"]:
|
||||||
f.write(b"\xef\xbb\xbf")
|
f.write(b"\xef\xbb\xbf")
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|
||||||
def write_text_with_bom(path, text):
|
def write_utf8_bom(path, content):
|
||||||
"""Write text to file with UTF-8 BOM."""
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
with open(path, "w", encoding="utf-8-sig") as f:
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
f.write(text)
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
|
||||||
|
Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -219,7 +325,7 @@ def main():
|
|||||||
parser.add_argument("-Synonym", default=None)
|
parser.add_argument("-Synonym", default=None)
|
||||||
parser.add_argument("-Purpose", default="Object")
|
parser.add_argument("-Purpose", default="Object")
|
||||||
parser.add_argument("-SetDefault", action="store_true")
|
parser.add_argument("-SetDefault", action="store_true")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
object_path = args.ObjectPath
|
object_path = args.ObjectPath
|
||||||
form_name = args.FormName
|
form_name = args.FormName
|
||||||
@@ -246,8 +352,65 @@ def main():
|
|||||||
|
|
||||||
object_xml_full = os.path.abspath(object_path)
|
object_xml_full = os.path.abspath(object_path)
|
||||||
assert_edit_allowed(object_xml_full, "editable")
|
assert_edit_allowed(object_xml_full, "editable")
|
||||||
|
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||||
|
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||||
|
format_version = None
|
||||||
|
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
|
||||||
|
obj_head = f.read(2000)
|
||||||
|
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
|
||||||
|
if m_ver:
|
||||||
|
format_version = m_ver.group(1)
|
||||||
|
if not format_version:
|
||||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||||
|
|
||||||
|
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||||
|
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||||
|
xmlns_decl = (
|
||||||
|
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||||
|
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||||
|
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||||
|
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
|
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||||
|
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||||
|
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||||
|
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||||
|
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||||
|
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||||
|
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||||
|
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||||
|
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||||
|
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||||
|
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
)
|
||||||
|
form_ns_decl = (
|
||||||
|
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||||
|
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||||
|
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||||
|
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||||
|
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
|
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||||
|
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||||
|
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||||
|
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||||
|
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||||
|
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||||
|
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||||
|
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||||
|
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||||
|
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||||
|
# дописать в конец нельзя.
|
||||||
|
if format_rank(format_version) >= 221:
|
||||||
|
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||||
|
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
|
||||||
|
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
|
||||||
|
|
||||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||||
tree = etree.parse(object_xml_full, parser_xml)
|
tree = etree.parse(object_xml_full, parser_xml)
|
||||||
root = tree.getroot()
|
root = tree.getroot()
|
||||||
@@ -335,24 +498,7 @@ def main():
|
|||||||
|
|
||||||
form_meta_xml = (
|
form_meta_xml = (
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
'<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
|
||||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
|
||||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
|
||||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
|
||||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
|
||||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
|
||||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
|
||||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
|
||||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
|
||||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
|
||||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
|
||||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
|
||||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
|
||||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
|
||||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
|
||||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
|
||||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
||||||
f' version="{format_version}">\n'
|
|
||||||
f'\t<Form uuid="{form_uuid}">\n'
|
f'\t<Form uuid="{form_uuid}">\n'
|
||||||
'\t\t<Properties>\n'
|
'\t\t<Properties>\n'
|
||||||
f'\t\t\t<Name>{form_name}</Name>\n'
|
f'\t\t\t<Name>{form_name}</Name>\n'
|
||||||
@@ -369,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">PlatformApplication</v8:Value>\n'
|
||||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
||||||
'\t\t\t</UsePurposes>\n'
|
'\t\t\t</UsePurposes>\n'
|
||||||
|
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||||
|
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||||
|
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
|
||||||
|
if format_rank(format_version) >= 221 else '')
|
||||||
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
||||||
+ '\t\t</Properties>\n'
|
+ '\t\t</Properties>\n'
|
||||||
'\t</Form>\n'
|
'\t</Form>\n'
|
||||||
'</MetaDataObject>'
|
'</MetaDataObject>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(form_meta_path, form_meta_xml)
|
write_xml_file(form_meta_path, form_meta_xml)
|
||||||
|
|
||||||
# --- 3b. Form.xml ---
|
# --- 3b. Form.xml ---
|
||||||
|
|
||||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||||
|
|
||||||
form_ns_decl = (
|
|
||||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
|
||||||
' 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"):
|
if purpose in ("List", "Choice"):
|
||||||
# Dynamic list
|
# Dynamic list
|
||||||
main_table = f"{object_type}.{object_name}"
|
main_table = f"{object_type}.{object_name}"
|
||||||
@@ -498,7 +629,7 @@ def main():
|
|||||||
if os.path.exists(form_xml_path):
|
if os.path.exists(form_xml_path):
|
||||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||||
else:
|
else:
|
||||||
write_text_with_bom(form_xml_path, form_xml)
|
write_xml_file(form_xml_path, form_xml)
|
||||||
|
|
||||||
# --- 3c. Module.bsl ---
|
# --- 3c. Module.bsl ---
|
||||||
|
|
||||||
@@ -529,7 +660,10 @@ def main():
|
|||||||
if os.path.exists(module_path):
|
if os.path.exists(module_path):
|
||||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||||
else:
|
else:
|
||||||
write_text_with_bom(module_path, module_bsl)
|
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||||
|
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||||
|
# неканоничен (1235 модулей с ним, 766 без).
|
||||||
|
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||||
|
|
||||||
# --- Phase 4: Register in parent object ---
|
# --- Phase 4: Register in parent object ---
|
||||||
|
|
||||||
@@ -539,7 +673,10 @@ def main():
|
|||||||
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Add <Form>$FormName</Form>
|
# Add <Form>$FormName</Form> — idempotent (do not duplicate already-registered form)
|
||||||
|
already_registered = child_objects.find(f"md:Form[.='{form_name}']", NSMAP) is not None
|
||||||
|
|
||||||
|
if not already_registered:
|
||||||
form_elem = etree.Element(f"{{{ns}}}Form")
|
form_elem = etree.Element(f"{{{ns}}}Form")
|
||||||
form_elem.text = form_name
|
form_elem.text = form_name
|
||||||
|
|
||||||
@@ -624,6 +761,9 @@ def main():
|
|||||||
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
|
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
|
||||||
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
|
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
|
||||||
print()
|
print()
|
||||||
|
if already_registered:
|
||||||
|
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
|
||||||
|
else:
|
||||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||||
if default_updated:
|
if default_updated:
|
||||||
print(f"{default_prop_name}: {default_value}")
|
print(f"{default_prop_name}: {default_value}")
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
|
|||||||
| `showTitle: true` | Показывать заголовок группы |
|
| `showTitle: true` | Показывать заголовок группы |
|
||||||
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
|
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
|
||||||
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
|
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
|
||||||
|
| `controlRepresentation` | Отображение управления свёрткой (`behavior: "collapsible"`): `"TitleHyperlink"` (гиперссылка заголовка, по умолчанию) / `"Picture"` (картинка) |
|
||||||
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
|
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
|
||||||
| `children: [...]` | Вложенные элементы |
|
| `children: [...]` | Вложенные элементы |
|
||||||
|
|
||||||
@@ -549,8 +550,8 @@ PictureField, привязанный к булеву/числу, рисует и
|
|||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. **Компиляция**: `/form-compile` генерирует `Form.xml` и автоматически регистрирует `<Form>` в `ChildObjects` родительского объекта (если OutputPath следует конвенции `.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml`).
|
1. **Каркас**: `/form-add` создаёт метаданные формы (`ФормаСписка.xml`), `Module.bsl` и регистрирует форму у объекта.
|
||||||
2. **Метаданные формы** (`ФормаСписка.xml`) и `Module.bsl` создаёт `/form-add`. Если `/form-add` ещё не вызывался — вызови после `/form-compile`. Он не перезаписывает существующий Form.xml.
|
2. **Компиляция**: `/form-compile` наполняет `Form.xml` элементами.
|
||||||
3. **Проверка**: `/form-validate`, `/form-info`.
|
3. **Проверка**: `/form-validate`, `/form-info`.
|
||||||
|
|
||||||
## Верификация
|
## Верификация
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||||
param(
|
param(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
# form-decompile v0.149 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||||
#
|
#
|
||||||
@@ -13,6 +13,28 @@ import xml.etree.ElementTree as ET
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- 1. Namespaces ---
|
# --- 1. Namespaces ---
|
||||||
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
||||||
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
||||||
@@ -81,29 +103,29 @@ def _attr(node, name, ns_uri=None):
|
|||||||
def convert_string_to_json_literal(s):
|
def convert_string_to_json_literal(s):
|
||||||
if s is None:
|
if s is None:
|
||||||
return 'null'
|
return 'null'
|
||||||
sb = ['"']
|
out = ['"']
|
||||||
for ch in s:
|
for ch in s:
|
||||||
code = ord(ch)
|
code = ord(ch)
|
||||||
if code == 0x22:
|
if code == 0x22:
|
||||||
sb.append('\\"')
|
out.append('\\"')
|
||||||
elif code == 0x5C:
|
elif code == 0x5C:
|
||||||
sb.append('\\\\')
|
out.append('\\\\')
|
||||||
elif code == 0x08:
|
elif code == 0x08:
|
||||||
sb.append('\\b')
|
out.append('\\b')
|
||||||
elif code == 0x09:
|
elif code == 0x09:
|
||||||
sb.append('\\t')
|
out.append('\\t')
|
||||||
elif code == 0x0A:
|
elif code == 0x0A:
|
||||||
sb.append('\\n')
|
out.append('\\n')
|
||||||
elif code == 0x0C:
|
elif code == 0x0C:
|
||||||
sb.append('\\f')
|
out.append('\\f')
|
||||||
elif code == 0x0D:
|
elif code == 0x0D:
|
||||||
sb.append('\\r')
|
out.append('\\r')
|
||||||
elif code < 0x20:
|
elif code < 0x20:
|
||||||
sb.append('\\u%04x' % code)
|
out.append('\\u%04x' % code)
|
||||||
else:
|
else:
|
||||||
sb.append(ch)
|
out.append(ch)
|
||||||
sb.append('"')
|
out.append('"')
|
||||||
return ''.join(sb)
|
return ''.join(out)
|
||||||
|
|
||||||
|
|
||||||
def _num_to_str(obj):
|
def _num_to_str(obj):
|
||||||
@@ -3115,7 +3137,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
||||||
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
||||||
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
output_path = args.OutputPath
|
output_path = args.OutputPath
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-edit v1.3 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -27,6 +27,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -63,10 +73,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -258,15 +271,24 @@ function X {
|
|||||||
|
|
||||||
function Esc-Xml {
|
function Esc-Xml {
|
||||||
param([string]$s)
|
param([string]$s)
|
||||||
|
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Esc-XmlText {
|
||||||
|
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
||||||
|
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
||||||
|
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
||||||
|
param([string]$s)
|
||||||
|
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||||
|
}
|
||||||
|
|
||||||
function Emit-MLText {
|
function Emit-MLText {
|
||||||
param([string]$tag, [string]$text, [string]$indent)
|
param([string]$tag, [string]$text, [string]$indent)
|
||||||
X "$indent<$tag>"
|
X "$indent<$tag>"
|
||||||
X "$indent`t<v8:item>"
|
X "$indent`t<v8:item>"
|
||||||
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
||||||
X "$indent`t`t<v8:content>$(Esc-Xml $text)</v8:content>"
|
X "$indent`t`t<v8:content>$(Esc-XmlText $text)</v8:content>"
|
||||||
X "$indent`t</v8:item>"
|
X "$indent`t</v8:item>"
|
||||||
X "$indent</$tag>"
|
X "$indent</$tag>"
|
||||||
}
|
}
|
||||||
@@ -295,24 +317,49 @@ $script:formTypeSynonyms["бизнеспроцессссылка"] = "
|
|||||||
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
||||||
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
||||||
|
|
||||||
|
# Алиас на локальный словарь: тело Resolve-TypeStr ниже — общая реализация,
|
||||||
|
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||||
|
$script:typeSynonyms = $script:formTypeSynonyms
|
||||||
|
|
||||||
function Resolve-TypeStr {
|
function Resolve-TypeStr {
|
||||||
param([string]$typeStr)
|
param([string]$typeStr)
|
||||||
if (-not $typeStr) { return $typeStr }
|
if (-not $typeStr) { return $typeStr }
|
||||||
|
|
||||||
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if ($typeStr.StartsWith('cfg:')) {
|
||||||
|
$typeStr = $typeStr.Substring(4)
|
||||||
|
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||||
|
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||||
$base = $Matches[1].Trim(); $params = $Matches[2]
|
$baseName = $Matches[1].Trim()
|
||||||
$r = $script:formTypeSynonyms[$base.ToLower()]
|
$params = $Matches[2]
|
||||||
if ($r) { return "$r($params)" }
|
$resolved = $script:typeSynonyms[$baseName.ToLower()]
|
||||||
|
if ($resolved) { return "$resolved($params)" }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||||
if ($typeStr.Contains('.')) {
|
if ($typeStr.Contains('.')) {
|
||||||
$i = $typeStr.IndexOf('.')
|
$dotIdx = $typeStr.IndexOf('.')
|
||||||
$prefix = $typeStr.Substring(0, $i); $suffix = $typeStr.Substring($i)
|
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||||
$r = $script:formTypeSynonyms[$prefix.ToLower()]
|
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||||
if ($r) { return "$r$suffix" }
|
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||||
|
if ($resolved) { return "$resolved$suffix" }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
$r = $script:formTypeSynonyms[$typeStr.ToLower()]
|
|
||||||
if ($r) { return $r }
|
# Простое имя
|
||||||
|
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||||
|
if ($resolved) { return $resolved }
|
||||||
return $typeStr
|
return $typeStr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +623,7 @@ function Emit-Label {
|
|||||||
X "$inner<Title formatted=`"$formatted`">"
|
X "$inner<Title formatted=`"$formatted`">"
|
||||||
X "$inner`t<v8:item>"
|
X "$inner`t<v8:item>"
|
||||||
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
||||||
X "$inner`t`t<v8:content>$(Esc-Xml "$($el.title)")</v8:content>"
|
X "$inner`t`t<v8:content>$(Esc-XmlText "$($el.title)")</v8:content>"
|
||||||
X "$inner`t</v8:item>"
|
X "$inner`t</v8:item>"
|
||||||
X "$inner</Title>"
|
X "$inner</Title>"
|
||||||
}
|
}
|
||||||
@@ -1371,8 +1418,16 @@ if ($def.elementEvents -and $def.elementEvents.Count -gt 0) {
|
|||||||
$content = $xmlDoc.OuterXml
|
$content = $xmlDoc.OuterXml
|
||||||
# Ensure encoding declaration is uppercase UTF-8
|
# Ensure encoding declaration is uppercase UTF-8
|
||||||
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$content = [regex]::Replace($content, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
|
||||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $resolvedFormPath) -and ([System.IO.File]::ReadAllText($resolvedFormPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$content = ($content -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
||||||
|
|
||||||
# === 14. Summary ===
|
# === 14. Summary ===
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-edit v1.3 — 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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -11,6 +11,65 @@ from lxml import etree
|
|||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
class CIDict(dict):
|
||||||
|
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||||
|
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||||
|
# сохраняется — от него зависит порядок эмиссии.
|
||||||
|
def _actual(self, key):
|
||||||
|
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||||
|
return key
|
||||||
|
ci = self.__dict__.get('_ci')
|
||||||
|
if ci is None or len(ci) != len(self):
|
||||||
|
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||||
|
self.__dict__['_ci'] = ci
|
||||||
|
return ci.get(key.lower(), key)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return dict.__getitem__(self, self._actual(key))
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return dict.__contains__(self, self._actual(key))
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, self._actual(key), default)
|
||||||
|
|
||||||
|
def pop(self, key, *default):
|
||||||
|
return dict.pop(self, self._actual(key), *default)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||||
|
dict.__setitem__(self, self._actual(key), value)
|
||||||
|
|
||||||
|
def ci_json(obj):
|
||||||
|
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [ci_json(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||||
@@ -31,6 +90,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +141,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +151,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -175,7 +251,7 @@ def assert_edit_allowed(target_path, require):
|
|||||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||||
parser.add_argument("-FormPath", "-Path", required=True)
|
parser.add_argument("-FormPath", "-Path", required=True)
|
||||||
parser.add_argument("-JsonPath", required=True)
|
parser.add_argument("-JsonPath", required=True)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
json_path = args.JsonPath
|
json_path = args.JsonPath
|
||||||
@@ -209,9 +285,16 @@ def local_name(node):
|
|||||||
# ── helpers ──────────────────────────────────────────────────
|
# ── helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def esc_xml(s):
|
def esc_xml(s):
|
||||||
|
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||||||
|
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
|
||||||
# ── 1. Load Form.xml ────────────────────────────────────────
|
# ── 1. Load Form.xml ────────────────────────────────────────
|
||||||
|
|
||||||
if not os.path.exists(form_path):
|
if not os.path.exists(form_path):
|
||||||
@@ -235,7 +318,7 @@ root = tree.getroot()
|
|||||||
# ── 2. Load JSON ────────────────────────────────────────────
|
# ── 2. Load JSON ────────────────────────────────────────────
|
||||||
|
|
||||||
with open(json_path, "r", encoding="utf-8-sig") as f:
|
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 ───────────────────────────────────
|
# ── 3. Form name + header ───────────────────────────────────
|
||||||
|
|
||||||
@@ -367,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):
|
def resolve_type_str(type_str):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
return type_str
|
return type_str
|
||||||
|
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||||
|
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||||
|
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||||
|
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||||
|
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||||
|
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||||
|
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||||
|
if type_str.startswith('cfg:'):
|
||||||
|
type_str = type_str[4:]
|
||||||
|
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||||
|
type_str = type_str[type_str.index(':') + 1:]
|
||||||
|
# Параметризованные типы: Number(15,2), Строка(100)
|
||||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||||
if m:
|
if m:
|
||||||
base, params = m.group(1).strip(), m.group(2)
|
base_name = m.group(1).strip()
|
||||||
r = _FORM_TYPE_SYNONYMS.get(base.lower())
|
params = m.group(2)
|
||||||
return f"{r}({params})" if r else type_str
|
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||||
|
if resolved:
|
||||||
|
return f'{resolved}({params})'
|
||||||
|
return type_str
|
||||||
|
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||||
if '.' in type_str:
|
if '.' in type_str:
|
||||||
i = type_str.index('.')
|
dot_idx = type_str.index('.')
|
||||||
prefix, suffix = type_str[:i], type_str[i:]
|
prefix = type_str[:dot_idx]
|
||||||
r = _FORM_TYPE_SYNONYMS.get(prefix.lower())
|
suffix = type_str[dot_idx:] # includes the dot
|
||||||
return f"{r}{suffix}" if r else type_str
|
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||||
r = _FORM_TYPE_SYNONYMS.get(type_str.lower())
|
if resolved:
|
||||||
return r if r else type_str
|
return f'{resolved}{suffix}'
|
||||||
|
return type_str
|
||||||
|
# Простое имя
|
||||||
|
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
return type_str
|
||||||
def emit_type(type_str, indent):
|
def emit_type(type_str, indent):
|
||||||
if not type_str:
|
if not type_str:
|
||||||
X(f"{indent}<Type/>")
|
X(f"{indent}<Type/>")
|
||||||
@@ -477,7 +585,7 @@ def emit_mltext(tag, text, indent):
|
|||||||
X(f"{indent}<{tag}>")
|
X(f"{indent}<{tag}>")
|
||||||
X(f"{indent}\t<v8:item>")
|
X(f"{indent}\t<v8:item>")
|
||||||
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
||||||
X(f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>")
|
X(f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>")
|
||||||
X(f"{indent}\t</v8:item>")
|
X(f"{indent}\t</v8:item>")
|
||||||
X(f"{indent}</{tag}>")
|
X(f"{indent}</{tag}>")
|
||||||
|
|
||||||
@@ -705,7 +813,7 @@ def emit_label(el, name, _id, indent):
|
|||||||
X(f'{inner}<Title formatted="{formatted}">')
|
X(f'{inner}<Title formatted="{formatted}">')
|
||||||
X(f"{inner}\t<v8:item>")
|
X(f"{inner}\t<v8:item>")
|
||||||
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
||||||
X(f"{inner}\t\t<v8:content>{esc_xml(str(el['title']))}</v8:content>")
|
X(f"{inner}\t\t<v8:content>{esc_xml_text(str(el['title']))}</v8:content>")
|
||||||
X(f"{inner}\t</v8:item>")
|
X(f"{inner}\t</v8:item>")
|
||||||
X(f"{inner}</Title>")
|
X(f"{inner}</Title>")
|
||||||
emit_common_flags(el, inner)
|
emit_common_flags(el, inner)
|
||||||
@@ -1458,13 +1566,39 @@ if elem_events_list:
|
|||||||
|
|
||||||
# ── 13. Save ────────────────────────────────────────────────
|
# ── 13. Save ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Round-trip: определить стиль исходного файла (на диске он ещё не перезаписан).
|
||||||
|
try:
|
||||||
|
_fe_raw = open(resolved_form_path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
_fe_raw = None
|
||||||
|
if _fe_raw is not None:
|
||||||
|
_fe_bom = _fe_raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
_fe_body = _fe_raw[3:] if _fe_bom else _fe_raw
|
||||||
|
_fe_crlf = b"\r\n" in _fe_body
|
||||||
|
_fe_enc_m = re.search(rb'encoding="([^"]+)"', _fe_body[:200])
|
||||||
|
_fe_enc = _fe_enc_m.group(1).decode("ascii") if _fe_enc_m else "utf-8"
|
||||||
|
_fe_final_nl = _fe_body.endswith(b"\n")
|
||||||
|
else:
|
||||||
|
_fe_bom, _fe_crlf, _fe_enc, _fe_final_nl = True, False, "utf-8", True
|
||||||
|
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
# Fix XML declaration quotes
|
# Восстановить регистр encoding как в оригинале.
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
xml_bytes = xml_bytes.replace(
|
||||||
if not xml_bytes.endswith(b"\n"):
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + _fe_enc.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"))
|
||||||
|
# Финальный перенос — как в оригинале.
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if _fe_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
# Write with BOM
|
# EOL — как в оригинале.
|
||||||
|
if _fe_crlf:
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
# Write preserving BOM as in original.
|
||||||
with open(resolved_form_path, "wb") as f:
|
with open(resolved_form_path, "wb") as f:
|
||||||
|
if _fe_bom:
|
||||||
f.write(b'\xef\xbb\xbf')
|
f.write(b'\xef\xbb\xbf')
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-info v1.4 — Analyze 1C managed form structure
|
# form-info v1.7 — Analyze 1C managed form structure (+единое имя хелпера состояния поддержки)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -372,6 +372,16 @@ if ($formsIdx -ge 0 -and ($formsIdx + 1) -lt $parts.Count) {
|
|||||||
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
|
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
|
||||||
# uuid of the nearest element meta-xml (form/template/etc.) and the config root
|
# uuid of the nearest element meta-xml (form/template/etc.) and the config root
|
||||||
# bin. Never throws — degrades to "не на поддержке".
|
# bin. Never throws — degrades to "не на поддержке".
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -390,8 +400,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -448,7 +460,8 @@ if ($formTitle) { $header += " — `"$formTitle`"" }
|
|||||||
if ($objectContext) { $header += " ($objectContext)" }
|
if ($objectContext) { $header += " ($objectContext)" }
|
||||||
$header += " ==="
|
$header += " ==="
|
||||||
$lines += $header
|
$lines += $header
|
||||||
$lines += "Поддержка: $(Get-SupportStatusForPath $FormPath)"
|
$support = Get-SupportStatusForPath $FormPath
|
||||||
|
if ($null -ne $support) { $lines += "Поддержка: $support" }
|
||||||
|
|
||||||
# --- Form properties (Title excluded — shown in header) ---
|
# --- Form properties (Title excluded — shown in header) ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-info v1.4 — Analyze 1C managed form structure
|
# form-info v1.7 — Analyze 1C managed form structure (+единое имя хелпера состояния поддержки)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -8,6 +8,28 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
# --- Namespace map ---
|
# --- Namespace map ---
|
||||||
|
|
||||||
NSMAP = {
|
NSMAP = {
|
||||||
@@ -353,14 +375,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -418,7 +455,7 @@ def main():
|
|||||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||||
parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
|
parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
|
||||||
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
limit = args.Limit
|
limit = args.Limit
|
||||||
@@ -513,7 +550,9 @@ def main():
|
|||||||
header += f" ({object_context})"
|
header += f" ({object_context})"
|
||||||
header += " ==="
|
header += " ==="
|
||||||
lines.append(header)
|
lines.append(header)
|
||||||
lines.append(f"Поддержка: {get_support_status_for_path(form_path)}")
|
_support = get_support_status_for_path(form_path)
|
||||||
|
if _support is not None:
|
||||||
|
lines.append(f"Поддержка: {_support}")
|
||||||
|
|
||||||
# --- Form properties (Title excluded -- shown in header) ---
|
# --- Form properties (Title excluded -- shown in header) ---
|
||||||
prop_names = [
|
prop_names = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-remove v1.3 — Remove form from 1C object
|
# form-remove v1.9 — Remove form from 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -64,6 +64,10 @@ foreach ($node in $formNodes) {
|
|||||||
$parent.RemoveChild($prev) | Out-Null
|
$parent.RemoveChild($prev) | Out-Null
|
||||||
}
|
}
|
||||||
$parent.RemoveChild($node) | 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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +78,9 @@ foreach ($node in $formNodes) {
|
|||||||
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
|
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
|
||||||
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
|
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
|
||||||
if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) {
|
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 = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = $encBom
|
$settings.Encoding = $encBom
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
|
|
||||||
$stream = New-Object System.IO.FileStream($rootXmlFull.Path, [System.IO.FileMode]::Create)
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
$xmlDoc.Save($writer)
|
$xmlDoc.Save($writer)
|
||||||
$writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
$stream.Close()
|
|
||||||
|
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||||
|
$memStream.Close()
|
||||||
|
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||||
|
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $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"
|
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# remove-form v1.3 — Remove form from 1C object
|
# form-remove v1.9 — Remove form from 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -10,16 +10,75 @@ import sys
|
|||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||||
|
|
||||||
|
|
||||||
def save_xml_with_bom(tree, path):
|
def _detect_xml_style(path):
|
||||||
"""Save XML tree to file with UTF-8 BOM."""
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
try:
|
||||||
if not xml_bytes.endswith(b"\n"):
|
raw = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
body = raw[3:] if bom else raw
|
||||||
|
crlf = b"\r\n" in body
|
||||||
|
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||||
|
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||||
|
final_nl = body.endswith(b"\n")
|
||||||
|
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
|
xml_bytes = xml_bytes.replace(
|
||||||
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||||
|
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||||
|
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||||
|
want_final_nl = style["final_nl"] if style else False
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if want_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
|
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||||
|
if (style["crlf"] if style else True):
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
return xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def save_xml_with_bom(tree, path):
|
||||||
|
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||||
|
style = _detect_xml_style(path)
|
||||||
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
|
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
|
if style is None or style["bom"]:
|
||||||
f.write(b"\xef\xbb\xbf")
|
f.write(b"\xef\xbb\xbf")
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
@@ -31,7 +90,7 @@ def main():
|
|||||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||||
parser.add_argument("-FormName", required=True)
|
parser.add_argument("-FormName", required=True)
|
||||||
parser.add_argument("-SrcDir", default="src")
|
parser.add_argument("-SrcDir", default="src")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
object_name = args.ObjectName
|
object_name = args.ObjectName
|
||||||
form_name = args.FormName
|
form_name = args.FormName
|
||||||
@@ -83,6 +142,10 @@ def main():
|
|||||||
if parent.text and parent.text.strip() == "":
|
if parent.text and parent.text.strip() == "":
|
||||||
parent.text = ""
|
parent.text = ""
|
||||||
parent.remove(node)
|
parent.remove(node)
|
||||||
|
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||||||
|
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||||||
|
if len(parent) == 0 and not (parent.text or "").strip():
|
||||||
|
parent.text = None
|
||||||
break
|
break
|
||||||
|
|
||||||
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
||||||
@@ -93,7 +156,9 @@ def main():
|
|||||||
if not isinstance(el.tag, str):
|
if not isinstance(el.tag, str):
|
||||||
continue
|
continue
|
||||||
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
|
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 with BOM
|
||||||
save_xml_with_bom(tree, root_xml_full)
|
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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -56,9 +56,23 @@ try {
|
|||||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||||
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
||||||
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||||
|
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||||
|
|
||||||
$root = $xmlDoc.DocumentElement
|
$root = $xmlDoc.DocumentElement
|
||||||
|
|
||||||
|
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||||
|
# support-guard: is_external_root, авторитет — cf-edit).
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
# --- Detect context: config vs EPF/ERF ---
|
# --- Detect context: config vs EPF/ERF ---
|
||||||
# Walk up from FormPath looking for Configuration.xml → config context
|
# Walk up from FormPath looking for Configuration.xml → config context
|
||||||
# No Configuration.xml → external data processor / report (EPF/ERF)
|
# No Configuration.xml → external data processor / report (EPF/ERF)
|
||||||
@@ -66,13 +80,56 @@ $script:isConfigContext = $false
|
|||||||
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
||||||
for ($i = 0; $i -lt 15; $i++) {
|
for ($i = 0; $i -lt 15; $i++) {
|
||||||
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
||||||
|
# Порядок проверок тот же, что у Detect-FormatVersion: сначала корень автономной обработки,
|
||||||
|
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||||
|
# версию конфигурации.
|
||||||
|
$extRoot = "$walkDir.xml"
|
||||||
|
if (-not $script:versionAnchor) {
|
||||||
|
if (Test-ExternalObjectRoot $extRoot) {
|
||||||
|
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||||
|
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||||
|
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||||
|
$script:versionAnchor = $extRoot
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
||||||
$script:isConfigContext = $true
|
$script:isConfigContext = $true
|
||||||
|
$script:configXmlPath = Join-Path $walkDir "Configuration.xml"
|
||||||
|
if (-not $script:versionAnchor) { $script:versionAnchor = $script:configXmlPath }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
$walkDir = Split-Path $walkDir
|
$walkDir = Split-Path $walkDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||||
|
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||||
|
function Detect-FormatVersion([string]$dir) {
|
||||||
|
$d = $dir
|
||||||
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
|
if (Test-Path $cfgPath) {
|
||||||
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
|
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||||
|
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||||
|
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||||
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
|
$parent = Split-Path $d -Parent
|
||||||
|
if ($parent -eq $d) { break }
|
||||||
|
$d = $parent
|
||||||
|
}
|
||||||
|
return "2.17"
|
||||||
|
}
|
||||||
|
|
||||||
# --- Counters ---
|
# --- Counters ---
|
||||||
|
|
||||||
$errors = 0
|
$errors = 0
|
||||||
@@ -101,6 +158,19 @@ function Report-Warn {
|
|||||||
Write-Host "[WARN] $msg"
|
Write-Host "[WARN] $msg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Format version ---
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
$formatVerifiedMin = "2.17"
|
||||||
|
$formatVerifiedMax = "2.21"
|
||||||
|
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||||
|
function Get-FormatRank([string]$ver) {
|
||||||
|
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# --- Form name from path ---
|
# --- Form name from path ---
|
||||||
|
|
||||||
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
||||||
@@ -127,12 +197,17 @@ if ($root.LocalName -ne "Form") {
|
|||||||
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
||||||
} else {
|
} else {
|
||||||
$version = $root.GetAttribute("version")
|
$version = $root.GetAttribute("version")
|
||||||
if ($version -eq "2.17" -or $version -eq "2.20") {
|
$versionRank = Get-FormatRank $version
|
||||||
Report-OK "Root element: Form version=$version"
|
if (-not $version) {
|
||||||
} elseif ($version) {
|
|
||||||
Report-Warn "Form version='$version' (expected 2.17 or 2.20)"
|
|
||||||
} else {
|
|
||||||
Report-Warn "Form version attribute missing"
|
Report-Warn "Form version attribute missing"
|
||||||
|
} elseif ($versionRank -eq 0) {
|
||||||
|
Report-Error "Malformed version '$version' (expected N.N)"
|
||||||
|
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||||
|
Report-Warn "Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||||
|
Report-Warn "Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||||
|
} else {
|
||||||
|
Report-OK "Root element: Form version=$version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,10 +218,15 @@ if (-not $stopped) {
|
|||||||
if ($acb) {
|
if ($acb) {
|
||||||
$acbName = $acb.GetAttribute("name")
|
$acbName = $acb.GetAttribute("name")
|
||||||
$acbId = $acb.GetAttribute("id")
|
$acbId = $acb.GetAttribute("id")
|
||||||
|
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||||
|
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||||
|
# предупреждение; ошибка — только если id вовсе не число.
|
||||||
if ($acbId -eq "-1") {
|
if ($acbId -eq "-1") {
|
||||||
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
||||||
|
} elseif ($acbId -match '^-?\d+$') {
|
||||||
|
Report-Warn "AutoCommandBar id='$acbId', usually '-1'"
|
||||||
} else {
|
} else {
|
||||||
Report-Error "AutoCommandBar id='$acbId', expected '-1'"
|
Report-Error "AutoCommandBar id='$acbId' is not a number"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Report-Error "AutoCommandBar element missing"
|
Report-Error "AutoCommandBar element missing"
|
||||||
@@ -426,11 +506,19 @@ if (-not $stopped) {
|
|||||||
$segments = $cleanPath -split '\.'
|
$segments = $cleanPath -split '\.'
|
||||||
$rootAttr = $segments[0]
|
$rootAttr = $segments[0]
|
||||||
|
|
||||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||||
if ($rootAttr -eq 'Items') {
|
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||||
|
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||||
|
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||||
|
$itemsHops = 0
|
||||||
|
$itemsBroken = $false
|
||||||
|
while ($rootAttr -eq 'Items') {
|
||||||
|
$itemsHops++
|
||||||
|
if ($itemsHops -gt 10) { $itemsBroken = $true; break } # страховка от кольца ссылок
|
||||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||||
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableName = $segments[1]
|
$tableName = $segments[1]
|
||||||
$tableEl = $null
|
$tableEl = $null
|
||||||
@@ -443,17 +531,21 @@ if (-not $stopped) {
|
|||||||
if (-not $tableEl) {
|
if (-not $tableEl) {
|
||||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
||||||
$pathErrors++
|
$pathErrors++
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||||
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||||
# Table without DataPath — can't resolve further, accept silently
|
# Table without DataPath — can't resolve further, accept silently
|
||||||
continue
|
$itemsBroken = $true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||||
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||||
$rootAttr = ($tableDp -split '\.')[0]
|
$segments = $tableDp -split '\.'
|
||||||
|
$rootAttr = $segments[0]
|
||||||
}
|
}
|
||||||
|
if ($itemsBroken) { continue }
|
||||||
|
|
||||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
||||||
@@ -568,13 +660,18 @@ if (-not $stopped) {
|
|||||||
$actionErrors = 0
|
$actionErrors = 0
|
||||||
$actionChecked = 0
|
$actionChecked = 0
|
||||||
|
|
||||||
|
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||||
|
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||||
|
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||||
|
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||||
|
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||||
foreach ($cmd in $cmdNodes) {
|
foreach ($cmd in $cmdNodes) {
|
||||||
if ($stopped) { break }
|
if ($stopped) { break }
|
||||||
$cmdName = $cmd.GetAttribute("name")
|
$cmdName = $cmd.GetAttribute("name")
|
||||||
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
||||||
$actionChecked++
|
$actionChecked++
|
||||||
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
||||||
Report-Error "Command '$cmdName': missing or empty Action"
|
Report-Warn "Command '$cmdName': no Action — handler must be assigned at runtime, otherwise the command does nothing"
|
||||||
$actionErrors++
|
$actionErrors++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -741,6 +838,41 @@ if (-not $stopped -and $isExtension) {
|
|||||||
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 11d. Пути на основной реквизит, которого форма не объявляет.
|
||||||
|
# Check 5 такое пропускает: у заимствованной формы он не проверяет базовые элементы (id < 1000000),
|
||||||
|
# а привязки в <xr:Link> вообще вне его списка тегов. Между тем это ровно тот случай, на котором
|
||||||
|
# платформа отвергает загрузку: «Неверный путь к полю - Объект.X». Правило: если основной реквизит
|
||||||
|
# не объявлен в <Attributes> формы, любой путь с его корнем не разрешится.
|
||||||
|
# Корень берётся из основного реквизита BaseForm: «Объект» он только у формы объекта, у формы
|
||||||
|
# списка это «Список», у формы записи регистра «Запись». С зашитым «Объект» проверка на таких
|
||||||
|
# формах молча не срабатывала — валидатор рапортовал «чисто» на форме, которую платформа не примет.
|
||||||
|
$mainAttrDeclared = $false
|
||||||
|
foreach ($attr in $attrNodes) {
|
||||||
|
$maNode = $attr.SelectSingleNode("f:MainAttribute", $nsMgr)
|
||||||
|
if ($maNode -and $maNode.InnerText.Trim() -eq "true") { $mainAttrDeclared = $true; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $mainAttrDeclared) {
|
||||||
|
# Значения привязок ищем текстом: интересуют и обычные теги, и <xr:DataPath> внутри
|
||||||
|
# <ChoiceParameterLinks>, а те живут в чужом пространстве имён.
|
||||||
|
$rawForm = [System.IO.File]::ReadAllText($FormPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$mainBase = $baseFormNode.SelectSingleNode("f:Attributes/f:Attribute[f:MainAttribute='true']", $bfNs)
|
||||||
|
$rootName = if ($mainBase -and $mainBase.GetAttribute("name")) { $mainBase.GetAttribute("name") } else { "Объект" }
|
||||||
|
$rootPat = [regex]::Escape($rootName)
|
||||||
|
$danglingPaths = @{}
|
||||||
|
foreach ($m in [regex]::Matches($rawForm, "<(?:\w+:)?\w*DataPath[^>]*>(${rootPat}\.[^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||||
|
$danglingPaths[$m.Groups[1].Value] = $true
|
||||||
|
}
|
||||||
|
if ($danglingPaths.Count -gt 0) {
|
||||||
|
$shown = @($danglingPaths.Keys | Sort-Object)
|
||||||
|
$sample = ($shown | Select-Object -First 3) -join ", "
|
||||||
|
$suffix = if ($shown.Count -gt 3) { " (и ещё $($shown.Count - 3))" } else { "" }
|
||||||
|
Report-Error "Path(s) rooted at '${rootName}' but the form declares no MainAttribute: $sample$suffix"
|
||||||
|
} elseif ($mainBase) {
|
||||||
|
Report-OK "Object paths: none dangling (MainAttribute not declared)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check callType without BaseForm (structural warning)
|
# Check callType without BaseForm (structural warning)
|
||||||
@@ -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 ---
|
# --- Summary ---
|
||||||
|
|
||||||
$checks = $script:okCount + $errors + $warnings
|
$checks = $script:okCount + $errors + $warnings
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-validate v1.8 — Validate 1C managed form
|
# form-validate v1.17 — Validate 1C managed form
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -8,6 +8,28 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
||||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||||
|
|
||||||
@@ -49,6 +71,64 @@ VALID_CFG_PREFIXES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||||
|
# support-guard: is_external_root, авторитет — cf-edit).
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||||
|
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||||
|
def detect_format_version(d):
|
||||||
|
while d:
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
ext_path = d + ".xml"
|
||||||
|
if os.path.isfile(ext_path):
|
||||||
|
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
ext_head = f.read(2000)
|
||||||
|
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
|
if os.path.isfile(cfg_path):
|
||||||
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
head = f.read(2000)
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
parent = os.path.dirname(d)
|
||||||
|
if parent == d:
|
||||||
|
break
|
||||||
|
d = parent
|
||||||
|
return "2.17"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Format version ───────────────────────────────────────────
|
||||||
|
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||||
|
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||||
|
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||||
|
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||||
|
FORMAT_VERIFIED_MIN = "2.17"
|
||||||
|
FORMAT_VERIFIED_MAX = "2.21"
|
||||||
|
|
||||||
|
|
||||||
|
def format_rank(ver):
|
||||||
|
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||||
|
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||||
|
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||||
|
|
||||||
|
|
||||||
def localname(el):
|
def localname(el):
|
||||||
return etree.QName(el.tag).localname
|
return etree.QName(el.tag).localname
|
||||||
|
|
||||||
@@ -60,7 +140,7 @@ def main():
|
|||||||
parser.add_argument("-FormPath", "-Path", required=True)
|
parser.add_argument("-FormPath", "-Path", required=True)
|
||||||
parser.add_argument("-Detailed", action="store_true")
|
parser.add_argument("-Detailed", action="store_true")
|
||||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
form_path = args.FormPath
|
form_path = args.FormPath
|
||||||
detailed = args.Detailed
|
detailed = args.Detailed
|
||||||
@@ -106,13 +186,29 @@ def main():
|
|||||||
|
|
||||||
# Detect context: config vs EPF/ERF
|
# Detect context: config vs EPF/ERF
|
||||||
is_config_context = False
|
is_config_context = False
|
||||||
|
config_xml_path = ''
|
||||||
|
version_anchor = ''
|
||||||
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
parent = os.path.dirname(walk_dir)
|
parent = os.path.dirname(walk_dir)
|
||||||
if parent == walk_dir:
|
if parent == walk_dir:
|
||||||
break
|
break
|
||||||
|
# Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки,
|
||||||
|
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||||
|
# версию конфигурации.
|
||||||
|
ext_root = walk_dir + '.xml'
|
||||||
|
if not version_anchor:
|
||||||
|
if _sg_is_external_root(ext_root):
|
||||||
|
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||||
|
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||||
|
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||||
|
version_anchor = ext_root
|
||||||
|
break
|
||||||
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
||||||
is_config_context = True
|
is_config_context = True
|
||||||
|
config_xml_path = os.path.join(walk_dir, 'Configuration.xml')
|
||||||
|
if not version_anchor:
|
||||||
|
version_anchor = config_xml_path
|
||||||
break
|
break
|
||||||
walk_dir = parent
|
walk_dir = parent
|
||||||
|
|
||||||
@@ -161,12 +257,19 @@ def main():
|
|||||||
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
||||||
else:
|
else:
|
||||||
version = root.get("version", "")
|
version = root.get("version", "")
|
||||||
if version in ("2.17", "2.20"):
|
version_rank = format_rank(version)
|
||||||
report_ok(f"Root element: Form version={version}")
|
if not version:
|
||||||
elif version:
|
|
||||||
report_warn(f"Form version='{version}' (expected 2.17 or 2.20)")
|
|
||||||
else:
|
|
||||||
report_warn("Form version attribute missing")
|
report_warn("Form version attribute missing")
|
||||||
|
elif version_rank == 0:
|
||||||
|
report_error(f"Malformed version '{version}' (expected N.N)")
|
||||||
|
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||||
|
report_warn(f"Format version '{version}' is below the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||||
|
report_warn(f"Format version '{version}' is above the tested range "
|
||||||
|
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||||
|
else:
|
||||||
|
report_ok(f"Root element: Form version={version}")
|
||||||
|
|
||||||
# --- Check 2: AutoCommandBar ---
|
# --- Check 2: AutoCommandBar ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -174,10 +277,15 @@ def main():
|
|||||||
if acb is not None:
|
if acb is not None:
|
||||||
acb_name = acb.get("name", "")
|
acb_name = acb.get("name", "")
|
||||||
acb_id = acb.get("id", "")
|
acb_id = acb.get("id", "")
|
||||||
|
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||||
|
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||||
|
# предупреждение; ошибка — только если id вовсе не число.
|
||||||
if acb_id == "-1":
|
if acb_id == "-1":
|
||||||
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
||||||
|
elif re.match(r'^-?\d+$', acb_id):
|
||||||
|
report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'")
|
||||||
else:
|
else:
|
||||||
report_error(f"AutoCommandBar id='{acb_id}', expected '-1'")
|
report_error(f"AutoCommandBar id='{acb_id}' is not a number")
|
||||||
else:
|
else:
|
||||||
report_error("AutoCommandBar element missing")
|
report_error("AutoCommandBar element missing")
|
||||||
|
|
||||||
@@ -429,11 +537,21 @@ def main():
|
|||||||
segments = clean_path.split(".")
|
segments = clean_path.split(".")
|
||||||
root_attr = segments[0]
|
root_attr = segments[0]
|
||||||
|
|
||||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||||
if root_attr == 'Items':
|
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||||
|
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||||
|
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||||
|
items_hops = 0
|
||||||
|
items_broken = False
|
||||||
|
while root_attr == 'Items':
|
||||||
|
items_hops += 1
|
||||||
|
if items_hops > 10: # страховка от кольца ссылок
|
||||||
|
items_broken = True
|
||||||
|
break
|
||||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||||
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_name = segments[1]
|
table_name = segments[1]
|
||||||
table_el = None
|
table_el = None
|
||||||
for candidate in all_elements:
|
for candidate in all_elements:
|
||||||
@@ -443,14 +561,19 @@ def main():
|
|||||||
if table_el is None:
|
if table_el is None:
|
||||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
||||||
path_errors += 1
|
path_errors += 1
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||||
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||||
continue
|
items_broken = True
|
||||||
|
break
|
||||||
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||||
if table_dp.startswith('~'):
|
if table_dp.startswith('~'):
|
||||||
table_dp = table_dp[1:]
|
table_dp = table_dp[1:]
|
||||||
root_attr = table_dp.split(".")[0]
|
segments = table_dp.split(".")
|
||||||
|
root_attr = segments[0]
|
||||||
|
if items_broken:
|
||||||
|
continue
|
||||||
|
|
||||||
if root_attr not in attr_map:
|
if root_attr not in attr_map:
|
||||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
||||||
@@ -464,6 +587,8 @@ def main():
|
|||||||
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
||||||
if path_errors == 0 and path_msg:
|
if path_errors == 0 and path_msg:
|
||||||
report_ok(f"Data bindings: {path_msg}")
|
report_ok(f"Data bindings: {path_msg}")
|
||||||
|
elif path_errors == 0:
|
||||||
|
report_ok("Data bindings: none")
|
||||||
|
|
||||||
# --- Check 6: Button command references ---
|
# --- Check 6: Button command references ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -498,6 +623,8 @@ def main():
|
|||||||
|
|
||||||
if cmd_errors == 0 and cmd_checked > 0:
|
if cmd_errors == 0 and cmd_checked > 0:
|
||||||
report_ok(f"Command references: {cmd_checked} buttons checked")
|
report_ok(f"Command references: {cmd_checked} buttons checked")
|
||||||
|
elif cmd_checked == 0:
|
||||||
|
report_ok("Command references: none")
|
||||||
|
|
||||||
# --- Check 7: Events have handler names ---
|
# --- Check 7: Events have handler names ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -537,12 +664,19 @@ def main():
|
|||||||
|
|
||||||
if event_errors == 0 and event_checked > 0:
|
if event_errors == 0 and event_checked > 0:
|
||||||
report_ok(f"Event handlers: {event_checked} events checked")
|
report_ok(f"Event handlers: {event_checked} events checked")
|
||||||
|
elif event_checked == 0:
|
||||||
|
report_ok("Event handlers: none")
|
||||||
|
|
||||||
# --- Check 8: Command actions ---
|
# --- Check 8: Command actions ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
action_errors = 0
|
action_errors = 0
|
||||||
action_checked = 0
|
action_checked = 0
|
||||||
|
|
||||||
|
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||||
|
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||||
|
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||||
|
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||||
|
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||||
for cmd in cmd_nodes:
|
for cmd in cmd_nodes:
|
||||||
if stopped:
|
if stopped:
|
||||||
break
|
break
|
||||||
@@ -550,11 +684,13 @@ def main():
|
|||||||
action_node = cmd.find(f"{{{F_NS}}}Action")
|
action_node = cmd.find(f"{{{F_NS}}}Action")
|
||||||
action_checked += 1
|
action_checked += 1
|
||||||
if action_node is None or not (action_node.text or "").strip():
|
if action_node is None or not (action_node.text or "").strip():
|
||||||
report_error(f"Command '{cmd_name}': missing or empty Action")
|
report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing")
|
||||||
action_errors += 1
|
action_errors += 1
|
||||||
|
|
||||||
if action_errors == 0 and action_checked > 0:
|
if action_errors == 0 and action_checked > 0:
|
||||||
report_ok(f"Command actions: {action_checked} commands checked")
|
report_ok(f"Command actions: {action_checked} commands checked")
|
||||||
|
elif action_checked == 0:
|
||||||
|
report_ok("Command actions: none")
|
||||||
|
|
||||||
# --- Check 9: MainAttribute count ---
|
# --- Check 9: MainAttribute count ---
|
||||||
if not stopped:
|
if not stopped:
|
||||||
@@ -685,6 +821,39 @@ def main():
|
|||||||
if (ext_attr_count + ext_cmd_count) > 0:
|
if (ext_attr_count + ext_cmd_count) > 0:
|
||||||
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
||||||
|
|
||||||
|
# 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442.
|
||||||
|
# Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000),
|
||||||
|
# \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 <xr:Link> \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c
|
||||||
|
# \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442
|
||||||
|
# \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 <Attributes> \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u0435\u0433\u043e \u043a\u043e\u0440\u043d\u0435\u043c \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f.
|
||||||
|
# \u041a\u043e\u0440\u0435\u043d\u044c \u0431\u0435\u0440\u0451\u0442\u0441\u044f \u0438\u0437 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 BaseForm: \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043e\u043d \u0442\u043e\u043b\u044c\u043a\u043e \u0443 \u0444\u043e\u0440\u043c\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430, \u0443 \u0444\u043e\u0440\u043c\u044b
|
||||||
|
# \u0441\u043f\u0438\u0441\u043a\u0430 \u044d\u0442\u043e \u00ab\u0421\u043f\u0438\u0441\u043e\u043a\u00bb, \u0443 \u0444\u043e\u0440\u043c\u044b \u0437\u0430\u043f\u0438\u0441\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u00ab\u0417\u0430\u043f\u0438\u0441\u044c\u00bb. \u0421 \u0437\u0430\u0448\u0438\u0442\u044b\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0430\u043a\u0438\u0445
|
||||||
|
# \u0444\u043e\u0440\u043c\u0430\u0445 \u043c\u043e\u043b\u0447\u0430 \u043d\u0435 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u0430.
|
||||||
|
main_attr_declared = False
|
||||||
|
for attr in attr_nodes:
|
||||||
|
ma_node = attr.find(f"{{{F_NS}}}MainAttribute")
|
||||||
|
if ma_node is not None and (ma_node.text or "").strip() == "true":
|
||||||
|
main_attr_declared = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not main_attr_declared:
|
||||||
|
# \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 <xr:DataPath> \u0432\u043d\u0443\u0442\u0440\u0438
|
||||||
|
# <ChoiceParameterLinks>, \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d.
|
||||||
|
with open(form_path, "r", encoding="utf-8-sig") as fh:
|
||||||
|
raw_form = fh.read()
|
||||||
|
main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']")
|
||||||
|
root_name = main_base.get("name") if main_base is not None and main_base.get("name") else "\u041e\u0431\u044a\u0435\u043a\u0442"
|
||||||
|
root_pat = re.escape(root_name)
|
||||||
|
dangling_paths = set(re.findall(
|
||||||
|
r'<(?:\w+:)?\w*DataPath[^>]*>(' + root_pat + r'\.[^<]+)</(?:\w+:)?\w*DataPath>', raw_form))
|
||||||
|
if dangling_paths:
|
||||||
|
shown = sorted(dangling_paths)
|
||||||
|
sample = ", ".join(shown[:3])
|
||||||
|
suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else ""
|
||||||
|
report_error(f"Path(s) rooted at '{root_name}' but the form declares no MainAttribute: {sample}{suffix}")
|
||||||
|
elif main_base is not None:
|
||||||
|
report_ok("Object paths: none dangling (MainAttribute not declared)")
|
||||||
|
|
||||||
# Check callType without BaseForm
|
# Check callType without BaseForm
|
||||||
if not stopped and not is_extension:
|
if not stopped and not is_extension:
|
||||||
call_type_without_base = False
|
call_type_without_base = False
|
||||||
@@ -747,6 +916,62 @@ def main():
|
|||||||
else:
|
else:
|
||||||
report_ok('12. Types: no type values to check')
|
report_ok('12. Types: no type values to check')
|
||||||
|
|
||||||
|
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||||
|
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||||
|
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||||
|
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||||
|
# считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна.
|
||||||
|
if not stopped:
|
||||||
|
prefix_errors = 0
|
||||||
|
prefix_checked = 0
|
||||||
|
prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$')
|
||||||
|
|
||||||
|
for node in root.iter():
|
||||||
|
if not isinstance(node.tag, str):
|
||||||
|
continue
|
||||||
|
ln = localname(node)
|
||||||
|
values = []
|
||||||
|
if ln in ('Type', 'TypeSet'):
|
||||||
|
values.append((node.text or '').strip())
|
||||||
|
xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type')
|
||||||
|
if xsi_type:
|
||||||
|
values.append(xsi_type.strip())
|
||||||
|
for val in values:
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
m = prefix_re.match(val)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
prefix_checked += 1
|
||||||
|
pfx = m.group(1)
|
||||||
|
if pfx not in node.nsmap:
|
||||||
|
kind = "xsi:type" if val == xsi_type else "Type"
|
||||||
|
report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared "
|
||||||
|
"— the platform cannot read the file (XDTO)")
|
||||||
|
prefix_errors += 1
|
||||||
|
|
||||||
|
if prefix_checked == 0:
|
||||||
|
report_ok('13. Namespace prefixes: nothing to check')
|
||||||
|
elif prefix_errors == 0:
|
||||||
|
report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared')
|
||||||
|
|
||||||
|
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||||
|
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||||
|
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||||
|
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||||
|
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||||
|
if not stopped and version_anchor:
|
||||||
|
form_ver = root.get('version', '')
|
||||||
|
dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path)))
|
||||||
|
|
||||||
|
if not form_ver:
|
||||||
|
report_ok('14. Format version: not comparable')
|
||||||
|
elif form_ver != dump_ver:
|
||||||
|
report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) '
|
||||||
|
'— a dump carries one version, the platform refuses a file it cannot read')
|
||||||
|
else:
|
||||||
|
report_ok(f'14. Format version: {form_ver}, matches the dump')
|
||||||
|
|
||||||
# --- Finalize ---
|
# --- Finalize ---
|
||||||
checks = ok_count + errors + warnings
|
checks = ok_count + errors + warnings
|
||||||
if errors == 0 and warnings == 0 and not detailed:
|
if errors == 0 and warnings == 0 and not detailed:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# help-add v1.7 — Add built-in help to 1C object
|
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -28,6 +28,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -64,10 +74,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -136,10 +149,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
$cfgPath = Join-Path $d "Configuration.xml"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||||
|
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||||
|
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
}
|
}
|
||||||
$parent = Split-Path $d -Parent
|
$parent = Split-Path $d -Parent
|
||||||
@@ -182,7 +205,18 @@ $helpXml = @"
|
|||||||
</Help>
|
</Help>
|
||||||
"@
|
"@
|
||||||
|
|
||||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
#
|
||||||
|
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
#
|
||||||
|
# HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||||
|
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||||
|
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-XmlFile $helpXmlPath $helpXml $encBom
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
# --- 2. Help/<lang>.html ---
|
||||||
|
|
||||||
@@ -242,11 +276,26 @@ if (Test-Path $formsDir) {
|
|||||||
$settings = New-Object System.Xml.XmlWriterSettings
|
$settings = New-Object System.Xml.XmlWriterSettings
|
||||||
$settings.Encoding = $encBom
|
$settings.Encoding = $encBom
|
||||||
$settings.Indent = $false
|
$settings.Indent = $false
|
||||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||||
|
$memStream = New-Object System.IO.MemoryStream
|
||||||
|
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||||
$xmlDoc.Save($writer)
|
$xmlDoc.Save($writer)
|
||||||
$writer.Close()
|
$writer.Flush(); $writer.Close()
|
||||||
$stream.Close()
|
|
||||||
|
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||||
|
$memStream.Close()
|
||||||
|
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||||
|
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $formMeta.FullName) -and ([System.IO.File]::ReadAllText($formMeta.FullName) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
|
[System.IO.File]::WriteAllText($formMeta.FullName, $xmlText, $encBom)
|
||||||
|
|
||||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# add-help v1.7 — Add built-in help to 1C object
|
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -10,6 +10,28 @@ import sys
|
|||||||
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||||
|
|
||||||
|
|
||||||
@@ -33,6 +55,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -72,6 +106,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -79,6 +116,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -174,6 +213,16 @@ def assert_edit_allowed(target_path, require):
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while d:
|
while d:
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
ext_path = d + ".xml"
|
||||||
|
if os.path.isfile(ext_path):
|
||||||
|
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
ext_head = f.read(2000)
|
||||||
|
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
cfg_path = os.path.join(d, "Configuration.xml")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -188,21 +237,72 @@ def detect_format_version(d):
|
|||||||
return "2.17"
|
return "2.17"
|
||||||
|
|
||||||
|
|
||||||
def save_xml_with_bom(tree, path):
|
def _detect_xml_style(path):
|
||||||
"""Save XML tree to file with UTF-8 BOM."""
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
try:
|
||||||
if not xml_bytes.endswith(b"\n"):
|
raw = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
body = raw[3:] if bom else raw
|
||||||
|
crlf = b"\r\n" in body
|
||||||
|
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||||
|
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||||
|
final_nl = body.endswith(b"\n")
|
||||||
|
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
|
xml_bytes = xml_bytes.replace(
|
||||||
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||||
|
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||||
|
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||||
|
want_final_nl = style["final_nl"] if style else False
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if want_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
|
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||||
|
if (style["crlf"] if style else True):
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
return xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def save_xml_with_bom(tree, path):
|
||||||
|
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||||
|
style = _detect_xml_style(path)
|
||||||
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
|
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
|
if style is None or style["bom"]:
|
||||||
f.write(b"\xef\xbb\xbf")
|
f.write(b"\xef\xbb\xbf")
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
|
|
||||||
def write_text_with_bom(path, text):
|
def write_utf8_bom(path, content):
|
||||||
"""Write text to file with UTF-8 BOM."""
|
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||||
with open(path, "w", encoding="utf-8-sig") as f:
|
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||||
f.write(text)
|
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(path, content):
|
||||||
|
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||||
|
|
||||||
|
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||||
|
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||||
|
|
||||||
|
HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||||
|
"""
|
||||||
|
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||||
|
write_utf8_bom(path, text)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -212,7 +312,7 @@ def main():
|
|||||||
parser.add_argument("-ObjectName", required=True)
|
parser.add_argument("-ObjectName", required=True)
|
||||||
parser.add_argument("-Lang", default="ru")
|
parser.add_argument("-Lang", default="ru")
|
||||||
parser.add_argument("-SrcDir", default="src")
|
parser.add_argument("-SrcDir", default="src")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
object_name = args.ObjectName
|
object_name = args.ObjectName
|
||||||
lang = args.Lang
|
lang = args.Lang
|
||||||
@@ -248,7 +348,7 @@ def main():
|
|||||||
'</Help>'
|
'</Help>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(help_xml_path, help_xml)
|
write_xml_file(help_xml_path, help_xml)
|
||||||
|
|
||||||
# --- 2. Help/<lang>.html ---
|
# --- 2. Help/<lang>.html ---
|
||||||
|
|
||||||
@@ -271,7 +371,7 @@ def main():
|
|||||||
'</html>'
|
'</html>'
|
||||||
)
|
)
|
||||||
|
|
||||||
write_text_with_bom(help_html_path, help_html)
|
write_utf8_bom(help_html_path, help_html)
|
||||||
|
|
||||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
# --- 3. Check IncludeHelpInContents in form metadata ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# img-grid v1.1 — Overlay numbered grid on image
|
# img-grid v1.2 — Overlay numbered grid on image
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
||||||
|
|
||||||
@@ -16,6 +16,28 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
MARGIN_TOP = 20
|
MARGIN_TOP = 20
|
||||||
MARGIN_LEFT = 24
|
MARGIN_LEFT = 24
|
||||||
|
|
||||||
@@ -30,7 +52,7 @@ def main():
|
|||||||
parser.add_argument("-r", "--rows", type=int, default=0,
|
parser.add_argument("-r", "--rows", type=int, default=0,
|
||||||
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
||||||
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
if args.cols <= 0:
|
if args.cols <= 0:
|
||||||
parser.error("--cols must be greater than 0")
|
parser.error("--cols must be greater than 0")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# interface-edit v1.6 — Edit 1C CommandInterface.xml
|
# interface-edit v1.18 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||||
@@ -39,6 +39,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -75,10 +85,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -149,9 +162,20 @@ Assert-EditAllowed $CIPath 'editable'
|
|||||||
function Detect-FormatVersion([string]$dir) {
|
function Detect-FormatVersion([string]$dir) {
|
||||||
$d = $dir
|
$d = $dir
|
||||||
while ($d) {
|
while ($d) {
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
$extPath = "$d.xml"
|
||||||
|
if (Test-Path $extPath) {
|
||||||
|
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||||
|
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||||
|
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
|
}
|
||||||
$cfgPath = Join-Path $d "Configuration.xml"
|
$cfgPath = Join-Path $d "Configuration.xml"
|
||||||
if (Test-Path $cfgPath) {
|
if (Test-Path $cfgPath) {
|
||||||
$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] }
|
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||||
}
|
}
|
||||||
$parent = Split-Path $d -Parent
|
$parent = Split-Path $d -Parent
|
||||||
@@ -186,7 +210,12 @@ if (-not (Test-Path $CIPath)) {
|
|||||||
</CommandInterface>
|
</CommandInterface>
|
||||||
"@
|
"@
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI, $utf8Bom)
|
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF, без перевода строки в конце.
|
||||||
|
# (Правка существующего файла, наоборот, наследует его стиль — это делает
|
||||||
|
# основной путь сохранения ниже.) Нормализация нужна потому, что here-string
|
||||||
|
# берёт переводы строк из самого .ps1, а он в репозитории хранится с LF.
|
||||||
|
$emptyCI = ($emptyCI -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||||
|
[System.IO.File]::WriteAllText($CIPath, $emptyCI.TrimEnd("`r", "`n"), $utf8Bom)
|
||||||
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
||||||
} else {
|
} else {
|
||||||
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
||||||
@@ -367,6 +396,10 @@ $script:typeNormMap = @{
|
|||||||
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
||||||
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
||||||
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
||||||
|
"РегистрРасчёта"="CalculationRegister"; "РегистрРасчета"="CalculationRegister"
|
||||||
|
"ПланВидовРасчёта"="ChartOfCalculationTypes"; "ПланВидовРасчета"="ChartOfCalculationTypes"
|
||||||
|
"Роль"="Role"; "ОбщийМакет"="CommonTemplate"; "ЭлементСтиля"="StyleItem"
|
||||||
|
"ОбщийРеквизит"="CommonAttribute"; "ГруппаКоманд"="CommandGroup"
|
||||||
# Russian plural
|
# Russian plural
|
||||||
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
||||||
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
||||||
@@ -376,6 +409,10 @@ $script:typeNormMap = @{
|
|||||||
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
||||||
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
||||||
"Подсистемы"="Subsystem"
|
"Подсистемы"="Subsystem"
|
||||||
|
"РегистрыРасчёта"="CalculationRegister"; "РегистрыРасчета"="CalculationRegister"
|
||||||
|
"ПланыВидовРасчёта"="ChartOfCalculationTypes"; "ПланыВидовРасчета"="ChartOfCalculationTypes"
|
||||||
|
"Роли"="Role"; "ОбщиеМакеты"="CommonTemplate"; "ЭлементыСтиля"="StyleItem"
|
||||||
|
"ОбщиеРеквизиты"="CommonAttribute"; "ГруппыКоманд"="CommandGroup"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Normalize-CmdName([string]$name) {
|
function Normalize-CmdName([string]$name) {
|
||||||
@@ -658,8 +695,16 @@ $memStream.Close()
|
|||||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||||
|
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||||
|
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||||
|
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||||
|
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||||
|
|
||||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||||
|
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||||
|
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||||
|
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||||
Info "Saved: $resolvedPath"
|
Info "Saved: $resolvedPath"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# interface-edit v1.6 — Edit 1C CommandInterface.xml
|
# interface-edit v1.18 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -10,6 +10,65 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
class CIDict(dict):
|
||||||
|
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||||
|
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||||
|
# сохраняется — от него зависит порядок эмиссии.
|
||||||
|
def _actual(self, key):
|
||||||
|
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||||
|
return key
|
||||||
|
ci = self.__dict__.get('_ci')
|
||||||
|
if ci is None or len(ci) != len(self):
|
||||||
|
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||||
|
self.__dict__['_ci'] = ci
|
||||||
|
return ci.get(key.lower(), key)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return dict.__getitem__(self, self._actual(key))
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return dict.__contains__(self, self._actual(key))
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return dict.get(self, self._actual(key), default)
|
||||||
|
|
||||||
|
def pop(self, key, *default):
|
||||||
|
return dict.pop(self, self._actual(key), *default)
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||||
|
dict.__setitem__(self, self._actual(key), value)
|
||||||
|
|
||||||
|
def ci_json(obj):
|
||||||
|
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [ci_json(v) for v in obj]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||||
@@ -31,6 +90,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +141,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +151,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -172,6 +248,16 @@ def assert_edit_allowed(target_path, require):
|
|||||||
|
|
||||||
def detect_format_version(d):
|
def detect_format_version(d):
|
||||||
while d:
|
while d:
|
||||||
|
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||||
|
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||||
|
ext_path = d + ".xml"
|
||||||
|
if os.path.isfile(ext_path):
|
||||||
|
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||||
|
ext_head = f.read(2000)
|
||||||
|
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||||
|
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
cfg_path = os.path.join(d, "Configuration.xml")
|
cfg_path = os.path.join(d, "Configuration.xml")
|
||||||
if os.path.isfile(cfg_path):
|
if os.path.isfile(cfg_path):
|
||||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||||
@@ -265,17 +351,54 @@ def import_ci_fragment(xml_string):
|
|||||||
def parse_value_list(val):
|
def parse_value_list(val):
|
||||||
val = val.strip()
|
val = val.strip()
|
||||||
if val.startswith("["):
|
if val.startswith("["):
|
||||||
arr = json.loads(val)
|
arr = ci_json(json.loads(val))
|
||||||
return [str(item) for item in arr]
|
return [str(item) for item in arr]
|
||||||
return [val]
|
return [val]
|
||||||
|
|
||||||
|
|
||||||
def save_xml_bom(tree, path):
|
def _detect_xml_style(path):
|
||||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||||
if not xml_bytes.endswith(b"\n"):
|
try:
|
||||||
|
raw = open(path, "rb").read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
body = raw[3:] if bom else raw
|
||||||
|
crlf = b"\r\n" in body
|
||||||
|
m = re.search(rb'encoding="([^"]+)"', body[:200])
|
||||||
|
enc = m.group(1).decode("ascii") if m else "utf-8"
|
||||||
|
final_nl = body.endswith(b"\n")
|
||||||
|
return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_xml_bytes(xml_bytes, style):
|
||||||
|
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||||
|
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||||
|
enc_decl = style["enc"] if style else "UTF-8"
|
||||||
|
xml_bytes = xml_bytes.replace(
|
||||||
|
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||||
|
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||||
|
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||||
|
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||||
|
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||||
|
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||||
|
want_final_nl = style["final_nl"] if style else False
|
||||||
|
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||||
|
if want_final_nl:
|
||||||
xml_bytes += b"\n"
|
xml_bytes += b"\n"
|
||||||
|
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||||
|
if (style["crlf"] if style else True):
|
||||||
|
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||||
|
return xml_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def save_xml_bom(tree, path):
|
||||||
|
style = _detect_xml_style(path)
|
||||||
|
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||||
|
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
|
if style is None or style["bom"]:
|
||||||
f.write(b"\xef\xbb\xbf")
|
f.write(b"\xef\xbb\xbf")
|
||||||
f.write(xml_bytes)
|
f.write(xml_bytes)
|
||||||
|
|
||||||
@@ -304,6 +427,10 @@ TYPE_NORM_MAP = {
|
|||||||
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
||||||
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
||||||
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
||||||
|
'РегистрРасчёта': 'CalculationRegister', 'РегистрРасчета': 'CalculationRegister',
|
||||||
|
'ПланВидовРасчёта': 'ChartOfCalculationTypes', 'ПланВидовРасчета': 'ChartOfCalculationTypes',
|
||||||
|
'Роль': 'Role', 'ОбщийМакет': 'CommonTemplate', 'ЭлементСтиля': 'StyleItem',
|
||||||
|
'ОбщийРеквизит': 'CommonAttribute', 'ГруппаКоманд': 'CommandGroup',
|
||||||
# Russian plural
|
# Russian plural
|
||||||
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
||||||
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
||||||
@@ -313,6 +440,10 @@ TYPE_NORM_MAP = {
|
|||||||
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
||||||
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
||||||
'Подсистемы': 'Subsystem',
|
'Подсистемы': 'Subsystem',
|
||||||
|
'РегистрыРасчёта': 'CalculationRegister', 'РегистрыРасчета': 'CalculationRegister',
|
||||||
|
'ПланыВидовРасчёта': 'ChartOfCalculationTypes', 'ПланыВидовРасчета': 'ChartOfCalculationTypes',
|
||||||
|
'Роли': 'Role', 'ОбщиеМакеты': 'CommonTemplate', 'ЭлементыСтиля': 'StyleItem',
|
||||||
|
'ОбщиеРеквизиты': 'CommonAttribute', 'ГруппыКоманд': 'CommandGroup',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -348,7 +479,7 @@ def main():
|
|||||||
parser.add_argument("-Value", default=None)
|
parser.add_argument("-Value", default=None)
|
||||||
parser.add_argument("-CreateIfMissing", action="store_true")
|
parser.add_argument("-CreateIfMissing", action="store_true")
|
||||||
parser.add_argument("-NoValidate", action="store_true")
|
parser.add_argument("-NoValidate", action="store_true")
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
# --- Mode validation ---
|
# --- Mode validation ---
|
||||||
if args.DefinitionFile and args.Operation:
|
if args.DefinitionFile and args.Operation:
|
||||||
@@ -385,7 +516,12 @@ def main():
|
|||||||
f'\tversion="{format_version}">\n'
|
f'\tversion="{format_version}">\n'
|
||||||
f'</CommandInterface>'
|
f'</CommandInterface>'
|
||||||
)
|
)
|
||||||
with open(ci_path, "w", encoding="utf-8-sig") as fh:
|
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF в разделителях. (Правка
|
||||||
|
# существующего файла, наоборот, наследует его стиль — это делает
|
||||||
|
# save_xml_bom через _detect_xml_style.) newline="" обязателен: без него
|
||||||
|
# текстовый режим дал бы CRLF на Windows и LF на macOS.
|
||||||
|
empty_ci = empty_ci.replace("\r\n", "\n").replace("\n", "\r\n").rstrip("\r\n")
|
||||||
|
with open(ci_path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||||
fh.write(empty_ci)
|
fh.write(empty_ci)
|
||||||
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
||||||
else:
|
else:
|
||||||
@@ -511,7 +647,7 @@ def main():
|
|||||||
|
|
||||||
def do_place(json_val):
|
def do_place(json_val):
|
||||||
nonlocal add_count, modify_count
|
nonlocal add_count, modify_count
|
||||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
defn = ci_json(json_val if isinstance(json_val, dict) else json.loads(json_val))
|
||||||
cmd_name = normalize_cmd_name(str(defn["command"]))
|
cmd_name = normalize_cmd_name(str(defn["command"]))
|
||||||
group_name = str(defn["group"])
|
group_name = str(defn["group"])
|
||||||
if not cmd_name or not group_name:
|
if not cmd_name or not group_name:
|
||||||
@@ -539,7 +675,7 @@ def main():
|
|||||||
|
|
||||||
def do_order(json_val):
|
def do_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
defn = ci_json(json_val if isinstance(json_val, dict) else json.loads(json_val))
|
||||||
group_name = str(defn["group"])
|
group_name = str(defn["group"])
|
||||||
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
||||||
if not group_name or not commands:
|
if not group_name or not commands:
|
||||||
@@ -573,7 +709,7 @@ def main():
|
|||||||
|
|
||||||
def do_subsystem_order(json_val):
|
def do_subsystem_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
parsed = ci_json(json_val if isinstance(json_val, list) else json.loads(json_val))
|
||||||
subsystems = [str(s) for s in parsed]
|
subsystems = [str(s) for s in parsed]
|
||||||
if not subsystems:
|
if not subsystems:
|
||||||
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
||||||
@@ -598,7 +734,7 @@ def main():
|
|||||||
|
|
||||||
def do_group_order(json_val):
|
def do_group_order(json_val):
|
||||||
nonlocal add_count, remove_count
|
nonlocal add_count, remove_count
|
||||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
parsed = ci_json(json_val if isinstance(json_val, list) else json.loads(json_val))
|
||||||
groups = [str(g) for g in parsed]
|
groups = [str(g) for g in parsed]
|
||||||
if not groups:
|
if not groups:
|
||||||
print("group-order requires array of group names", file=sys.stderr)
|
print("group-order requires array of group names", file=sys.stderr)
|
||||||
@@ -628,7 +764,7 @@ def main():
|
|||||||
if not os.path.isabs(def_file):
|
if not os.path.isabs(def_file):
|
||||||
def_file = os.path.join(os.getcwd(), def_file)
|
def_file = os.path.join(os.getcwd(), def_file)
|
||||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
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):
|
if isinstance(ops, list):
|
||||||
operations = ops
|
operations = ops
|
||||||
else:
|
else:
|
||||||
@@ -638,19 +774,21 @@ def main():
|
|||||||
|
|
||||||
for op in operations:
|
for op in operations:
|
||||||
op_name = op.get("operation", args.Operation or "")
|
op_name = op.get("operation", args.Operation or "")
|
||||||
|
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||||
|
op_key = str(op_name).lower()
|
||||||
op_value = op.get("value", args.Value or "")
|
op_value = op.get("value", args.Value or "")
|
||||||
|
|
||||||
if op_name == "hide":
|
if op_key == "hide":
|
||||||
do_hide(parse_value_list(op_value))
|
do_hide(parse_value_list(op_value))
|
||||||
elif op_name == "show":
|
elif op_key == "show":
|
||||||
do_show(parse_value_list(op_value))
|
do_show(parse_value_list(op_value))
|
||||||
elif op_name == "place":
|
elif op_key == "place":
|
||||||
do_place(op_value)
|
do_place(op_value)
|
||||||
elif op_name == "order":
|
elif op_key == "order":
|
||||||
do_order(op_value)
|
do_order(op_value)
|
||||||
elif op_name == "subsystem-order":
|
elif op_key == "subsystem-order":
|
||||||
do_subsystem_order(op_value)
|
do_subsystem_order(op_value)
|
||||||
elif op_name == "group-order":
|
elif op_key == "group-order":
|
||||||
do_group_order(op_value)
|
do_group_order(op_value)
|
||||||
else:
|
else:
|
||||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||||
|
|||||||
@@ -1,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
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||||
@@ -51,16 +51,21 @@ $script:output = New-Object System.Text.StringBuilder 8192
|
|||||||
$script:allCommandNames = @()
|
$script:allCommandNames = @()
|
||||||
|
|
||||||
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
||||||
function Report-OK([string]$msg) {
|
function Report-OK {
|
||||||
|
param([string]$msg)
|
||||||
$script:okCount++
|
$script:okCount++
|
||||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||||
}
|
}
|
||||||
function Report-Error([string]$msg) {
|
function Report-Error {
|
||||||
|
param([string]$msg)
|
||||||
$script:errors++
|
$script:errors++
|
||||||
Out-Line "[ERROR] $msg"
|
Out-Line "[ERROR] $msg"
|
||||||
if ($script:errors -ge $MaxErrors) { $script:stopped = $true }
|
if ($script:errors -ge $MaxErrors) {
|
||||||
|
$script:stopped = $true
|
||||||
}
|
}
|
||||||
function Report-Warn([string]$msg) {
|
}
|
||||||
|
function Report-Warn {
|
||||||
|
param([string]$msg)
|
||||||
$script:warnings++
|
$script:warnings++
|
||||||
Out-Line "[WARN] $msg"
|
Out-Line "[WARN] $msg"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
# interface-validate v1.3 — Validate 1C CommandInterface.xml structure (+Report-*: общий эталон вывода валидаторов)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
|
|
||||||
|
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||||
|
# регистр не различают, в argparse совпадение точное.
|
||||||
|
def ci_parse_args(parser, argv=None):
|
||||||
|
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||||
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||||
|
for i, tok in enumerate(argv):
|
||||||
|
if tok.startswith('-') and tok.lower() in names:
|
||||||
|
argv[i] = names[tok.lower()]
|
||||||
|
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||||
|
choice_map = {}
|
||||||
|
for a in parser._actions:
|
||||||
|
if a.choices:
|
||||||
|
for s in a.option_strings:
|
||||||
|
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||||
|
for i in range(len(argv) - 1):
|
||||||
|
m = choice_map.get(argv[i])
|
||||||
|
if m and argv[i + 1].lower() in m:
|
||||||
|
argv[i + 1] = m[argv[i + 1].lower()]
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
NS_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
NS_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
||||||
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
||||||
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||||
@@ -83,7 +105,7 @@ def main():
|
|||||||
parser.add_argument('-Detailed', action='store_true')
|
parser.add_argument('-Detailed', action='store_true')
|
||||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||||
args = parser.parse_args()
|
args = ci_parse_args(parser)
|
||||||
|
|
||||||
ci_path = args.CIPath
|
ci_path = args.CIPath
|
||||||
detailed = args.Detailed
|
detailed = args.Detailed
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ allowed-tools:
|
|||||||
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
||||||
регистрирует объект в `Configuration.xml`.
|
регистрирует объект в `Configuration.xml`.
|
||||||
|
|
||||||
|
`ConfigDumpInfo.xml` намеренно не трогается: это служебный файл версий объектов, которым управляет
|
||||||
|
платформа (для инкрементальной выгрузки).
|
||||||
|
|
||||||
## Порядок работы
|
## Порядок работы
|
||||||
|
|
||||||
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ shorthand — вместо строки задаётся объект:
|
|||||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
||||||
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
||||||
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||||
|
| `lineNumberLength` | по режиму совместимости | `5`…`9` — разрядность номера строки: `5` → до 99 999 строк, `9` → до 999 999 999. Требует формата 2.20 (платформа 8.3.27) |
|
||||||
|
|
||||||
### `lineNumber` — стандартный реквизит НомерСтроки
|
### `lineNumber` — стандартный реквизит НомерСтроки
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||||
| `owners` | `[]` | массив ссылок-владельцев: `["CatalogRef.Контрагенты"]` |
|
| `owners` | `[]` | массив владельцев: `["Catalog.Контрагенты"]` |
|
||||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||||
| `codeType` | `String` | `String` / `Number` |
|
| `codeType` | `String` | `String` / `Number` |
|
||||||
|
|||||||
@@ -7,16 +7,21 @@
|
|||||||
| Ключ | Умолчание | Значения |
|
| Ключ | Умолчание | Значения |
|
||||||
|------|-----------|----------|
|
|------|-----------|----------|
|
||||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||||
|
|
||||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||||
- строка — URL-путь без методов: `"/health"`;
|
- строка — 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`.
|
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||||
Обработчик метода в модуле именуется `{ИмяШаблона}{ИмяМетода}`.
|
Обработчик по умолчанию именуется `{ИмяШаблона}{ИмяМетода}`; в типовых конфигурациях он часто
|
||||||
|
произвольный — тогда задавайте `handler` явно.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
||||||
@@ -31,21 +36,29 @@ HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `
|
|||||||
| Ключ | Умолчание | Значения |
|
| Ключ | Умолчание | Значения |
|
||||||
|------|-----------|----------|
|
|------|-----------|----------|
|
||||||
| `namespace` | пусто | URI пространства имён WSDL |
|
| `namespace` | пусто | URI пространства имён WSDL |
|
||||||
| `xdtoPackages` | пусто | XDTO-пакеты |
|
| `xdtoPackages` | пусто | список пакетов (см. ниже) |
|
||||||
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
| `descriptorFileName` | `= name` + `.1cws` | имя файла дескриптора |
|
||||||
|
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||||
| `operations` | `{}` | операции (см. ниже) |
|
| `operations` | `{}` | операции (см. ниже) |
|
||||||
|
|
||||||
|
`xdtoPackages` — **массив** значений: `"XDTOPackage.Имя"` — пакет конфигурации, любое другое
|
||||||
|
значение — URI внешнего пространства имён (например `"http://v8.1c.ru/8.3/data/ext"`).
|
||||||
|
|
||||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||||
`handler` (имя процедуры, по умолчанию = имя операции), `parameters`.
|
`procedureName` (имя процедуры, по умолчанию = имя операции; синоним ключа — `handler`),
|
||||||
|
`dataLockControlMode` (по умолчанию `Managed`), `synonym`, `comment`, `parameters`.
|
||||||
|
|
||||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||||
- строка — XDTO-тип (`direction` = `In`);
|
- строка — 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`.
|
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||||
|
Тип из собственного пространства имён задаётся в нотации Кларка — `"{http://ваш.uri}ИмяТипа"`;
|
||||||
|
компилятор сам объявит локальный `xmlns` в теге, как это делает платформа.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
||||||
|
|||||||
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