mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-12 21:20:52 +03:00
Compare commits
306
Commits
@@ -1,4 +1,4 @@
|
||||
# cf-edit v1.4 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||
@@ -29,11 +29,149 @@ if (-not (Test-Path $ConfigPath)) { Write-Error "File not found: $ConfigPath"; e
|
||||
$resolvedPath = (Resolve-Path $ConfigPath).Path
|
||||
$script:configDir = [System.IO.Path]::GetDirectoryName($resolvedPath)
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
Assert-EditAllowed $resolvedPath 'editable'
|
||||
|
||||
# --- Load XML with PreserveWhitespace ---
|
||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$script:xmlDoc.PreserveWhitespace = $true
|
||||
$script:xmlDoc.Load($resolvedPath)
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
|
||||
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
|
||||
|
||||
$script:addCount = 0
|
||||
$script:removeCount = 0
|
||||
$script:modifyCount = 0
|
||||
@@ -76,7 +214,7 @@ Info "Configuration: $($script:objName)"
|
||||
$script:typeOrder = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -92,7 +230,7 @@ $script:typeOrder = @(
|
||||
$script:typeToDir = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
||||
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
||||
@@ -580,6 +718,7 @@ $script:ruTypeMap = @{
|
||||
"регистррасчёта" = "CalculationRegister"
|
||||
"бизнеспроцесс" = "BusinessProcess"
|
||||
"задача" = "Task"
|
||||
"бот" = "Bot"
|
||||
"планобмена" = "ExchangePlan"
|
||||
"хранилищенастроек" = "SettingsStorage"
|
||||
}
|
||||
@@ -730,7 +869,7 @@ function Do-SetHomePage($valArg) {
|
||||
|
||||
$hpXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
|
||||
$leftXml
|
||||
$rightXml
|
||||
|
||||
@@ -1,16 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-edit v1.4 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid as _uuid
|
||||
from html import escape as html_escape
|
||||
from lxml import etree
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
@@ -21,7 +199,7 @@ XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
TYPE_ORDER = [
|
||||
"Language", "Subsystem", "StyleItem", "Style",
|
||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||
@@ -37,7 +215,7 @@ TYPE_ORDER = [
|
||||
TYPE_TO_DIR = {
|
||||
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
||||
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
||||
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
||||
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
||||
@@ -146,12 +324,48 @@ def parse_batch_value(val):
|
||||
return items
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -190,10 +404,16 @@ def main():
|
||||
resolved_path = os.path.abspath(config_path)
|
||||
config_dir = os.path.dirname(resolved_path)
|
||||
|
||||
assert_edit_allowed(resolved_path, "editable")
|
||||
|
||||
xml_parser = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(resolved_path, xml_parser)
|
||||
xml_root = tree.getroot()
|
||||
|
||||
# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
|
||||
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
|
||||
format_version = xml_root.get('version') or '2.17'
|
||||
|
||||
add_count = 0
|
||||
remove_count = 0
|
||||
modify_count = 0
|
||||
@@ -608,6 +828,7 @@ def main():
|
||||
"регистррасчета": "CalculationRegister",
|
||||
"регистррасчёта": "CalculationRegister",
|
||||
"бизнеспроцесс": "BusinessProcess",
|
||||
"бот": "Bot",
|
||||
"задача": "Task", "планобмена": "ExchangePlan",
|
||||
"хранилищенастроек": "SettingsStorage",
|
||||
}
|
||||
@@ -742,7 +963,7 @@ def main():
|
||||
'<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">\r\n'
|
||||
f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">\r\n'
|
||||
f'\t<WorkingAreaTemplate>{tmpl}</WorkingAreaTemplate>\r\n'
|
||||
f'{left_xml}\r\n'
|
||||
f'{right_xml}\r\n'
|
||||
@@ -806,7 +1027,7 @@ def main():
|
||||
if os.path.isfile(validate_script):
|
||||
print()
|
||||
print("--- Running cf-validate ---")
|
||||
subprocess.run([sys.executable, validate_script, "-ConfigPath", "-Path", resolved_path])
|
||||
subprocess.run([sys.executable, validate_script, "-ConfigPath", resolved_path])
|
||||
|
||||
# --- Summary ---
|
||||
print()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-info v1.2 — Compact summary of 1C configuration root
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||
@@ -89,7 +89,7 @@ function Get-PropML([string]$propName) {
|
||||
$typeOrder = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -105,6 +105,7 @@ $typeRuNames = @{
|
||||
"Language"="Языки"; "Subsystem"="Подсистемы"; "StyleItem"="Элементы стиля"; "Style"="Стили"
|
||||
"CommonPicture"="Общие картинки"; "SessionParameter"="Параметры сеанса"; "Role"="Роли"
|
||||
"CommonTemplate"="Общие макеты"; "FilterCriterion"="Критерии отбора"; "CommonModule"="Общие модули"
|
||||
"Bot"="Боты"
|
||||
"CommonAttribute"="Общие реквизиты"; "ExchangePlan"="Планы обмена"; "XDTOPackage"="XDTO-пакеты"
|
||||
"WebService"="Веб-сервисы"; "HTTPService"="HTTP-сервисы"; "WSReference"="WS-ссылки"
|
||||
"EventSubscription"="Подписки на события"; "ScheduledJob"="Регламентные задания"
|
||||
@@ -218,6 +219,78 @@ function Get-HomePageLayout {
|
||||
|
||||
$script:homePage = Get-HomePageLayout
|
||||
|
||||
# --- Support state (Ext/ParentConfigurations.bin) ---
|
||||
# Decodes the 1C support-state file. See docs/1c-support-state-spec.md.
|
||||
# Returns $null on absent/error; else hashtable: State='absent'|'removed'|'parsed',
|
||||
# G (0=editing on, 1=off), K (vendor configs), Vendors @(@{Vendor;Name;Version}),
|
||||
# Counts @(locked, editable, removed) by f1 — record tally (K>1 counts each
|
||||
# vendor block separately); only computed when G=0.
|
||||
function Read-SupportState([string]$binPath) {
|
||||
try {
|
||||
if (-not (Test-Path $binPath)) { return @{ State = 'absent' } }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return @{ State = 'removed' } }
|
||||
$startIdx = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $startIdx = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $startIdx, $bytes.Length - $startIdx)
|
||||
$h = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $h.Success) { return $null }
|
||||
$G = [int]$h.Groups[1].Value
|
||||
$K = [int]$h.Groups[2].Value
|
||||
if ($K -eq 0) { return @{ State = 'removed' } }
|
||||
# Vendor descriptors: ...,"ver","vendor","name",count,
|
||||
$vendors = @()
|
||||
$vRe = [regex]'"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",\d+,'
|
||||
foreach ($m in $vRe.Matches($text)) {
|
||||
$vendors += @{
|
||||
Version = ($m.Groups[1].Value -replace '""','"')
|
||||
Vendor = ($m.Groups[2].Value -replace '""','"')
|
||||
Name = ($m.Groups[3].Value -replace '""','"')
|
||||
}
|
||||
}
|
||||
# Per-object counts only matter when editing is enabled (G=0); when G=1 the
|
||||
# whole config is read-only and stored f1 values are the inactive default.
|
||||
$counts = $null
|
||||
if ($G -eq 0) {
|
||||
$counts = @(0, 0, 0)
|
||||
# Object records: f1,0,uuidLocal[,uuidVendor] — flags precede the uuid.
|
||||
$rRe = [regex]'([0-2]),0,[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
foreach ($m in $rRe.Matches($text)) {
|
||||
$counts[[int]$m.Groups[1].Value]++
|
||||
}
|
||||
}
|
||||
return @{ State = 'parsed'; G = $G; K = $K; Vendors = $vendors; Counts = $counts }
|
||||
} catch { return $null }
|
||||
}
|
||||
|
||||
function Get-SupportLines {
|
||||
$configDir = [System.IO.Path]::GetDirectoryName($ConfigPath)
|
||||
$binPath = Join-Path (Join-Path $configDir "Ext") "ParentConfigurations.bin"
|
||||
$st = Read-SupportState $binPath
|
||||
$out = @()
|
||||
if (-not $st -or $st.State -eq 'absent') {
|
||||
if ($cfgExtPurpose) { $out += "Поддержка: расширение (CFE), правки свободны" }
|
||||
else { $out += "Поддержка: не на поддержке (своя конфигурация)" }
|
||||
return $out
|
||||
}
|
||||
if ($st.State -eq 'removed') {
|
||||
$out += "Поддержка: снята с поддержки полностью"
|
||||
return $out
|
||||
}
|
||||
$out += "Поддержка: на поддержке"
|
||||
if ($st.G -eq 0) {
|
||||
$out += " Возможность изменения: включена"
|
||||
$out += " Объектов: на замке $($st.Counts[0]) / редактируется $($st.Counts[1]) / снято $($st.Counts[2])"
|
||||
} else {
|
||||
$out += " Возможность изменения: выключена — вся конфигурация read-only (правки заблокированы)"
|
||||
}
|
||||
$out += " Конфигураций поставщика: $($st.K)"
|
||||
if ($st.K -gt 1) {
|
||||
foreach ($v in $st.Vendors) { $out += " Поставщик: $($v.Vendor) — $($v.Name) $($v.Version)" }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
|
||||
function Format-HomePageItem($it, [bool]$detailed) {
|
||||
$badges = @()
|
||||
$badges += "h=$($it.height)"
|
||||
@@ -253,6 +326,7 @@ $cfgVersion = Get-PropText "Version"
|
||||
$cfgVendor = Get-PropText "Vendor"
|
||||
$cfgCompat = Get-PropText "CompatibilityMode"
|
||||
$cfgExtCompat = Get-PropText "ConfigurationExtensionCompatibilityMode"
|
||||
$cfgExtPurpose = Get-PropText "ConfigurationExtensionPurpose"
|
||||
$cfgDefaultRun = Get-PropText "DefaultRunMode"
|
||||
$cfgScript = Get-PropText "ScriptVariant"
|
||||
$cfgDefaultLang = Get-PropText "DefaultLanguage"
|
||||
@@ -284,6 +358,7 @@ if ($Mode -eq "overview" -and -not $Section) {
|
||||
Out "Формат: $version"
|
||||
if ($cfgVendor) { Out "Поставщик: $cfgVendor" }
|
||||
if ($cfgVersion) { Out "Версия: $cfgVersion" }
|
||||
foreach ($l in (Get-SupportLines)) { Out $l }
|
||||
Out "Совместимость: $cfgCompat"
|
||||
Out "Режим запуска: $cfgDefaultRun"
|
||||
Out "Язык скриптов: $cfgScript"
|
||||
@@ -386,6 +461,7 @@ if ($Mode -eq "full" -and -not $Section) {
|
||||
if ($cfgPrefix) { Out "Префикс: $cfgPrefix" }
|
||||
if ($cfgVendor) { Out "Поставщик: $cfgVendor" }
|
||||
if ($cfgVersion) { Out "Версия: $cfgVersion" }
|
||||
foreach ($l in (Get-SupportLines)) { Out $l }
|
||||
$cfgUpdateAddr = Get-PropText "UpdateCatalogAddress"
|
||||
if ($cfgUpdateAddr) { Out "Каталог обн.: $cfgUpdateAddr" }
|
||||
Out ""
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-info v1.2 — Compact summary of 1C configuration root
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
from lxml import etree
|
||||
@@ -94,7 +95,7 @@ def get_prop_ml(prop_name):
|
||||
type_order = [
|
||||
"Language", "Subsystem", "StyleItem", "Style",
|
||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
||||
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||
@@ -110,6 +111,7 @@ type_ru_names = {
|
||||
"Language": "Языки", "Subsystem": "Подсистемы", "StyleItem": "Элементы стиля", "Style": "Стили",
|
||||
"CommonPicture": "Общие картинки", "SessionParameter": "Параметры сеанса", "Role": "Роли",
|
||||
"CommonTemplate": "Общие макеты", "FilterCriterion": "Критерии отбора", "CommonModule": "Общие модули",
|
||||
"Bot": "Боты",
|
||||
"CommonAttribute": "Общие реквизиты", "ExchangePlan": "Планы обмена", "XDTOPackage": "XDTO-пакеты",
|
||||
"WebService": "Веб-сервисы", "HTTPService": "HTTP-сервисы", "WSReference": "WS-ссылки",
|
||||
"EventSubscription": "Подписки на события", "ScheduledJob": "Регламентные задания",
|
||||
@@ -219,6 +221,71 @@ def get_home_page_layout():
|
||||
|
||||
home_page = get_home_page_layout()
|
||||
|
||||
# --- Support state (Ext/ParentConfigurations.bin) ---
|
||||
# Decodes the 1C support-state file. See docs/1c-support-state-spec.md.
|
||||
# Returns None on absent/error; else dict: state='absent'|'removed'|'parsed',
|
||||
# g (0=editing on, 1=off), k (vendor configs), vendors [{vendor,name,version}],
|
||||
# counts [locked, editable, removed] by f1 — record tally (k>1 counts each
|
||||
# vendor block separately); only computed when g==0.
|
||||
def read_support_state(bin_path):
|
||||
try:
|
||||
if not os.path.isfile(bin_path):
|
||||
return {"state": "absent"}
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return {"state": "removed"}
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return None
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return {"state": "removed"}
|
||||
vendors = []
|
||||
for m in re.finditer(r'"((?:[^"]|"")*)","((?:[^"]|"")*)","((?:[^"]|"")*)",\d+,', text):
|
||||
vendors.append({
|
||||
"version": m.group(1).replace('""', '"'),
|
||||
"vendor": m.group(2).replace('""', '"'),
|
||||
"name": m.group(3).replace('""', '"'),
|
||||
})
|
||||
counts = None
|
||||
if g == 0:
|
||||
counts = [0, 0, 0]
|
||||
for m in re.finditer(r"([0-2]),0,[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", text):
|
||||
counts[int(m.group(1))] += 1
|
||||
return {"state": "parsed", "g": g, "k": k, "vendors": vendors, "counts": counts}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_support_lines():
|
||||
config_dir = os.path.dirname(config_path)
|
||||
bin_path = os.path.join(config_dir, "Ext", "ParentConfigurations.bin")
|
||||
st = read_support_state(bin_path)
|
||||
res = []
|
||||
if not st or st["state"] == "absent":
|
||||
if cfg_ext_purpose:
|
||||
res.append("Поддержка: расширение (CFE), правки свободны")
|
||||
else:
|
||||
res.append("Поддержка: не на поддержке (своя конфигурация)")
|
||||
return res
|
||||
if st["state"] == "removed":
|
||||
res.append("Поддержка: снята с поддержки полностью")
|
||||
return res
|
||||
res.append("Поддержка: на поддержке")
|
||||
if st["g"] == 0:
|
||||
res.append(" Возможность изменения: включена")
|
||||
res.append(f" Объектов: на замке {st['counts'][0]} / редактируется {st['counts'][1]} / снято {st['counts'][2]}")
|
||||
else:
|
||||
res.append(" Возможность изменения: выключена — вся конфигурация read-only (правки заблокированы)")
|
||||
res.append(f" Конфигураций поставщика: {st['k']}")
|
||||
if st["k"] > 1:
|
||||
for v in st["vendors"]:
|
||||
res.append(f" Поставщик: {v['vendor']} — {v['name']} {v['version']}")
|
||||
return res
|
||||
|
||||
def format_home_page_item(it, detailed):
|
||||
badges = [f"h={it['height']}"]
|
||||
if not it["common"]:
|
||||
@@ -249,6 +316,7 @@ cfg_version = get_prop_text("Version")
|
||||
cfg_vendor = get_prop_text("Vendor")
|
||||
cfg_compat = get_prop_text("CompatibilityMode")
|
||||
cfg_ext_compat = get_prop_text("ConfigurationExtensionCompatibilityMode")
|
||||
cfg_ext_purpose = get_prop_text("ConfigurationExtensionPurpose")
|
||||
cfg_default_run = get_prop_text("DefaultRunMode")
|
||||
cfg_script = get_prop_text("ScriptVariant")
|
||||
cfg_default_lang = get_prop_text("DefaultLanguage")
|
||||
@@ -281,6 +349,8 @@ if args.Mode == "overview" and not args.Section:
|
||||
out(f"Поставщик: {cfg_vendor}")
|
||||
if cfg_version:
|
||||
out(f"Версия: {cfg_version}")
|
||||
for ln in get_support_lines():
|
||||
out(ln)
|
||||
out(f"Совместимость: {cfg_compat}")
|
||||
out(f"Режим запуска: {cfg_default_run}")
|
||||
out(f"Язык скриптов: {cfg_script}")
|
||||
@@ -369,6 +439,8 @@ if args.Mode == "full" and not args.Section:
|
||||
out(f"Поставщик: {cfg_vendor}")
|
||||
if cfg_version:
|
||||
out(f"Версия: {cfg_version}")
|
||||
for ln in get_support_lines():
|
||||
out(ln)
|
||||
cfg_update_addr = get_prop_text("UpdateCatalogAddress")
|
||||
if cfg_update_addr:
|
||||
out(f"Каталог обн.: {cfg_update_addr}")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-init v1.2 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.4 — Create empty 1C configuration scaffold
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -7,7 +7,12 @@ param(
|
||||
[string]$OutputDir = "src",
|
||||
[string]$Version,
|
||||
[string]$Vendor,
|
||||
[string]$CompatibilityMode = "Version8_3_24"
|
||||
[string]$CompatibilityMode = "Version8_3_24",
|
||||
# Версия формата выгрузки (MDClasses). Её задаёт ПЛАТФОРМА, которой выгружают, и от режима
|
||||
# совместимости она не зависит: 8.3.20-8.3.24 пишут 2.17, 8.3.25 — 2.18, 8.3.26 — 2.19,
|
||||
# 8.3.27 — 2.20. Дефолт консервативный: 2.17 читается всеми поддерживаемыми платформами.
|
||||
[ValidateSet("2.17", "2.18", "2.19", "2.20", "2.21")]
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -73,7 +78,7 @@ $versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -175,7 +180,7 @@ $cfgXml = @"
|
||||
# --- Languages/Русский.xml ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<Properties>
|
||||
<Name>Русский</Name>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-init v1.2 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.4 — Create empty 1C configuration scaffold
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration."""
|
||||
import sys, os, argparse, uuid
|
||||
@@ -24,6 +24,11 @@ def main():
|
||||
parser.add_argument('-Version', dest='Version', default='')
|
||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости:
|
||||
# 8.3.20-8.3.24 пишут 2.17, 8.3.25 — 2.18, 8.3.26 — 2.19, 8.3.27 — 2.20.
|
||||
# Дефолт консервативный: 2.17 читается всеми платформами.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17',
|
||||
choices=['2.17', '2.18', '2.19', '2.20', '2.21'])
|
||||
args = parser.parse_args()
|
||||
|
||||
name = args.Name
|
||||
@@ -96,7 +101,7 @@ def main():
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
@@ -168,7 +173,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>Русский</Name>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cf-validate v1.3 — Validate 1C configuration root structure
|
||||
# cf-validate v1.5 — Validate 1C configuration root structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -104,11 +104,11 @@ $validClassIds = @(
|
||||
"fb282519-d103-4dd3-bc12-cb271d631dfc" # home page / client app interface
|
||||
)
|
||||
|
||||
# 44 types in canonical order
|
||||
# 45 types in canonical order
|
||||
$childObjectTypes = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -125,6 +125,7 @@ $childTypeDirMap = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||
"Bot"="Bots"
|
||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
||||
@@ -204,8 +205,9 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
$version = $root.GetAttribute("version")
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-validate v1.3 — Validate 1C configuration XML structure
|
||||
# cf-validate v1.5 — Validate 1C configuration XML structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||
import sys, os, argparse, re
|
||||
@@ -33,11 +33,11 @@ VALID_CLASS_IDS = [
|
||||
'fb282519-d103-4dd3-bc12-cb271d631dfc', # home page / client app interface
|
||||
]
|
||||
|
||||
# 44 types in canonical order
|
||||
# 45 types in canonical order
|
||||
CHILD_OBJECT_TYPES = [
|
||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||
@@ -54,6 +54,7 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||
'Bot': 'Bots',
|
||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||
@@ -231,8 +232,9 @@ def main():
|
||||
version = root.get('version', '')
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'):
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-borrow v1.3 — Borrow objects from configuration into extension (CFE)
|
||||
# cfe-borrow v1.11 — Borrow objects from configuration into extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$ExtensionPath,
|
||||
@@ -13,6 +13,31 @@ $ErrorActionPreference = "Stop"
|
||||
function Info([string]$msg) { Write-Host "[INFO] $msg" }
|
||||
function Warn([string]$msg) { Write-Host "[WARN] $msg" }
|
||||
|
||||
# Form data-binding tags (value = attribute path). A binding survives only if its root
|
||||
# attribute is borrowed into the form's <Attributes>; otherwise it must be stripped or the
|
||||
# platform rejects the form with "Неверный путь к данным" on load.
|
||||
$script:formBindingDataTags = @('DataPath','TitleDataPath','FooterDataPath','HeaderDataPath','MultipleValueDataPath','MultipleValuePresentDataPath')
|
||||
# Picture-path binding tags (value = picture index path, never a data attribute) — always stripped in the skeleton.
|
||||
$script:formBindingPictureTags = @('RowPictureDataPath','MultipleValuePictureDataPath')
|
||||
|
||||
# Strip data-binding tags whose root attribute isn't borrowed.
|
||||
# $keepObjekt=$true (BorrowMainAttribute): keep Объект.* data bindings, strip the rest.
|
||||
# $keepObjekt=$false (default skeleton): strip all bindings. Picture-path tags are always stripped.
|
||||
function Strip-FormBindings {
|
||||
param([string]$xml, [bool]$keepObjekt)
|
||||
foreach ($tag in $script:formBindingDataTags) {
|
||||
if ($keepObjekt) {
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>(?!Объект\.)[^<]*</$tag>", '')
|
||||
} else {
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>[^<]*</$tag>", '')
|
||||
}
|
||||
}
|
||||
foreach ($tag in $script:formBindingPictureTags) {
|
||||
$xml = [regex]::Replace($xml, "\s*<$tag>[^<]*</$tag>", '')
|
||||
}
|
||||
return $xml
|
||||
}
|
||||
|
||||
# --- 1. Resolve paths ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($ExtensionPath)) {
|
||||
$ExtensionPath = Join-Path (Get-Location).Path $ExtensionPath
|
||||
@@ -260,14 +285,36 @@ $script:generatedTypes = @{
|
||||
"DefinedType" = @(
|
||||
@{ prefix = "DefinedType"; category = "DefinedType" }
|
||||
)
|
||||
"Sequence" = @(
|
||||
@{ prefix = "SequenceRecord"; category = "Record" }
|
||||
@{ prefix = "SequenceManager"; category = "Manager" }
|
||||
@{ prefix = "SequenceRecordSet"; category = "RecordSet" }
|
||||
)
|
||||
"FilterCriterion" = @(
|
||||
@{ prefix = "FilterCriterionManager"; category = "Manager" }
|
||||
@{ prefix = "FilterCriterionList"; category = "List" }
|
||||
)
|
||||
"SettingsStorage" = @(
|
||||
@{ prefix = "SettingsStorageManager"; category = "Manager" }
|
||||
)
|
||||
"IntegrationService" = @(
|
||||
@{ prefix = "IntegrationServiceManager"; category = "Manager" }
|
||||
)
|
||||
"WSReference" = @(
|
||||
@{ prefix = "WSReferenceManager"; category = "Manager" }
|
||||
)
|
||||
}
|
||||
|
||||
# Types that need ChildObjects element
|
||||
# Types that need ChildObjects element — fallback when the source object cannot be probed.
|
||||
# The platform emits <ChildObjects> for every container type even when empty, and rejects
|
||||
# the file without it ("ожидаемое ChildObjects"); primary signal is the source object itself.
|
||||
$typesWithChildObjects = @(
|
||||
"Catalog","Document","ExchangePlan","ChartOfAccounts",
|
||||
"ChartOfCharacteristicTypes","ChartOfCalculationTypes",
|
||||
"BusinessProcess","Task","Enum",
|
||||
"InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister"
|
||||
"InformationRegister","AccumulationRegister","AccountingRegister","CalculationRegister",
|
||||
"DataProcessor","Report","DocumentJournal","FilterCriterion","SettingsStorage",
|
||||
"Sequence","HTTPService","WebService","IntegrationService","Subsystem"
|
||||
)
|
||||
|
||||
# CommonModule properties to copy from source
|
||||
@@ -323,7 +370,10 @@ function Detect-FormatVersion([string]$dir) {
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
@@ -419,7 +469,18 @@ function Read-SourceObject {
|
||||
$srcProps[$propName] = $propNode.InnerText.Trim()
|
||||
}
|
||||
}
|
||||
# DefinedType: carry the <Type> definition. A type alias is meaningless as a bare shell —
|
||||
# the platform needs its underlying type (e.g. to know a column is a summable Number for totals).
|
||||
if ($typeName -eq "DefinedType") {
|
||||
$typeNode = $propsNode.SelectSingleNode("md:Type", $srcNs)
|
||||
if ($typeNode) {
|
||||
$srcProps["__TypeXml"] = [regex]::Replace($typeNode.OuterXml, '\s+xmlns(?::\w+)?="[^"]*"', '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Whether the platform emits <ChildObjects> for this type — the source object is the ground truth
|
||||
$srcProps["__HasChildObjects"] = ($srcEl.SelectSingleNode("md:ChildObjects", $srcNs) -ne $null)
|
||||
|
||||
return @{
|
||||
Uuid = $srcUuid
|
||||
@@ -481,8 +542,23 @@ function Borrow-Form {
|
||||
}
|
||||
$srcFormContent = [System.IO.File]::ReadAllText($srcFormXmlPath, $enc)
|
||||
|
||||
# 3. Generate form metadata XML (ФормаЭлемента.xml)
|
||||
$newFormUuid = [guid]::NewGuid().ToString()
|
||||
# 3. Generate form metadata XML (ФормаЭлемента.xml).
|
||||
# If the wrapper was already borrowed, reuse its uuid so re-borrow is idempotent
|
||||
# (regenerating it would churn the form's identity on every rerun).
|
||||
$formMetaFileExisting = Join-Path (Join-Path (Join-Path (Join-Path $extDir $dirName) $objName) "Forms") "${formName}.xml"
|
||||
$newFormUuid = ""
|
||||
if (Test-Path $formMetaFileExisting) {
|
||||
try {
|
||||
$existingDoc = New-Object System.Xml.XmlDocument
|
||||
$existingDoc.Load($formMetaFileExisting)
|
||||
$existingFormNode = $existingDoc.DocumentElement.SelectSingleNode("*[local-name()='Form']")
|
||||
if ($existingFormNode) {
|
||||
$existingUuid = $existingFormNode.GetAttribute("uuid")
|
||||
if ($existingUuid) { $newFormUuid = $existingUuid }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $newFormUuid) { $newFormUuid = [guid]::NewGuid().ToString() }
|
||||
$formMetaSb = New-Object System.Text.StringBuilder
|
||||
$formMetaSb.AppendLine("<?xml version=`"1.0`" encoding=`"UTF-8`"?>") | Out-Null
|
||||
$formMetaSb.AppendLine("<MetaDataObject $($script:xmlnsDecl) version=`"$($script:formatVersion)`">") | Out-Null
|
||||
@@ -516,8 +592,10 @@ function Borrow-Form {
|
||||
$srcFormDoc.Load($srcFormXmlPath)
|
||||
$srcFormEl = $srcFormDoc.DocumentElement
|
||||
|
||||
$formVersion = $srcFormEl.GetAttribute("version")
|
||||
if (-not $formVersion) { $formVersion = $script:formatVersion }
|
||||
# Borrowed form must use the extension's format version (not the source form's), so the whole
|
||||
# extension stays uniform — otherwise the platform rejects the import on a version mismatch
|
||||
# (e.g. a 2.13 form inside a 2.17 extension). The platform itself upgrades the form to the root version.
|
||||
$formVersion = $script:formatVersion
|
||||
|
||||
# Find direct children: form properties, AutoCommandBar, ChildItems
|
||||
$srcAutoCmd = $null
|
||||
@@ -552,13 +630,8 @@ function Borrow-Form {
|
||||
$autoCmdXml = $autoCmdXml -replace '<Autofill>true</Autofill>', '<Autofill>false</Autofill>'
|
||||
# Strip ExcludedCommand (references to standard commands invalid in extension)
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
||||
# Strip DataPath in AutoCommandBar buttons
|
||||
if ($BorrowMainAttr) {
|
||||
# Keep only Объект.* DataPaths
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<DataPath>(?!Объект\.)[^<]*</DataPath>', '')
|
||||
} else {
|
||||
$autoCmdXml = [regex]::Replace($autoCmdXml, '\s*<DataPath>[^<]*</DataPath>', '')
|
||||
}
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
$autoCmdXml = Strip-FormBindings $autoCmdXml ([bool]$BorrowMainAttr)
|
||||
}
|
||||
|
||||
# ChildItems: copy full tree, clean up base-config references
|
||||
@@ -568,17 +641,9 @@ function Borrow-Form {
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, $nsStripPattern, '')
|
||||
# Replace all CommandName values with 0
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>')
|
||||
# Strip DataPath, TitleDataPath, RowPictureDataPath
|
||||
if ($BorrowMainAttr) {
|
||||
# Keep only Объект.* DataPaths — strip form-attribute DataPaths (not borrowed)
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<DataPath>(?!Объект\.)[^<]*</DataPath>', '')
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<TitleDataPath>(?!Объект\.)[^<]*</TitleDataPath>', '')
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<RowPictureDataPath>[^<]*</RowPictureDataPath>', '')
|
||||
} else {
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<DataPath>[^<]*</DataPath>', '')
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<TitleDataPath>[^<]*</TitleDataPath>', '')
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<RowPictureDataPath>[^<]*</RowPictureDataPath>', '')
|
||||
}
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
# (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*/RowPicture*)
|
||||
$childItemsXml = Strip-FormBindings $childItemsXml ([bool]$BorrowMainAttr)
|
||||
# Strip ExcludedCommand in nested AutoCommandBars (references to standard commands invalid in extension)
|
||||
$childItemsXml = [regex]::Replace($childItemsXml, '\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '')
|
||||
# Strip TypeLink blocks with human-readable DataPath (Items.XXX — can't convert to UUID)
|
||||
@@ -855,14 +920,19 @@ function Borrow-Form {
|
||||
[System.IO.File]::WriteAllText($formXmlFile, $formXmlSb.ToString(), $enc)
|
||||
Info " Created: $formXmlFile"
|
||||
|
||||
# 6. Create empty Module.bsl
|
||||
# 6. Create empty Module.bsl — but NEVER overwrite an existing one (re-borrow must
|
||||
# not clobber user code added to the form module).
|
||||
$moduleDir = Join-Path $formXmlDir "Form"
|
||||
if (-not (Test-Path $moduleDir)) {
|
||||
New-Item -ItemType Directory -Path $moduleDir -Force | Out-Null
|
||||
}
|
||||
$moduleBslFile = Join-Path $moduleDir "Module.bsl"
|
||||
if (Test-Path $moduleBslFile) {
|
||||
Info " Preserved existing Module.bsl"
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($moduleBslFile, "", $enc)
|
||||
Info " Created: $moduleBslFile"
|
||||
}
|
||||
|
||||
# 7. Register form in parent object ChildObjects
|
||||
Register-FormInObject $typeName $objName $formName
|
||||
@@ -1010,8 +1080,29 @@ function Collect-FormDataPaths {
|
||||
$firstLevel = @{}
|
||||
$deepPaths = @()
|
||||
|
||||
$matches2 = [regex]::Matches($content, '<DataPath>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</DataPath>')
|
||||
foreach ($m in $matches2) {
|
||||
# Scan every data-binding tag (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*)
|
||||
# for Объект.* references — picture-path tags carry picture indices, not data attributes.
|
||||
foreach ($tag in $script:formBindingDataTags) {
|
||||
$bms = [regex]::Matches($content, "<$tag>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</$tag>")
|
||||
foreach ($m in $bms) {
|
||||
$path = $m.Groups[1].Value
|
||||
$segments = $path.Split(".")
|
||||
$seg0 = $segments[0]
|
||||
if ($script:standardFields -contains $seg0) { continue }
|
||||
$firstLevel[$seg0] = $true
|
||||
if ($segments.Count -ge 2) {
|
||||
$seg1 = $segments[1]
|
||||
if ($script:standardFields -contains $seg1) { continue }
|
||||
$seg2 = if ($segments.Count -ge 3) { $segments[2] } else { $null }
|
||||
$deepPaths += @{ ObjectAttr = $seg0; SubAttr = $seg1; SubSubAttr = $seg2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Also scan <Field>Объект.X</Field> — object attributes referenced by filter/conditional-appearance
|
||||
# fields (and dynamic lists), not via a *DataPath binding (e.g. УдалитьЮрФизЛицо). Designer borrows these too.
|
||||
$fieldMatches = [regex]::Matches($content, "<Field>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</Field>")
|
||||
foreach ($m in $fieldMatches) {
|
||||
$path = $m.Groups[1].Value
|
||||
$segments = $path.Split(".")
|
||||
$seg0 = $segments[0]
|
||||
@@ -1024,21 +1115,11 @@ function Collect-FormDataPaths {
|
||||
}
|
||||
}
|
||||
|
||||
# Also collect from TitleDataPath
|
||||
$matches3 = [regex]::Matches($content, '<TitleDataPath>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</TitleDataPath>')
|
||||
foreach ($m in $matches3) {
|
||||
$path = $m.Groups[1].Value
|
||||
$segments = $path.Split(".")
|
||||
$seg0 = $segments[0]
|
||||
if ($script:standardFields -contains $seg0) { continue }
|
||||
$firstLevel[$seg0] = $true
|
||||
}
|
||||
|
||||
# Deduplicate deep paths
|
||||
$seen = @{}
|
||||
$uniqueDeep = @()
|
||||
foreach ($dp in $deepPaths) {
|
||||
$key = "$($dp.ObjectAttr).$($dp.SubAttr)"
|
||||
$key = "$($dp.ObjectAttr).$($dp.SubAttr).$($dp.SubSubAttr)"
|
||||
if (-not $seen.ContainsKey($key)) {
|
||||
$seen[$key] = $true
|
||||
$uniqueDeep += $dp
|
||||
@@ -1142,7 +1223,8 @@ function Resolve-SourceAttributes {
|
||||
}
|
||||
|
||||
# Extract extra Properties for main object enrichment (Hierarchical, CodeLength, etc.)
|
||||
$extraProps = @{}
|
||||
# Ordered so PS emits the same property order as the Python port (dict preserves insertion order).
|
||||
$extraProps = [ordered]@{}
|
||||
$propsNode = $srcEl.SelectSingleNode("md:Properties", $srcNs)
|
||||
if ($propsNode) {
|
||||
$propsToExtract = @("Hierarchical","FoldersOnTop","CodeLength","DescriptionLength","CodeType","CodeAllowedLength",
|
||||
@@ -1375,28 +1457,45 @@ function Borrow-MainAttribute {
|
||||
# Step 3: Build the adopted content and insert into main object XML
|
||||
$objFile = Join-Path (Join-Path $extDir $dirName) "${objName}.xml"
|
||||
|
||||
# Read existing object XML (needed for dedup + enrichment)
|
||||
$objContent = [System.IO.File]::ReadAllText($objFile, (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
# Dedup: skip attributes/TS already present in object's ChildObjects (idempotent re-borrow)
|
||||
$existingChildNames = @{}
|
||||
if ($objContent -match '(?s)<ChildObjects>(.*?)</ChildObjects>') {
|
||||
foreach ($nm in [regex]::Matches($Matches[1], '<Name>(\w+)</Name>')) {
|
||||
$existingChildNames[$nm.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
$insertAttrs = @($srcAttrs | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||
$insertTS = @($srcTS | Where-Object { -not $existingChildNames.ContainsKey($_.Name) })
|
||||
|
||||
# Generate full object XML with attributes and TS
|
||||
$contentSb = New-Object System.Text.StringBuilder
|
||||
foreach ($attr in $srcAttrs) {
|
||||
foreach ($attr in $insertAttrs) {
|
||||
$attrXml = Build-AdoptedAttributeXml $attr.Name $attr.Uuid $attr.TypeXml "`t`t`t"
|
||||
$contentSb.AppendLine($attrXml) | Out-Null
|
||||
}
|
||||
foreach ($ts in $srcTS) {
|
||||
foreach ($ts in $insertTS) {
|
||||
$tsXml = Build-AdoptedTabularSectionXml $ts.Name $ts.Uuid $ts.GeneratedTypes $ts.Attributes "`t`t`t"
|
||||
$contentSb.AppendLine($tsXml) | Out-Null
|
||||
}
|
||||
$adoptedContent = $contentSb.ToString().TrimEnd()
|
||||
|
||||
# Read existing object XML and inject
|
||||
$objContent = [System.IO.File]::ReadAllText($objFile, (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
# Inject extra properties after ExtendedConfigurationObject
|
||||
# Inject extra properties into the object's OWN Properties only — idempotent and anchored to the
|
||||
# first ExtendedConfigurationObject (the object's). On re-borrow, adopted attributes each have their
|
||||
# own ExtendedConfigurationObject; a global replace would push object props inside every <Attribute>.
|
||||
if ($extraProps.Count -gt 0) {
|
||||
$objPropsBlock = ""
|
||||
if ($objContent -match '(?s)<Properties>(.*?)</Properties>') { $objPropsBlock = $Matches[1] }
|
||||
$propsSb = New-Object System.Text.StringBuilder
|
||||
foreach ($pName in $extraProps.Keys) {
|
||||
if ($objPropsBlock -match "<$pName>") { continue }
|
||||
$propsSb.Append("`r`n`t`t`t<${pName}>$($extraProps[$pName])</${pName}>") | Out-Null
|
||||
}
|
||||
$objContent = $objContent -replace '(</ExtendedConfigurationObject>)', "`$1$($propsSb.ToString())"
|
||||
if ($propsSb.Length -gt 0) {
|
||||
$objContent = ([regex]'</ExtendedConfigurationObject>').Replace($objContent, "</ExtendedConfigurationObject>$($propsSb.ToString())", 1)
|
||||
}
|
||||
}
|
||||
|
||||
# Replace empty ChildObjects with adopted content
|
||||
@@ -1454,58 +1553,83 @@ function Borrow-MainAttribute {
|
||||
|
||||
# Step 5: Handle deep paths (Form mode only)
|
||||
if ($mode -eq "Form" -and $deepPaths.Count -gt 0) {
|
||||
# Filter out deep paths where ObjectAttr is a TabularSection (those are TS column refs, not deep attribute refs)
|
||||
$realDeep = @()
|
||||
foreach ($dp in $deepPaths) {
|
||||
if (-not $tsNames.ContainsKey($dp.ObjectAttr)) { $realDeep += $dp }
|
||||
}
|
||||
|
||||
if ($realDeep.Count -gt 0) {
|
||||
Info " Processing $($realDeep.Count) deep path(s)..."
|
||||
|
||||
# Group by ObjectAttr → target catalog
|
||||
# Top-level ref deep paths: Объект.<Ref>.<Sub> — borrow the ref attribute's catalog with the sub-attribute
|
||||
$deepByAttr = @{}
|
||||
foreach ($dp in $realDeep) {
|
||||
foreach ($dp in $deepPaths) {
|
||||
if ($tsNames.ContainsKey($dp.ObjectAttr)) { continue }
|
||||
if (-not $deepByAttr.ContainsKey($dp.ObjectAttr)) { $deepByAttr[$dp.ObjectAttr] = @() }
|
||||
$deepByAttr[$dp.ObjectAttr] += $dp.SubAttr
|
||||
if ($deepByAttr[$dp.ObjectAttr] -notcontains $dp.SubAttr) { $deepByAttr[$dp.ObjectAttr] += $dp.SubAttr }
|
||||
}
|
||||
|
||||
if ($deepByAttr.Count -gt 0) {
|
||||
Info " Processing $($deepByAttr.Count) deep path attribute(s)..."
|
||||
foreach ($attrName in $deepByAttr.Keys) {
|
||||
# Find the attribute's type to determine target catalog
|
||||
$attrInfo = $srcAttrs | Where-Object { $_.Name -eq $attrName } | Select-Object -First 1
|
||||
if (-not $attrInfo) { continue }
|
||||
|
||||
# Extract catalog name from type: cfg:CatalogRef.XXX
|
||||
$catMatch = [regex]::Match($attrInfo.TypeXml, 'cfg:(\w+)Ref\.(\w+)')
|
||||
if (-not $catMatch.Success) { continue }
|
||||
Borrow-DeepTargetAttrs $catMatch.Groups[1].Value $catMatch.Groups[2].Value $deepByAttr[$attrName]
|
||||
}
|
||||
}
|
||||
|
||||
$targetTypeName = $catMatch.Groups[1].Value
|
||||
$targetObjName = $catMatch.Groups[2].Value
|
||||
# Tabular-section deep paths: Объект.<ТЧ>.<Колонка>.<Sub> — borrow the column's catalog with the sub-attribute
|
||||
$tsDeepByCol = @{}
|
||||
foreach ($dp in $deepPaths) {
|
||||
if (-not $tsNames.ContainsKey($dp.ObjectAttr)) { continue }
|
||||
if (-not $dp.SubSubAttr) { continue }
|
||||
if ($script:standardFields -contains $dp.SubSubAttr) { continue }
|
||||
$k = "$($dp.ObjectAttr)|$($dp.SubAttr)"
|
||||
if (-not $tsDeepByCol.ContainsKey($k)) { $tsDeepByCol[$k] = @() }
|
||||
if ($tsDeepByCol[$k] -notcontains $dp.SubSubAttr) { $tsDeepByCol[$k] += $dp.SubSubAttr }
|
||||
}
|
||||
if ($tsDeepByCol.Count -gt 0) {
|
||||
Info " Processing $($tsDeepByCol.Count) tabular-section deep path(s)..."
|
||||
foreach ($k in $tsDeepByCol.Keys) {
|
||||
$parts = $k.Split("|")
|
||||
$tsName = $parts[0]; $colName = $parts[1]
|
||||
$tsInfo = $srcTS | Where-Object { $_.Name -eq $tsName } | Select-Object -First 1
|
||||
if (-not $tsInfo) { continue }
|
||||
$colInfo = $tsInfo.Attributes | Where-Object { $_.Name -eq $colName } | Select-Object -First 1
|
||||
if (-not $colInfo) { continue }
|
||||
$catMatch = [regex]::Match($colInfo.TypeXml, 'cfg:(\w+)Ref\.(\w+)')
|
||||
if (-not $catMatch.Success) { continue }
|
||||
Borrow-DeepTargetAttrs $catMatch.Groups[1].Value $catMatch.Groups[2].Value $tsDeepByCol[$k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure target is borrowed
|
||||
Info " Main attribute borrowing complete"
|
||||
}
|
||||
|
||||
# --- 11i. Helper: borrow a deep-path target catalog together with the referenced sub-attributes ---
|
||||
# Used for both Объект.<Ref>.<Sub> (top-level ref attr) and Объект.<ТЧ>.<Колонка>.<Sub> (tabular-section
|
||||
# column ref). Mirrors Designer: the referenced catalog is adopted WITH the sub-attributes the form shows,
|
||||
# otherwise the platform rejects the deep DataPath ("Неверный путь к данным").
|
||||
function Borrow-DeepTargetAttrs {
|
||||
param([string]$targetTypeName, [string]$targetObjName, $subAttrNames)
|
||||
|
||||
$encBomLocal = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
# Ensure target is borrowed (shell)
|
||||
if (-not (Test-ObjectBorrowed $targetTypeName $targetObjName)) {
|
||||
$tSrc = Read-SourceObject $targetTypeName $targetObjName
|
||||
$tBorrowedXml = Build-BorrowedObjectXml $targetTypeName $targetObjName $tSrc.Uuid $tSrc.Properties
|
||||
$tTargetDir = Join-Path $extDir $childTypeDirMap[$targetTypeName]
|
||||
if (-not (Test-Path $tTargetDir)) {
|
||||
New-Item -ItemType Directory -Path $tTargetDir -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $tTargetDir)) { New-Item -ItemType Directory -Path $tTargetDir -Force | Out-Null }
|
||||
$tTargetFile = Join-Path $tTargetDir "${targetObjName}.xml"
|
||||
[System.IO.File]::WriteAllText($tTargetFile, $tBorrowedXml, $encBom)
|
||||
[System.IO.File]::WriteAllText($tTargetFile, $tBorrowedXml, $encBomLocal)
|
||||
Add-ToChildObjects $targetTypeName $targetObjName
|
||||
$script:borrowedFiles += $tTargetFile
|
||||
Info " Auto-borrowed for deep path: ${targetTypeName}.${targetObjName}"
|
||||
}
|
||||
|
||||
# Resolve sub-attributes in target catalog
|
||||
# Resolve sub-attributes in target catalog and merge them in
|
||||
$subNames = @{}
|
||||
foreach ($sn in $deepByAttr[$attrName]) { $subNames[$sn] = $true }
|
||||
foreach ($sn in $subAttrNames) { $subNames[$sn] = $true }
|
||||
$subResolved = Resolve-SourceAttributes $targetTypeName $targetObjName $subNames
|
||||
|
||||
if ($subResolved.Attributes.Count -gt 0) {
|
||||
Merge-AttributesIntoObject $targetTypeName $targetObjName $subResolved.Attributes
|
||||
|
||||
# Collect and borrow ref types from deep attributes
|
||||
# Borrow ref types referenced by the sub-attributes
|
||||
$subTypeXmls = @()
|
||||
foreach ($sa in $subResolved.Attributes) { $subTypeXmls += $sa.TypeXml }
|
||||
$subRefTypes = Collect-ReferenceTypes $subTypeXmls
|
||||
@@ -1517,22 +1641,15 @@ function Borrow-MainAttribute {
|
||||
$sSrc = Read-SourceObject $srt.TypeName $srt.ObjName
|
||||
$sBorrowedXml = Build-BorrowedObjectXml $srt.TypeName $srt.ObjName $sSrc.Uuid $sSrc.Properties
|
||||
$sTargetDir = Join-Path $extDir $childTypeDirMap[$srt.TypeName]
|
||||
if (-not (Test-Path $sTargetDir)) {
|
||||
New-Item -ItemType Directory -Path $sTargetDir -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $sTargetDir)) { New-Item -ItemType Directory -Path $sTargetDir -Force | Out-Null }
|
||||
$sTargetFile = Join-Path $sTargetDir "$($srt.ObjName).xml"
|
||||
[System.IO.File]::WriteAllText($sTargetFile, $sBorrowedXml, $encBom)
|
||||
[System.IO.File]::WriteAllText($sTargetFile, $sBorrowedXml, $encBomLocal)
|
||||
Add-ToChildObjects $srt.TypeName $srt.ObjName
|
||||
$script:borrowedFiles += $sTargetFile
|
||||
Info " Auto-borrowed (deep): $($srt.TypeName).$($srt.ObjName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Info " Main attribute borrowing complete"
|
||||
}
|
||||
|
||||
# --- 12. Helper: build borrowed object XML ---
|
||||
function Build-BorrowedObjectXml {
|
||||
@@ -1572,10 +1689,15 @@ function Build-BorrowedObjectXml {
|
||||
}
|
||||
}
|
||||
|
||||
# DefinedType: emit the carried <Type> definition (needed for the alias to resolve, e.g. totals)
|
||||
if ($typeName -eq "DefinedType" -and $sourceProps.ContainsKey("__TypeXml")) {
|
||||
$sb.AppendLine("`t`t`t$($sourceProps['__TypeXml'])") | Out-Null
|
||||
}
|
||||
|
||||
$sb.AppendLine("`t`t</Properties>") | Out-Null
|
||||
|
||||
# ChildObjects (for types that need it)
|
||||
if ($typesWithChildObjects -contains $typeName) {
|
||||
if ($sourceProps["__HasChildObjects"] -or ($typesWithChildObjects -contains $typeName)) {
|
||||
$sb.AppendLine("`t`t<ChildObjects/>") | Out-Null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-borrow v1.3 — Borrow objects from configuration into extension (CFE)
|
||||
# cfe-borrow v1.11 — Borrow objects from configuration into extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,36 @@ XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
# Form data-binding tags (value = attribute path). A binding survives only if its root
|
||||
# attribute is borrowed into the form's <Attributes>; otherwise it must be stripped or the
|
||||
# platform rejects the form with "Неверный путь к данным" on load.
|
||||
FORM_BINDING_DATA_TAGS = ["DataPath", "TitleDataPath", "FooterDataPath", "HeaderDataPath", "MultipleValueDataPath", "MultipleValuePresentDataPath"]
|
||||
# Picture-path binding tags (value = picture index path, never a data attribute) — always stripped in the skeleton.
|
||||
FORM_BINDING_PICTURE_TAGS = ["RowPictureDataPath", "MultipleValuePictureDataPath"]
|
||||
|
||||
|
||||
def strip_form_bindings(xml, keep_objekt):
|
||||
"""Strip data-binding tags whose root attribute isn't borrowed.
|
||||
keep_objekt=True (BorrowMainAttribute): keep Объект.* data bindings, strip the rest.
|
||||
keep_objekt=False (default skeleton): strip all bindings. Picture-path tags are always stripped."""
|
||||
for tag in FORM_BINDING_DATA_TAGS:
|
||||
if keep_objekt:
|
||||
xml = re.sub(rf'\s*<{tag}>(?!Объект\.)[^<]*</{tag}>', '', xml)
|
||||
else:
|
||||
xml = re.sub(rf'\s*<{tag}>[^<]*</{tag}>', '', xml)
|
||||
for tag in FORM_BINDING_PICTURE_TAGS:
|
||||
xml = re.sub(rf'\s*<{tag}>[^<]*</{tag}>', '', xml)
|
||||
return xml
|
||||
|
||||
|
||||
def decode_numeric_entities(s):
|
||||
"""lxml emits numeric character refs (&#xNNNN;) for non-ASCII in some self-closed
|
||||
elements where the PowerShell port writes literal characters. Normalize numeric refs
|
||||
back to literal so PS↔PY output matches. Named entities (& < ...) are left intact."""
|
||||
s = re.sub(r'&#x([0-9A-Fa-f]+);', lambda m: chr(int(m.group(1), 16)), s)
|
||||
s = re.sub(r'&#(\d+);', lambda m: chr(int(m.group(1))), s)
|
||||
return s
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
@@ -224,13 +254,36 @@ GENERATED_TYPES = {
|
||||
"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 — 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 ("expected ChildObjects"); primary signal is the source object itself.
|
||||
TYPES_WITH_CHILD_OBJECTS = [
|
||||
"Catalog", "Document", "ExchangePlan", "ChartOfAccounts",
|
||||
"ChartOfCharacteristicTypes", "ChartOfCalculationTypes",
|
||||
"BusinessProcess", "Task", "Enum",
|
||||
"InformationRegister", "AccumulationRegister", "AccountingRegister", "CalculationRegister",
|
||||
"DataProcessor", "Report", "DocumentJournal", "FilterCriterion", "SettingsStorage",
|
||||
"Sequence", "HTTPService", "WebService", "IntegrationService", "Subsystem",
|
||||
]
|
||||
|
||||
COMMON_MODULE_PROPS = ["Global", "ClientManagedApplication", "Server", "ExternalConnection", "ClientOrdinaryApplication", "ServerCall"]
|
||||
@@ -319,12 +372,48 @@ def expand_self_closing(container, parent_indent):
|
||||
container.text = "\r\n" + parent_indent
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -462,6 +551,16 @@ def main():
|
||||
prop_node = props_node.find(f"{{{MD_NS}}}{prop_name}")
|
||||
if prop_node is not None:
|
||||
src_props[prop_name] = (prop_node.text or "").strip()
|
||||
# DefinedType: carry the <Type> definition. A type alias is meaningless as a bare shell —
|
||||
# the platform needs its underlying type (e.g. to know a column is a summable Number for totals).
|
||||
if type_name == "DefinedType":
|
||||
type_node = props_node.find(f"{{{MD_NS}}}Type")
|
||||
if type_node is not None:
|
||||
type_xml = etree.tostring(type_node, encoding="unicode")
|
||||
src_props["__TypeXml"] = re.sub(r'\s+xmlns(?::\w+)?="[^"]*"', '', type_xml)
|
||||
|
||||
# Whether the platform emits <ChildObjects> for this type — the source object is the ground truth
|
||||
src_props["__HasChildObjects"] = src_el.find(f"{{{MD_NS}}}ChildObjects") is not None
|
||||
|
||||
return {"Uuid": src_uuid, "Properties": src_props, "Element": src_el}
|
||||
|
||||
@@ -533,9 +632,13 @@ def main():
|
||||
prop_val = source_props.get(prop_name, "false")
|
||||
lines.append(f"\t\t\t<{prop_name}>{prop_val}</{prop_name}>")
|
||||
|
||||
# DefinedType: emit the carried <Type> definition (needed for the alias to resolve, e.g. totals)
|
||||
if type_name == "DefinedType" and "__TypeXml" in source_props:
|
||||
lines.append(f"\t\t\t{source_props['__TypeXml']}")
|
||||
|
||||
lines.append("\t\t</Properties>")
|
||||
|
||||
if type_name in TYPES_WITH_CHILD_OBJECTS:
|
||||
if source_props.get("__HasChildObjects") or type_name in TYPES_WITH_CHILD_OBJECTS:
|
||||
lines.append("\t\t<ChildObjects/>")
|
||||
|
||||
lines.append(f"\t</{type_name}>")
|
||||
@@ -644,7 +747,10 @@ def main():
|
||||
first_level = {}
|
||||
deep_paths = []
|
||||
|
||||
for m in re.finditer(r'<DataPath>[^<]*\b\u041e\u0431\u044a\u0435\u043a\u0442\.(\w+(?:\.\w+)*)</DataPath>', content):
|
||||
# Scan every data-binding tag (DataPath/TitleDataPath/FooterDataPath/HeaderDataPath/MultipleValue*)
|
||||
# for Объект.* references — picture-path tags carry picture indices, not data attributes.
|
||||
for tag in FORM_BINDING_DATA_TAGS:
|
||||
for m in re.finditer(r'<' + tag + r'>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</' + tag + r'>', content):
|
||||
path = m.group(1)
|
||||
segments = path.split(".")
|
||||
seg0 = segments[0]
|
||||
@@ -655,22 +761,30 @@ def main():
|
||||
seg1 = segments[1]
|
||||
if seg1 in STANDARD_FIELDS:
|
||||
continue
|
||||
deep_paths.append({"ObjectAttr": seg0, "SubAttr": seg1})
|
||||
seg2 = segments[2] if len(segments) >= 3 else None
|
||||
deep_paths.append({"ObjectAttr": seg0, "SubAttr": seg1, "SubSubAttr": seg2})
|
||||
|
||||
# Also collect from TitleDataPath
|
||||
for m in re.finditer(r'<TitleDataPath>[^<]*\b\u041e\u0431\u044a\u0435\u043a\u0442\.(\w+(?:\.\w+)*)</TitleDataPath>', content):
|
||||
# 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.
|
||||
for m in re.finditer(r'<Field>[^<]*\bОбъект\.(\w+(?:\.\w+)*)</Field>', content):
|
||||
path = m.group(1)
|
||||
segments = path.split(".")
|
||||
seg0 = segments[0]
|
||||
if seg0 in STANDARD_FIELDS:
|
||||
continue
|
||||
first_level[seg0] = True
|
||||
if len(segments) >= 2:
|
||||
seg1 = segments[1]
|
||||
if seg1 in STANDARD_FIELDS:
|
||||
continue
|
||||
seg2 = segments[2] if len(segments) >= 3 else None
|
||||
deep_paths.append({"ObjectAttr": seg0, "SubAttr": seg1, "SubSubAttr": seg2})
|
||||
|
||||
# Deduplicate deep paths
|
||||
seen = set()
|
||||
unique_deep = []
|
||||
for dp in deep_paths:
|
||||
key = f"{dp['ObjectAttr']}.{dp['SubAttr']}"
|
||||
key = f"{dp['ObjectAttr']}.{dp['SubAttr']}.{dp.get('SubSubAttr')}"
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_deep.append(dp)
|
||||
@@ -941,26 +1055,40 @@ def main():
|
||||
# Step 3: Build the adopted content and insert into main object XML
|
||||
obj_file = os.path.join(ext_dir, dir_name, f"{obj_name}.xml")
|
||||
|
||||
# Generate full object XML with attributes and TS
|
||||
content_parts = []
|
||||
for attr in src_attrs:
|
||||
attr_xml = build_adopted_attribute_xml(attr["Name"], attr["Uuid"], attr["TypeXml"], "\t\t\t")
|
||||
content_parts.append(attr_xml)
|
||||
for ts in src_ts:
|
||||
ts_xml = build_adopted_tabular_section_xml(ts["Name"], ts["Uuid"], ts["GeneratedTypes"], ts["Attributes"], "\t\t\t")
|
||||
content_parts.append(ts_xml)
|
||||
adopted_content = "\n".join(content_parts).rstrip()
|
||||
|
||||
# Read existing object XML and inject
|
||||
# Read existing object XML (needed for dedup + enrichment)
|
||||
with open(obj_file, "r", encoding="utf-8-sig") as fh:
|
||||
obj_content = fh.read()
|
||||
|
||||
# Inject extra properties after ExtendedConfigurationObject
|
||||
# Dedup: skip attributes/TS already present in object's ChildObjects (idempotent re-borrow)
|
||||
existing_child_names = set()
|
||||
m_co = re.search(r'(?s)<ChildObjects>(.*?)</ChildObjects>', obj_content)
|
||||
if m_co:
|
||||
for nm in re.findall(r'<Name>(\w+)</Name>', m_co.group(1)):
|
||||
existing_child_names.add(nm)
|
||||
insert_attrs = [a for a in src_attrs if a["Name"] not in existing_child_names]
|
||||
insert_ts = [t for t in src_ts if t["Name"] not in existing_child_names]
|
||||
|
||||
# Generate full object XML with attributes and TS
|
||||
content_parts = []
|
||||
for attr in insert_attrs:
|
||||
content_parts.append(build_adopted_attribute_xml(attr["Name"], attr["Uuid"], attr["TypeXml"], "\t\t\t"))
|
||||
for ts in insert_ts:
|
||||
content_parts.append(build_adopted_tabular_section_xml(ts["Name"], ts["Uuid"], ts["GeneratedTypes"], ts["Attributes"], "\t\t\t"))
|
||||
adopted_content = "\n".join(content_parts).rstrip()
|
||||
|
||||
# Inject extra properties into the object's OWN Properties only — idempotent and anchored to the
|
||||
# first ExtendedConfigurationObject (the object's). On re-borrow, adopted attributes each have their
|
||||
# own ExtendedConfigurationObject; a global replace would push object props inside every <Attribute>.
|
||||
if extra_props:
|
||||
m_props = re.search(r'(?s)<Properties>(.*?)</Properties>', obj_content)
|
||||
obj_props_block = m_props.group(1) if m_props else ""
|
||||
props_xml = ""
|
||||
for p_name, p_val in extra_props.items():
|
||||
if f"<{p_name}>" in obj_props_block:
|
||||
continue
|
||||
props_xml += f"\r\n\t\t\t<{p_name}>{p_val}</{p_name}>"
|
||||
obj_content = obj_content.replace("</ExtendedConfigurationObject>", f"</ExtendedConfigurationObject>{props_xml}")
|
||||
if props_xml:
|
||||
obj_content = obj_content.replace("</ExtendedConfigurationObject>", f"</ExtendedConfigurationObject>{props_xml}", 1)
|
||||
|
||||
# Replace empty ChildObjects with adopted content
|
||||
if adopted_content:
|
||||
@@ -1012,38 +1140,58 @@ def main():
|
||||
|
||||
# Step 5: Handle deep paths (Form mode only)
|
||||
if mode == "Form" and deep_paths:
|
||||
# Filter out deep paths where ObjectAttr is a TabularSection
|
||||
real_deep = [dp for dp in deep_paths if dp["ObjectAttr"] not in ts_names]
|
||||
|
||||
if real_deep:
|
||||
info(f" Processing {len(real_deep)} deep path(s)...")
|
||||
|
||||
# Group by ObjectAttr -> target catalog
|
||||
# Top-level ref deep paths: Объект.<Ref>.<Sub> — borrow the ref attribute's catalog with the sub-attribute
|
||||
deep_by_attr = {}
|
||||
for dp in real_deep:
|
||||
if dp["ObjectAttr"] not in deep_by_attr:
|
||||
deep_by_attr[dp["ObjectAttr"]] = []
|
||||
for dp in deep_paths:
|
||||
if dp["ObjectAttr"] in ts_names:
|
||||
continue
|
||||
deep_by_attr.setdefault(dp["ObjectAttr"], [])
|
||||
if dp["SubAttr"] not in deep_by_attr[dp["ObjectAttr"]]:
|
||||
deep_by_attr[dp["ObjectAttr"]].append(dp["SubAttr"])
|
||||
|
||||
if deep_by_attr:
|
||||
info(f" Processing {len(deep_by_attr)} deep path attribute(s)...")
|
||||
for attr_name, sub_attr_names in deep_by_attr.items():
|
||||
# Find the attribute's type to determine target catalog
|
||||
attr_info = None
|
||||
for a in src_attrs:
|
||||
if a["Name"] == attr_name:
|
||||
attr_info = a
|
||||
break
|
||||
attr_info = next((a for a in src_attrs if a["Name"] == attr_name), None)
|
||||
if not attr_info:
|
||||
continue
|
||||
|
||||
# Extract catalog name from type: cfg:CatalogRef.XXX
|
||||
cat_match = re.search(r'cfg:(\w+)Ref\.(\w+)', attr_info["TypeXml"])
|
||||
if not cat_match:
|
||||
continue
|
||||
borrow_deep_target_attrs(cat_match.group(1), cat_match.group(2), sub_attr_names)
|
||||
|
||||
target_type_name = cat_match.group(1)
|
||||
target_obj_name = cat_match.group(2)
|
||||
# Tabular-section deep paths: Объект.<ТЧ>.<Колонка>.<Sub> — borrow the column's catalog with the sub-attribute
|
||||
ts_deep_by_col = {}
|
||||
for dp in deep_paths:
|
||||
if dp["ObjectAttr"] not in ts_names:
|
||||
continue
|
||||
if not dp.get("SubSubAttr"):
|
||||
continue
|
||||
if dp["SubSubAttr"] in STANDARD_FIELDS:
|
||||
continue
|
||||
k = (dp["ObjectAttr"], dp["SubAttr"])
|
||||
ts_deep_by_col.setdefault(k, [])
|
||||
if dp["SubSubAttr"] not in ts_deep_by_col[k]:
|
||||
ts_deep_by_col[k].append(dp["SubSubAttr"])
|
||||
if ts_deep_by_col:
|
||||
info(f" Processing {len(ts_deep_by_col)} tabular-section deep path(s)...")
|
||||
for (ts_name, col_name), sub_attr_names in ts_deep_by_col.items():
|
||||
ts_info = next((t for t in src_ts if t["Name"] == ts_name), None)
|
||||
if not ts_info:
|
||||
continue
|
||||
col_info = next((c for c in ts_info["Attributes"] if c["Name"] == col_name), None)
|
||||
if not col_info:
|
||||
continue
|
||||
cat_match = re.search(r'cfg:(\w+)Ref\.(\w+)', col_info["TypeXml"])
|
||||
if not cat_match:
|
||||
continue
|
||||
borrow_deep_target_attrs(cat_match.group(1), cat_match.group(2), sub_attr_names)
|
||||
|
||||
# Ensure target is borrowed
|
||||
info(" Main attribute borrowing complete")
|
||||
|
||||
def borrow_deep_target_attrs(target_type_name, target_obj_name, sub_attr_names):
|
||||
# Borrow a deep-path target catalog together with the referenced sub-attributes, for both
|
||||
# Объект.<Ref>.<Sub> and Объект.<ТЧ>.<Колонка>.<Sub>. Mirrors Designer: the referenced catalog
|
||||
# is adopted WITH the sub-attributes the form shows, else the platform rejects the deep DataPath.
|
||||
if not test_object_borrowed(target_type_name, target_obj_name):
|
||||
t_src = read_source_object(target_type_name, target_obj_name)
|
||||
t_borrowed_xml = build_borrowed_object_xml(target_type_name, target_obj_name, t_src["Uuid"], t_src["Properties"])
|
||||
@@ -1055,14 +1203,10 @@ def main():
|
||||
borrowed_files.append(t_target_file)
|
||||
info(f" Auto-borrowed for deep path: {target_type_name}.{target_obj_name}")
|
||||
|
||||
# Resolve sub-attributes in target catalog
|
||||
sub_names = {sn: True for sn in sub_attr_names}
|
||||
sub_resolved = resolve_source_attributes(target_type_name, target_obj_name, sub_names)
|
||||
|
||||
if sub_resolved["Attributes"]:
|
||||
merge_attributes_into_object(target_type_name, target_obj_name, sub_resolved["Attributes"])
|
||||
|
||||
# Collect and borrow ref types from deep attributes
|
||||
sub_type_xmls = [sa["TypeXml"] for sa in sub_resolved["Attributes"]]
|
||||
sub_ref_types = collect_reference_types(sub_type_xmls)
|
||||
for srt in sub_ref_types:
|
||||
@@ -1083,8 +1227,6 @@ def main():
|
||||
borrowed_files.append(s_target_file)
|
||||
info(f" Auto-borrowed (deep): {srt['TypeName']}.{srt['ObjName']}")
|
||||
|
||||
info(" Main attribute borrowing complete")
|
||||
|
||||
def borrow_form(type_name, obj_name, form_name, borrow_main_attr=False):
|
||||
dir_name = CHILD_TYPE_DIR_MAP[type_name]
|
||||
|
||||
@@ -1100,7 +1242,21 @@ def main():
|
||||
with open(src_form_xml_path, "r", encoding="utf-8-sig") as fh:
|
||||
src_form_content = fh.read()
|
||||
|
||||
# 3. Generate form metadata XML
|
||||
# 3. Generate form metadata XML.
|
||||
# If the wrapper was already borrowed, reuse its uuid so re-borrow is idempotent
|
||||
# (regenerating it would churn the form's identity on every rerun).
|
||||
existing_wrapper = os.path.join(ext_dir, dir_name, obj_name, "Forms", f"{form_name}.xml")
|
||||
new_form_uuid = ""
|
||||
if os.path.isfile(existing_wrapper):
|
||||
try:
|
||||
existing_root = etree.parse(existing_wrapper).getroot()
|
||||
for c in existing_root:
|
||||
if isinstance(c.tag, str) and localname(c) == "Form":
|
||||
new_form_uuid = c.get("uuid", "") or ""
|
||||
break
|
||||
except Exception:
|
||||
new_form_uuid = ""
|
||||
if not new_form_uuid:
|
||||
new_form_uuid = new_guid()
|
||||
form_meta_lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -1131,7 +1287,10 @@ def main():
|
||||
src_form_tree = etree.parse(src_form_xml_path, src_form_parser)
|
||||
src_form_el = src_form_tree.getroot()
|
||||
|
||||
form_version = src_form_el.get("version", format_version)
|
||||
# Borrowed form uses the extension's format version (not the source form's) — keeps the
|
||||
# extension uniform; otherwise the platform rejects the import on a version mismatch
|
||||
# (e.g. a 2.13 form inside a 2.17 extension). The platform upgrades the form to the root version.
|
||||
form_version = format_version
|
||||
|
||||
src_auto_cmd = None
|
||||
form_props = []
|
||||
@@ -1149,25 +1308,21 @@ def main():
|
||||
continue
|
||||
if not reached_visual:
|
||||
# Form-level properties before AutoCommandBar (WindowOpeningMode, AutoFillCheck, etc.)
|
||||
form_props.append(etree.tostring(fc, encoding="unicode"))
|
||||
form_props.append(decode_numeric_entities(etree.tostring(fc, encoding="unicode")))
|
||||
|
||||
ns_strip_pattern = re.compile(r'\s+xmlns(?::\w+)?="[^"]*"')
|
||||
|
||||
# AutoCommandBar: keep ChildItems (buttons with CommandName->0), Autofill->false
|
||||
auto_cmd_xml = ""
|
||||
if src_auto_cmd is not None:
|
||||
auto_cmd_xml = etree.tostring(src_auto_cmd, encoding="unicode")
|
||||
auto_cmd_xml = decode_numeric_entities(etree.tostring(src_auto_cmd, encoding="unicode"))
|
||||
auto_cmd_xml = ns_strip_pattern.sub("", auto_cmd_xml)
|
||||
auto_cmd_xml = re.sub(r'<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>', auto_cmd_xml)
|
||||
auto_cmd_xml = auto_cmd_xml.replace('<Autofill>true</Autofill>', '<Autofill>false</Autofill>')
|
||||
# Strip ExcludedCommand (references to standard commands invalid in extension)
|
||||
auto_cmd_xml = re.sub(r'\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '', auto_cmd_xml)
|
||||
# Strip DataPath in AutoCommandBar buttons
|
||||
if borrow_main_attr:
|
||||
# Keep only Объект.* DataPaths
|
||||
auto_cmd_xml = re.sub(r'\s*<DataPath>(?!\u041e\u0431\u044a\u0435\u043a\u0442\.)[^<]*</DataPath>', '', auto_cmd_xml)
|
||||
else:
|
||||
auto_cmd_xml = re.sub(r'\s*<DataPath>[^<]*</DataPath>', '', auto_cmd_xml)
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
auto_cmd_xml = strip_form_bindings(auto_cmd_xml, borrow_main_attr)
|
||||
|
||||
# ChildItems: copy full tree, clean up base-config references
|
||||
child_items_xml = ""
|
||||
@@ -1178,20 +1333,12 @@ def main():
|
||||
break
|
||||
|
||||
if src_child_items is not None:
|
||||
child_items_xml = etree.tostring(src_child_items, encoding="unicode")
|
||||
child_items_xml = decode_numeric_entities(etree.tostring(src_child_items, encoding="unicode"))
|
||||
child_items_xml = ns_strip_pattern.sub("", child_items_xml)
|
||||
# Replace all CommandName values with 0
|
||||
child_items_xml = re.sub(r'<CommandName>[^<]*</CommandName>', '<CommandName>0</CommandName>', child_items_xml)
|
||||
# Strip DataPath / TitleDataPath / RowPictureDataPath
|
||||
if borrow_main_attr:
|
||||
# Keep only Объект.* DataPaths — strip form-attribute DataPaths (not borrowed)
|
||||
child_items_xml = re.sub(r'\s*<DataPath>(?!\u041e\u0431\u044a\u0435\u043a\u0442\.)[^<]*</DataPath>', '', child_items_xml)
|
||||
child_items_xml = re.sub(r'\s*<TitleDataPath>(?!\u041e\u0431\u044a\u0435\u043a\u0442\.)[^<]*</TitleDataPath>', '', child_items_xml)
|
||||
child_items_xml = re.sub(r'\s*<RowPictureDataPath>[^<]*</RowPictureDataPath>', '', child_items_xml)
|
||||
else:
|
||||
child_items_xml = re.sub(r'\s*<DataPath>[^<]*</DataPath>', '', child_items_xml)
|
||||
child_items_xml = re.sub(r'\s*<TitleDataPath>[^<]*</TitleDataPath>', '', child_items_xml)
|
||||
child_items_xml = re.sub(r'\s*<RowPictureDataPath>[^<]*</RowPictureDataPath>', '', child_items_xml)
|
||||
# Strip data-binding tags whose root attribute isn't borrowed
|
||||
child_items_xml = strip_form_bindings(child_items_xml, borrow_main_attr)
|
||||
# Strip ExcludedCommand in nested AutoCommandBars (references to standard commands invalid in extension)
|
||||
child_items_xml = re.sub(r'\s*<ExcludedCommand>[^<]*</ExcludedCommand>', '', child_items_xml)
|
||||
# Strip TypeLink blocks with human-readable DataPath (Items.XXX)
|
||||
@@ -1428,10 +1575,14 @@ def main():
|
||||
save_text_bom(form_xml_file, "".join(parts))
|
||||
info(f" Created: {form_xml_file}")
|
||||
|
||||
# 6. Create empty Module.bsl
|
||||
# 6. Create empty Module.bsl — but NEVER overwrite an existing one (re-borrow must
|
||||
# not clobber user code added to the form module).
|
||||
module_dir = os.path.join(form_xml_dir, "Form")
|
||||
os.makedirs(module_dir, exist_ok=True)
|
||||
module_bsl_file = os.path.join(module_dir, "Module.bsl")
|
||||
if os.path.isfile(module_bsl_file):
|
||||
info(" Preserved existing Module.bsl")
|
||||
else:
|
||||
save_text_bom(module_bsl_file, "")
|
||||
info(f" Created: {module_bsl_file}")
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# cfe-init v1.1 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -35,6 +35,10 @@ if (Test-Path $cfgFile) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# MDClasses format version — inherited from the base config so the extension stays uniform
|
||||
# with it (a 2.13 base must yield a 2.13 extension, else platform import rejects the mismatch).
|
||||
$formatVersion = "2.17"
|
||||
|
||||
# --- Resolve ConfigPath ---
|
||||
$baseLangUuid = "00000000-0000-0000-0000-000000000000"
|
||||
if ($ConfigPath) {
|
||||
@@ -75,6 +79,11 @@ if ($ConfigPath) {
|
||||
$baseCfgDoc.Load((Resolve-Path $ConfigPath).Path)
|
||||
$baseCfgNs = New-Object System.Xml.XmlNamespaceManager($baseCfgDoc.NameTable)
|
||||
$baseCfgNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$fmtVer = $baseCfgDoc.DocumentElement.GetAttribute("version")
|
||||
if ($fmtVer) {
|
||||
$formatVersion = $fmtVer
|
||||
Write-Host "[INFO] Base config format version: $formatVersion"
|
||||
}
|
||||
$compatNode = $baseCfgDoc.SelectSingleNode("//md:Configuration/md:Properties/md:CompatibilityMode", $baseCfgNs)
|
||||
if ($compatNode -and $compatNode.InnerText) {
|
||||
$CompatibilityMode = $compatNode.InnerText.Trim()
|
||||
@@ -138,7 +147,7 @@ $childObjectsXml += "`r`n`t`t"
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -203,7 +212,7 @@ $cfgXml = @"
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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" 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">
|
||||
<InternalInfo/>
|
||||
<Properties>
|
||||
@@ -220,7 +229,7 @@ $langXml = @"
|
||||
# --- Role XML ---
|
||||
$roleXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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" 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">
|
||||
<Role uuid="$uuidRole">
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-init v1.1 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration extension."""
|
||||
import sys, os, argparse, uuid
|
||||
@@ -50,6 +50,10 @@ def main():
|
||||
print(f"Configuration.xml already exists: {cfg_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# MDClasses format version — inherited from the base config so the extension stays uniform
|
||||
# with it (a 2.13 base must yield a 2.13 extension, else platform import rejects the mismatch).
|
||||
format_version = "2.17"
|
||||
|
||||
# --- Resolve ConfigPath ---
|
||||
base_lang_uuid = "00000000-0000-0000-0000-000000000000"
|
||||
if args.ConfigPath:
|
||||
@@ -88,6 +92,10 @@ def main():
|
||||
try:
|
||||
base_cfg_tree = ET.parse(os.path.abspath(config_path))
|
||||
base_cfg_root = base_cfg_tree.getroot()
|
||||
fmt_ver = base_cfg_root.get("version")
|
||||
if fmt_ver:
|
||||
format_version = fmt_ver
|
||||
print(f"[INFO] Base config format version: {format_version}")
|
||||
ns = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
|
||||
compat_node = base_cfg_root.find('.//md:Configuration/md:Properties/md:CompatibilityMode', ns)
|
||||
if compat_node is not None and compat_node.text:
|
||||
@@ -155,7 +163,7 @@ def main():
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" 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}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
@@ -190,7 +198,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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" 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}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<InternalInfo/>
|
||||
\t\t<Properties>
|
||||
@@ -205,7 +213,7 @@ def main():
|
||||
|
||||
# --- Role XML ---
|
||||
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="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" 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}">
|
||||
\t<Role uuid="{uuid_role}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(role_name)}</Name>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: cfe-patch-method
|
||||
description: Генерация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после или вместо оригинального
|
||||
argument-hint: -ExtensionPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
description: Генерация и актуализация перехватчика метода в расширении 1С (CFE). Используй когда нужно перехватить метод заимствованного объекта — вставить код до, после, вместо оригинала, изменить его тело (ИзменениеИКонтроль) — или актуализировать перехватчик после изменения оригинала
|
||||
argument-hint: -ExtensionPath <path> -ConfigPath <path> -ModulePath "Catalog.X.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -10,22 +10,31 @@ allowed-tools:
|
||||
|
||||
# /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` | Путь к расширению (обязат.) | — |
|
||||
| `ModulePath` | Путь к модулю (обязат.) | — |
|
||||
| `MethodName` | Имя перехватываемого метода (обязат.) | — |
|
||||
| `InterceptorType` | `Before` / `After` / `ModificationAndControl` (обязат.) | — |
|
||||
| `Context` | Директива контекста | `НаСервере` |
|
||||
| `IsFunction` | Метод — функция (добавит `Возврат`) | false |
|
||||
| `ConfigPath` | Путь к конфигурации-источнику | обязат., кроме случая, когда `ModulePath` — путь к файлу |
|
||||
| `ModulePath` | Логическое имя (`Тип.Имя.Модуль`) **или** путь к файлу модуля `.bsl` | обязат. для генерации |
|
||||
| `MethodName` | Имя перехватываемого метода | обязат. для генерации |
|
||||
| `InterceptorType` | `Before` / `After` / `Instead` / `ModificationAndControl` | обязат. для генерации |
|
||||
| `Check` | Проверить контролируемые методы на дрейф (только отчёт) | — |
|
||||
| `Actualize` | Актуализировать дрейфнувшие контролируемые методы | — |
|
||||
|
||||
## Формат ModulePath
|
||||
|
||||
@@ -40,39 +49,97 @@ allowed-tools:
|
||||
|
||||
Аналогично для Report, DataProcessor, InformationRegister и других типов.
|
||||
|
||||
Вместо логического имени в `ModulePath` можно передать **путь к файлу** модуля-источника `.bsl` — тогда `-ConfigPath` указывать не нужно (оригинал читается прямо из этого файла). Путь модуля расширения определяется от типовой папки в пути автоматически.
|
||||
|
||||
## Типы перехвата
|
||||
|
||||
| InterceptorType | Декоратор | Назначение |
|
||||
|-----------------|-----------|------------|
|
||||
| `Before` | `&Перед` | Код до вызова оригинального метода |
|
||||
| `After` | `&После` | Код после вызова оригинального метода |
|
||||
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела метода с маркерами `#Вставка`/`#Удаление` |
|
||||
| InterceptorType | Декоратор | Назначение | Применим к |
|
||||
|-----------------|-----------|------------|------------|
|
||||
| `Before` | `&Перед` | Код до вызова оригинального метода | процедуры |
|
||||
| `After` | `&После` | Код после вызова оригинального метода | процедуры |
|
||||
| `Instead` | `&Вместо` | Замена метода; в теле — скаффолд `ПродолжитьВызов(...)` | процедуры и функции |
|
||||
| `ModificationAndControl` | `&ИзменениеИКонтроль` | Копия тела оригинала для правки маркерами `#Вставка`/`#Удаление` | процедуры и функции |
|
||||
|
||||
## Маркеры `#Вставка` / `#Удаление` (для `ModificationAndControl`)
|
||||
|
||||
`&ИзменениеИКонтроль` вставляет в перехватчик **копию тела оригинала**. Дальше отредактируй тело под свою доработку, **помечая каждое изменение** — платформа так отличает твою правку от неизменного оригинала:
|
||||
|
||||
- **Добавляешь код** → оберни его `#Вставка` … `#КонецВставки`.
|
||||
- **Удаляешь код оригинала** → оберни удаляемые строки `#Удаление` … `#КонецУдаления`, но сами строки **оставь между маркерами** (платформа сверяет их с оригиналом).
|
||||
- **Заменяешь** → `#Удаление` старое `#КонецУдаления` сразу за ним `#Вставка` новое `#КонецВставки`.
|
||||
|
||||
Пример:
|
||||
```bsl
|
||||
&ИзменениеИКонтроль("ПриЗаписи")
|
||||
Процедура Расш_ПриЗаписи(Отказ)
|
||||
СуммаДокумента = РассчитатьСумму();
|
||||
#Вставка
|
||||
// доработка: округляем
|
||||
СуммаДокумента = Окр(СуммаДокумента, 2);
|
||||
#КонецВставки
|
||||
#Удаление
|
||||
Записать();
|
||||
#КонецУдаления
|
||||
#Вставка
|
||||
ЗаписатьСПроверкой(Отказ);
|
||||
#КонецВставки
|
||||
КонецПроцедуры
|
||||
```
|
||||
|
||||
Правила:
|
||||
- Маркеры — на **отдельной строке с 0-й колонки** (без отступа), даже внутри отступов и текста запроса (`|…`).
|
||||
- **Незамеченные (unmarked) строки должны совпадать с оригиналом дословно** — это и есть «контроль». Если оригинал в конфигурации-источнике изменится, unmarked-контекст разойдётся → метод потребует актуализации (см. ниже; проверить пачкой — `-Check`).
|
||||
- Меняешь только свои `#Вставка`/`#Удаление`; чужой оригинал не трогай.
|
||||
|
||||
## Актуализация
|
||||
|
||||
После изменения оригинала в конфигурации-источнике перехватчик `&ИзменениеИКонтроль` может рассинхронизироваться — unmarked-контекст разойдётся с новым оригиналом. Платформа при загрузке об этом молчит, поэтому актуализируй сам:
|
||||
|
||||
- **Проверить** — `-Check`: отчёт по всем контролируемым методам расширения, ничего не пишет (`exit 1`, если есть дрейф или конфликт).
|
||||
- **Актуализировать** — `-Actualize`: переносит правки (`#Вставка`/`#Удаление`) на новый оригинал по всему расширению. Сузить область: `-ModulePath` — один модуль, `+ -MethodName` — один метод. Для одного метода то же делает повторный вызов с `-InterceptorType ModificationAndControl`.
|
||||
|
||||
Статусы в выводе:
|
||||
|
||||
- `[АКТУАЛЕН]` — оригинал не менялся, правок нет;
|
||||
- `[АКТУАЛИЗИРОВАН]` — тело обновлено по новому оригиналу, правки сохранены (в выводе — сводка);
|
||||
- `[АКТУАЛИЗИРОВАН-ЧАСТИЧНО]` — часть правок не удалось разместить (якорь изменился в новом оригинале). Они помечены `// [РЕСИНК-КОНФЛИКТ]` в модуле и не потеряны; путь к merge-воркспейсу — в выводе (начни с `index.md`, дальше по каждому конфликту его `conflict.md`, размести блоки вручную в `.bsl` расширения);
|
||||
- `[ПЕРЕНЕСЕНО В ОСНОВНУЮ]` — правка уже есть в новом оригинале (вставленный код внесён вендором) или удаляемый блок уже вырезан. Правка неактуальна — убирается из тела, дублировать/конфликтовать не нужно. Если так со всеми правками метода — перехватчик можно удалить. `-Check` этим не роняет `exit`.
|
||||
|
||||
Повторный вызов `Before`/`After`/`Instead` для уже перехваченного метода дубль не создаёт (`[ПРОПУЩЕН]`).
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
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\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Перехват &Перед на сервере
|
||||
... -ExtensionPath src -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват &После на клиенте
|
||||
... -ExtensionPath src -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After -Context "НаКлиенте"
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# ИзменениеИКонтроль для функции
|
||||
... -ExtensionPath src -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType ModificationAndControl -IsFunction
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Генерируемый код (Before)
|
||||
## Верификация
|
||||
|
||||
```bsl
|
||||
&НаСервере
|
||||
&Перед("ПриЗаписи")
|
||||
Процедура Расш1_ПриЗаписи()
|
||||
// TODO: код перед вызовом оригинального метода
|
||||
КонецПроцедуры
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension structure (CFE)
|
||||
# cfe-validate v1.5 — Validate 1C configuration extension structure (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -197,8 +197,9 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
$version = $root.GetAttribute("version")
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
@@ -930,6 +931,17 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
||||
Report-OK "13. TypeLink: clean"
|
||||
}
|
||||
|
||||
# --- 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 ---
|
||||
& $finalize
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-validate v1.4 — Validate 1C configuration extension XML structure (CFE)
|
||||
# cfe-validate v1.5 — Validate 1C configuration extension XML structure (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates extension Configuration.xml: root, InternalInfo, extension properties, ChildObjects, borrowed objects."""
|
||||
import sys, os, argparse, re
|
||||
@@ -216,8 +216,9 @@ def main():
|
||||
version = root.get('version', '')
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.20', '2.21'):
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version not in ('2.17', '2.18', '2.19', '2.20', '2.21'):
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
r.warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
@@ -885,6 +886,21 @@ def main():
|
||||
elif check13_ok:
|
||||
r.ok('13. TypeLink: clean')
|
||||
|
||||
# --- 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 ---
|
||||
r.finalize(out_file)
|
||||
sys.exit(1 if r.errors > 0 else 0)
|
||||
|
||||
@@ -25,7 +25,7 @@ allowed-tools:
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта для `v8path` (путь к платформе).
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
После создания базы предложи зарегистрировать через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
@@ -38,27 +38,21 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-create.ps1" <п
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Путь к файловой базе |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UseTemplate <файл>` | нет | Создать из шаблона (.cf или .dt) |
|
||||
| `-AddToList` | нет | Добавить в список баз 1С |
|
||||
| `-ListName <имя>` | нет | Имя базы в списке |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
## После создания
|
||||
|
||||
1. Прочитай лог-файл и покажи результат
|
||||
2. Предложи зарегистрировать базу в `.v8-project.json` (через `/db-list add`)
|
||||
Предложи зарегистрировать базу в `.v8-project.json` (через `/db-list add`)
|
||||
3. Если указан шаблон `/UseTemplate` — предупреди что конфигурация будет загружена из шаблона
|
||||
|
||||
## Примеры
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-create v1.0 — Create 1C information base
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Создание информационной базы 1С
|
||||
@@ -29,6 +30,12 @@
|
||||
.PARAMETER ListName
|
||||
Имя базы в списке
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB"
|
||||
|
||||
@@ -60,32 +67,298 @@ param(
|
||||
[switch]$AddToList,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ListName
|
||||
[string]$ListName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
# --- 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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$UseTemplate = ConvertTo-CleanPath $UseTemplate '-UseTemplate'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-FileIbCreated {
|
||||
# File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$IbPath)
|
||||
$f = Join-Path $IbPath "1Cv8.1CD"
|
||||
return (Test-Path $f) -and ((Get-Item $f -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/UseTemplate' = '-UseTemplate'; '/AddToList' = '-AddToList'; '--db-path' = '-InfoBasePath'; '--load' = '-UseTemplate'; '--restore' = '-UseTemplate' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -101,9 +374,41 @@ $tempDir = Join-Path $env:TEMP "db_create_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "create", "--db-path=$InfoBasePath", "--create-database")
|
||||
if ($UseTemplate) {
|
||||
if ([System.IO.Path]::GetExtension($UseTemplate) -ieq ".dt") {
|
||||
$arguments += "--restore=$UseTemplate"
|
||||
} else {
|
||||
$arguments += "--load=$UseTemplate", "--apply"
|
||||
}
|
||||
}
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$ibMissing = ($exitCode -eq 0) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("CREATEINFOBASE")
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): 1C's own parser wants them there, quoting
|
||||
# the whole token instead breaks a path with spaces. Hence -PreQuoted on the launch.
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "Srvr=`"$InfoBaseServer`";Ref=`"$InfoBaseRef`""
|
||||
} else {
|
||||
@@ -128,19 +433,26 @@ try {
|
||||
$outFile = Join-Path $tempDir "create_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $((Format-ArgsForDisplay $arguments $engine) -join ' ')"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition (file infobase only): exit 0 without a non-empty 1Cv8.1CD is a false success.
|
||||
$ibMissing = ($exitCode -eq 0) -and -not ($InfoBaseServer -and $InfoBaseRef) -and -not (Test-FileIbCreated $InfoBasePath)
|
||||
if ($ibMissing) { $exitCode = 1 }
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Information base created successfully: $InfoBaseServer/$InfoBaseRef" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Information base created successfully: $InfoBasePath" -ForegroundColor Green
|
||||
}
|
||||
} elseif ($ibMissing) {
|
||||
Write-Host "Error: exit code 0 but 1Cv8.1CD is missing or empty at $InfoBasePath — information base was not created" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error creating information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
@@ -153,6 +465,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.0 — Create 1C information base
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
found = sorted(glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe"))
|
||||
if found:
|
||||
return found[-1]
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def file_ib_created(ib_path):
|
||||
"""File-infobase postcondition: the platform must have produced a non-empty 1Cv8.1CD.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
f = os.path.join(ib_path, "1Cv8.1CD")
|
||||
return os.path.isfile(f) and os.path.getsize(f) > 0
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -44,12 +349,39 @@ def main():
|
||||
parser.add_argument("-UseTemplate", default="")
|
||||
parser.add_argument("-AddToList", action="store_true")
|
||||
parser.add_argument("-ListName", default="")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.UseTemplate = clean_path(args.UseTemplate, "-UseTemplate")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/UseTemplate": "-UseTemplate",
|
||||
"/AddToList": "-AddToList",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--load": "-UseTemplate",
|
||||
"--restore": "-UseTemplate",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -58,6 +390,37 @@ def main():
|
||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "create", f"--db-path={args.InfoBasePath}", "--create-database"]
|
||||
if args.UseTemplate:
|
||||
if os.path.splitext(args.UseTemplate)[1].lower() == ".dt":
|
||||
arguments.append(f"--restore={args.UseTemplate}")
|
||||
else:
|
||||
arguments.extend([f"--load={args.UseTemplate}", "--apply"])
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
print(f"Running: ibcmd {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
ib_missing = exit_code == 0 and not file_ib_created(args.InfoBasePath)
|
||||
if ib_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_create_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -66,6 +429,8 @@ def main():
|
||||
# --- Build arguments ---
|
||||
arguments = ["CREATEINFOBASE"]
|
||||
|
||||
# Quotes go INSIDE the token (File="path"): that is where 1C's parser expects them.
|
||||
# Quoting the whole token instead breaks a path with spaces — on both OSes.
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.append(f'Srvr="{args.InfoBaseServer}";Ref="{args.InfoBaseRef}"')
|
||||
else:
|
||||
@@ -73,35 +438,44 @@ def main():
|
||||
|
||||
# --- Template ---
|
||||
if args.UseTemplate:
|
||||
arguments.extend(["/UseTemplate", args.UseTemplate])
|
||||
arguments.extend(["/UseTemplate", f'"{args.UseTemplate}"'])
|
||||
|
||||
# --- Add to list ---
|
||||
if args.AddToList:
|
||||
if args.ListName:
|
||||
arguments.extend(["/AddToList", args.ListName])
|
||||
arguments.extend(["/AddToList", f'"{args.ListName}"'])
|
||||
else:
|
||||
arguments.append("/AddToList")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "create_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {' '.join(format_args_for_display(arguments, engine))}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# 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 args.InfoBaseServer and args.InfoBaseRef:
|
||||
if is_server:
|
||||
print(f"Information base created successfully: {args.InfoBaseServer}/{args.InfoBaseRef}")
|
||||
else:
|
||||
print(f"Information base created successfully: {args.InfoBasePath}")
|
||||
elif ib_missing:
|
||||
print(
|
||||
f"Error: exit code 0 but 1Cv8.1CD is missing or empty at {args.InfoBasePath} "
|
||||
"— information base was not created",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error creating information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
@@ -115,6 +489,7 @@ def main():
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
@@ -42,7 +42,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -51,20 +51,11 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-cf.ps1" <п
|
||||
| `-OutputFile <путь>` | да | Путь к выходному CF-файлу |
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
## После выполнения
|
||||
|
||||
Прочитай лог-файл и покажи результат. Если есть ошибки — покажи содержимое лога.
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-dump-cf v1.0 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в CF-файл
|
||||
@@ -35,6 +36,12 @@
|
||||
.PARAMETER AllExtensions
|
||||
Выгрузить все расширения
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "config.cf"
|
||||
|
||||
@@ -69,32 +76,317 @@ param(
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -110,6 +402,37 @@ $tempDir = Join-Path $env:TEMP "db_dump_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config save does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "save", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$OutputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -135,15 +458,21 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
@@ -156,6 +485,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.0 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
found = sorted(glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe"))
|
||||
if found:
|
||||
return found[-1]
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -46,12 +369,40 @@ def main():
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -60,6 +411,37 @@ def main():
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -69,40 +451,43 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpCfg", args.OutputFile])
|
||||
arguments.extend(["/DumpCfg", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", args.Extension])
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_cf_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
@@ -117,6 +502,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: db-dump-dt
|
||||
description: Выгрузка информационной базы 1С в DT-файл (вся база — конфигурация + данные). Используй когда нужно выгрузить информационную базу, выгрузить архив базы, сделать бэкап, выгрузить dt
|
||||
argument-hint: "[database] [output.dt]"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-dump-dt — Выгрузка информационной базы в DT-файл
|
||||
|
||||
Выгружает информационную базу целиком (конфигурация **+ данные**) в DT-файл — полный снимок ИБ.
|
||||
|
||||
> В отличие от `/db-dump-cf` (только конфигурация), `.dt` содержит **всю базу**: данные,
|
||||
> настройки, пользователей. Это бэкап/точка отката, а не выгрузка метаданных.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-dump-dt [database] [output.dt]
|
||||
/db-dump-dt dev backup.dt
|
||||
/db-dump-dt — база по умолчанию, имя файла по базе и дате
|
||||
```
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному DT-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Выгрузка ИБ (файловая база)
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -OutputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Password "secret" -OutputFile "base.dt"
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-load-dt` — загрузка ИБ из DT (обратная операция)
|
||||
- `/db-dump-cf` — выгрузка только конфигурации (без данных)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -0,0 +1,470 @@
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка информационной базы 1С в DT-файл
|
||||
|
||||
.DESCRIPTION
|
||||
Выгружает информационную базу целиком (конфигурация + данные) в DT-файл.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному DT-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
$outDir = Split-Path $OutputFile -Parent
|
||||
if ($outDir -and -not (Test-Path $outDir)) {
|
||||
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_dump_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "dump", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$OutputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
$arguments += "/DumpIB", "`"$OutputFile`""
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "dump_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base dumped successfully to: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — information base was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping information base (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump 1C information base to DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-OutputFile", required=True)
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
out_dir = os.path.dirname(args.OutputFile)
|
||||
if out_dir and not os.path.isdir(out_dir):
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "dump", f"--db-path={args.InfoBasePath}"]
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.OutputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/DumpIB", f'"{args.OutputFile}"'])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Information base dumped successfully to: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -29,7 +29,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
|
||||
@@ -44,7 +44,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -56,6 +56,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
| `-Extension <имя>` | нет | Выгрузить расширение |
|
||||
| `-AllExtensions` | нет | Выгрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -68,13 +70,6 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-dump-xml.ps1" <
|
||||
| `Partial` | Частичная — выбранные объекты из параметра `-Objects` |
|
||||
| `UpdateInfo` | Обновить только ConfigDumpInfo.xml без выгрузки файлов |
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
> Если пользователь просит выгрузить конкретные объекты — используй `-Mode Partial` с `-Objects`.
|
||||
|
||||
## Примеры
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-dump-xml v1.0 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Выгрузка конфигурации 1С в XML-файлы
|
||||
@@ -47,6 +48,12 @@
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
@@ -92,32 +99,317 @@ param(
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical"
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -139,6 +431,49 @@ $tempDir = Join-Path $env:TEMP "db_dump_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "export", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "UpdateInfo") {
|
||||
Write-Host "Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8" -ForegroundColor Red
|
||||
exit 1
|
||||
} elseif ($Mode -eq "Partial") {
|
||||
$objList = @($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$arguments = @("infobase", "config", "export", "objects") + $objList
|
||||
$arguments += "--out=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "export", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration exported successfully to: $ConfigDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not exported" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error exporting configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -192,16 +527,22 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $ConfigDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully" -ForegroundColor Green
|
||||
Write-Host "Configuration dumped to: $ConfigDir"
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $ConfigDir — configuration was not dumped" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping configuration (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
@@ -214,6 +555,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.0 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
candidates = glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
if candidates:
|
||||
candidates.sort()
|
||||
return candidates[-1]
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return v8path
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
@@ -61,13 +382,41 @@ def main():
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -81,6 +430,49 @@ def main():
|
||||
os.makedirs(args.ConfigDir, exist_ok=True)
|
||||
print(f"Created output directory: {args.ConfigDir}")
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical Full/Changes) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif args.Mode == "Partial":
|
||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||
arguments = ["infobase", "config", "export", "objects"] + obj_list
|
||||
arguments += [f"--out={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "export", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Configuration exported successfully to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_dump_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -90,16 +482,16 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpConfigToFiles", args.ConfigDir]
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
if args.Mode == "Full":
|
||||
@@ -116,7 +508,7 @@ def main():
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(object_list))
|
||||
|
||||
arguments += ["-listFile", list_file]
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
print(f"Objects to dump: {len(object_list)}")
|
||||
for obj in object_list:
|
||||
print(f" {obj}")
|
||||
@@ -126,28 +518,31 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", args.Extension]
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.ConfigDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print("Dump completed successfully")
|
||||
print(f"Configuration dumped to: {args.ConfigDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
@@ -162,6 +557,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -29,6 +29,7 @@ allowed-tools:
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"v8args": ["/UseHwLicenses+"],
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
@@ -61,6 +62,8 @@ allowed-tools:
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `v8path` | string | Каталог bin платформы 1С. Необязательный — если не задан, автоопределение |
|
||||
| `v8args` | array | Доп. аргументы запуска `1cv8.exe` для всех навыков, напр. `["/UseHwLicenses+"]` |
|
||||
| `ibcmdargs` | array | Доп. аргументы `ibcmd` (форма `--ключ=значение`) |
|
||||
| `databases` | array | Массив баз данных |
|
||||
| `default` | string | id базы по умолчанию |
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
@@ -43,7 +43,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -52,20 +52,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-cf.ps1" <п
|
||||
| `-InputFile <путь>` | да | Путь к CF-файлу |
|
||||
| `-Extension <имя>` | нет | Загрузить как расширение |
|
||||
| `-AllExtensions` | нет | Загрузить все расширения из архива |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
## После выполнения
|
||||
|
||||
1. Прочитай лог-файл и покажи результат
|
||||
2. **Предложи выполнить `/db-update`** — загрузка CF обновляет только «основную» конфигурацию конфигуратора, для применения к БД нужен `/UpdateDBCfg`
|
||||
**Предложи выполнить `/db-update`** — загрузка CF обновляет только «основную» конфигурацию конфигуратора, для применения к БД нужен `/UpdateDBCfg`
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-load-cf v1.0 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из CF-файла
|
||||
@@ -35,6 +36,12 @@
|
||||
.PARAMETER AllExtensions
|
||||
Загрузить все расширения из архива
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "config.cf"
|
||||
|
||||
@@ -69,32 +76,327 @@ param(
|
||||
[string]$Extension,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$AllExtensions
|
||||
[switch]$AllExtensions,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -110,6 +412,33 @@ $tempDir = Join-Path $env:TEMP "db_load_cf_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config load does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "load", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$InputFile"
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -135,17 +464,18 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_cf_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Configuration loaded successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
@@ -156,6 +486,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.0 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
found = sorted(glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe"))
|
||||
if found:
|
||||
return found[-1]
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -46,12 +387,40 @@ def main():
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -60,6 +429,31 @@ def main():
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_cf_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -69,42 +463,39 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.extend(["/LoadCfg", args.InputFile])
|
||||
arguments.extend(["/LoadCfg", f'"{args.InputFile}"'])
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", args.Extension])
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_cf_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
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):
|
||||
try:
|
||||
@@ -117,6 +508,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: db-load-dt
|
||||
description: Загрузка информационной базы 1С из DT-файла — полная перезапись базы (конфигурация + данные). Используй когда нужно загрузить архив информационной базы, восстановить базу, загрузить dt
|
||||
disable-model-invocation: true
|
||||
argument-hint: <input.dt> [database]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-load-dt — Загрузка информационной базы из DT-файла
|
||||
|
||||
Восстанавливает информационную базу целиком (конфигурация **+ данные**) из DT-файла.
|
||||
|
||||
> ⚠️ **Необратимая операция.** Загрузка `.dt` **полностью перезаписывает базу** — и
|
||||
> конфигурацию, и все данные. Текущее содержимое базы будет потеряно. После загрузки
|
||||
> `/db-update` **не нужен** — конфигурация БД уже синхронна внутри снимка.
|
||||
|
||||
## Когда НЕ использовать
|
||||
|
||||
- Нужно создать **новую** базу из `.dt` → используй `/db-create` (из DT-шаблона), а не загрузку
|
||||
в существующую.
|
||||
- Нужно обновить только конфигурацию (без данных) → `/db-load-cf` или `/db-load-xml`.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-load-dt <input.dt> [database]
|
||||
/db-load-dt backup.dt dev
|
||||
```
|
||||
|
||||
## Порядок действий перед загрузкой
|
||||
|
||||
1. Предложи пользователю сначала сделать `/db-dump-dt` текущего состояния базы — это точка
|
||||
отката (восстановиться будет нечем, если не сохранить).
|
||||
2. Запроси **явное подтверждение**: вся база (данные + конфигурация) будет перезаписана.
|
||||
3. Только после подтверждения выполняй загрузку.
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` (путь к платформе) и разреши базу:
|
||||
1. Если пользователь указал параметры подключения (путь, сервер) — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" <параметры>
|
||||
```
|
||||
|
||||
### Параметры скрипта
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Имя пользователя |
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-InputFile <путь>` | да | Путь к DT-файлу |
|
||||
| `-JobsCount <N>` | нет | Число фоновых заданий загрузки (0 = по числу процессоров) |
|
||||
| `-UnlockCode <код>` | нет | Код разблокировки (`/UC`), если заблокировано начало сеансов |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## После выполнения
|
||||
|
||||
Если база занята (активные сеансы), загрузка не выполнится — для серверной базы можно
|
||||
передать `-UnlockCode`; иначе освободи базу и повтори.
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Файловая база
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBasePath "C:\Bases\MyDB" -UserName "Admin" -InputFile "C:\backup\base.dt"
|
||||
|
||||
# Серверная база с ускорением загрузки
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-dt.ps1" -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Test" -UserName "Admin" -Password "secret" -InputFile "base.dt" -JobsCount 4
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- `/db-dump-dt` — выгрузка ИБ в DT (обратная операция, точка отката перед загрузкой)
|
||||
- `/db-create` — создать новую базу (в т.ч. из DT-шаблона)
|
||||
@@ -0,0 +1,487 @@
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка информационной базы 1С из DT-файла
|
||||
|
||||
.DESCRIPTION
|
||||
Загружает информационную базу целиком (конфигурация + данные) из DT-файла.
|
||||
ВНИМАНИЕ: операция полностью перезаписывает базу.
|
||||
|
||||
.PARAMETER V8Path
|
||||
Путь к каталогу bin платформы или к 1cv8.exe
|
||||
|
||||
.PARAMETER InfoBasePath
|
||||
Путь к файловой информационной базе
|
||||
|
||||
.PARAMETER InfoBaseServer
|
||||
Сервер 1С (для серверной базы)
|
||||
|
||||
.PARAMETER InfoBaseRef
|
||||
Имя базы на сервере
|
||||
|
||||
.PARAMETER UserName
|
||||
Имя пользователя 1С
|
||||
|
||||
.PARAMETER Password
|
||||
Пароль пользователя
|
||||
|
||||
.PARAMETER InputFile
|
||||
Путь к DT-файлу для загрузки
|
||||
|
||||
.PARAMETER JobsCount
|
||||
Количество фоновых заданий для загрузки (0 = по числу процессоров)
|
||||
|
||||
.PARAMETER UnlockCode
|
||||
Код разблокировки базы (/UC) — если заблокировано начало сеансов
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBasePath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseServer,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$InfoBaseRef,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UserName,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Password,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[int]$JobsCount = 0,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$UnlockCode,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Temp dir ---
|
||||
$tempDir = Join-Path $env:TEMP "db_load_dt_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
$arguments = @("infobase", "restore", "--db-path=$InfoBasePath")
|
||||
if (-not (Test-Path (Join-Path $InfoBasePath "1Cv8.1CD"))) { $arguments += "--create-database" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "$InputFile"
|
||||
|
||||
$arguments += "--data=$tempDir"
|
||||
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
if ($InfoBaseServer -and $InfoBaseRef) {
|
||||
$arguments += "/S", "`"$InfoBaseServer/$InfoBaseRef`""
|
||||
} else {
|
||||
$arguments += "/F", "`"$InfoBasePath`""
|
||||
}
|
||||
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
if ($UnlockCode) { $arguments += "/UC`"$UnlockCode`"" }
|
||||
|
||||
$arguments += "/RestoreIB", "`"$InputFile`""
|
||||
if ($JobsCount -gt 0) { $arguments += "-JobsCount", "$JobsCount" }
|
||||
|
||||
# --- Output ---
|
||||
$outFile = Join-Path $tempDir "load_dt_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Information base restored successfully from: $InputFile" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error restoring information base (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
Write-Host "--- Log ---"
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
if (Test-Path $tempDir) {
|
||||
Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Load 1C information base from DT file",
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument("-V8Path", default="")
|
||||
parser.add_argument("-InfoBasePath", default="")
|
||||
parser.add_argument("-InfoBaseServer", default="")
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-InputFile", required=True)
|
||||
parser.add_argument("-JobsCount", type=int, default=0)
|
||||
parser.add_argument("-UnlockCode", default="")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
arguments = ["infobase", "restore", f"--db-path={args.InfoBasePath}"]
|
||||
if not os.path.isfile(os.path.join(args.InfoBasePath, "1Cv8.1CD")):
|
||||
arguments.append("--create-database")
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(args.InputFile)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_dt_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
if args.UnlockCode:
|
||||
arguments.append(f'/UC"{args.UnlockCode}"')
|
||||
|
||||
arguments.extend(["/RestoreIB", f'"{args.InputFile}"'])
|
||||
if args.JobsCount > 0:
|
||||
arguments.extend(["-JobsCount", str(args.JobsCount)])
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_dt_log.txt")
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print(f"Information base restored successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
log_content = f.read()
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
if os.path.isdir(temp_dir):
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,7 +30,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
Если в записи базы указан `configSrc` — используй как каталог конфигурации.
|
||||
@@ -45,7 +45,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -59,13 +59,14 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-git.ps1" <
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-DryRun` | нет | Только показать что будет загружено (без загрузки) |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
## После выполнения
|
||||
|
||||
1. Показать список загруженных файлов и результат из лога
|
||||
2. Если `-UpdateDB` не был указан — **предложить `/db-update`** для применения изменений к БД
|
||||
Если `-UpdateDB` не был указан — **предложить `/db-update`** для применения изменений к БД
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-load-git v1.3 — Load Git changes into 1C database
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка изменений из Git в базу 1С
|
||||
@@ -47,6 +48,12 @@
|
||||
.PARAMETER DryRun
|
||||
Только показать что будет загружено (без загрузки)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Source All
|
||||
|
||||
@@ -101,12 +108,164 @@ param(
|
||||
[switch]$DryRun,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function Get-ExitAnnotation {
|
||||
# Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
# A batch DESIGNER that crashes (e.g. missing license) may leave the infobase locked or
|
||||
# half-updated — surface that instead of a plain code. (Windows exception codes only;
|
||||
# POSIX signals are handled in the .py port.)
|
||||
param([int]$Code)
|
||||
$win = @{
|
||||
-1073741819 = "0xC0000005 (access violation)"
|
||||
-1073741515 = "0xC0000135 (missing DLL)"
|
||||
-1073740791 = "0xC0000409 (stack overrun)"
|
||||
}
|
||||
if ($win.ContainsKey($Code)) {
|
||||
return " — abnormal termination, exception $($win[$Code]); the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# --- Helper: map sub-file path (BSL, HTML, etc.) to object XML ---
|
||||
function Get-ObjectXmlFromSubFile {
|
||||
param([string]$RelativePath)
|
||||
@@ -120,32 +279,138 @@ function Get-ObjectXmlFromSubFile {
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
if (-not $DryRun) {
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate connection (skip if DryRun) ---
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
$engine = "1cv8"
|
||||
if (-not $DryRun) {
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if (-not (Test-Path $ConfigDir)) {
|
||||
Write-Host "Error: config directory not found: $ConfigDir" -ForegroundColor Red
|
||||
@@ -167,6 +432,15 @@ try {
|
||||
}
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
# Все git-вызовы для сбора путей идут через один хелпер с -c core.quotePath=false,
|
||||
# иначе кириллические пути возвращаются в octal-виде и не распознаются (зеркало run_git в .py).
|
||||
function Invoke-GitLines {
|
||||
param([string[]]$GitArgs)
|
||||
$out = git -c core.quotePath=false @GitArgs 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { return $out }
|
||||
return @()
|
||||
}
|
||||
|
||||
$changedFiles = @()
|
||||
$ConfigDir = (Resolve-Path $ConfigDir).Path.TrimEnd('\')
|
||||
$configDirNormalized = $ConfigDir.Replace('\', '/')
|
||||
@@ -176,29 +450,22 @@ try {
|
||||
switch ($Source) {
|
||||
"Staged" {
|
||||
Write-Host "Getting staged changes..."
|
||||
$raw = git diff --cached --name-only --relative 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
}
|
||||
"Unstaged" {
|
||||
Write-Host "Getting unstaged changes..."
|
||||
$raw = git diff --name-only --relative 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$raw = git ls-files --others --exclude-standard 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
"Commit" {
|
||||
Write-Host "Getting changes from $CommitRange..."
|
||||
$raw = git diff --name-only --relative $CommitRange 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative', $CommitRange)
|
||||
}
|
||||
"All" {
|
||||
Write-Host "Getting all uncommitted changes..."
|
||||
$raw = git diff --cached --name-only --relative 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$raw = git diff --name-only --relative 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$raw = git ls-files --others --exclude-standard 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { $changedFiles += $raw }
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--cached', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('diff', '--name-only', '--relative')
|
||||
$changedFiles += Invoke-GitLines -GitArgs @('ls-files', '--others', '--exclude-standard')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -216,13 +483,16 @@ Write-Host "Git changes detected: $($changedFiles.Count) files"
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
$configFiles = @()
|
||||
$supportSkipped = @()
|
||||
|
||||
foreach ($file in $changedFiles) {
|
||||
$file = $file.Trim().Replace('\', '/')
|
||||
if ([string]::IsNullOrWhiteSpace($file)) { continue }
|
||||
|
||||
# Skip service files
|
||||
if ($file -eq "ConfigDumpInfo.xml") { continue }
|
||||
# Skip service files (not partially loadable). Support-state files are tracked
|
||||
# to warn the user: support changes apply only via a full load.
|
||||
if ($file -match 'ParentConfigurations\.bin$') { $supportSkipped += $file; continue }
|
||||
if ($file -eq "ConfigDumpInfo.xml" -or $file -match '(^|/)ConfigDumpInfo\.xml$') { continue }
|
||||
|
||||
$fullPath = Join-Path $ConfigDir $file
|
||||
|
||||
@@ -265,6 +535,12 @@ foreach ($file in $changedFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($supportSkipped.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportSkipped) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
if ($configFiles.Count -eq 0) {
|
||||
Write-Host "No configuration files found in changes"
|
||||
exit 0
|
||||
@@ -285,6 +561,55 @@ $tempDir = Join-Path $env:TEMP "db_load_git_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $configFiles
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading changes (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Changes loaded successfully ($($configFiles.Count) files)" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
$listFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
@@ -324,21 +649,22 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host ""
|
||||
Write-Host "Executing partial configuration load..."
|
||||
Write-Host "Running: 1cv8.exe $($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
|
||||
$exitCode = $process.ExitCode
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
Write-Host ""
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
@@ -349,6 +675,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-git v1.3 — Load Git changes into 1C database
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
@@ -13,24 +15,327 @@ import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
candidates = glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
if candidates:
|
||||
candidates.sort()
|
||||
return candidates[-1]
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return v8path
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def get_object_xml_from_subfile(relative_path):
|
||||
@@ -44,7 +349,7 @@ def get_object_xml_from_subfile(relative_path):
|
||||
def run_git(config_dir, git_args):
|
||||
"""Run a git command in config_dir and return output lines on success."""
|
||||
result = subprocess.run(
|
||||
["git"] + git_args,
|
||||
["git", "-c", "core.quotePath=false"] + git_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -55,6 +360,39 @@ def run_git(config_dir, git_args):
|
||||
return []
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -86,19 +424,48 @@ def main():
|
||||
)
|
||||
parser.add_argument("-DryRun", action="store_true", help="Only show what would be loaded (no actual load)")
|
||||
parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
|
||||
# --- Resolve V8Path (skip if DryRun) ---
|
||||
v8path = None
|
||||
if not args.DryRun:
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Validate connection (skip if DryRun) ---
|
||||
# --- Detect engine + validate connection (skip if DryRun) ---
|
||||
engine = "1cv8"
|
||||
if not args.DryRun:
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
@@ -146,14 +513,19 @@ def main():
|
||||
|
||||
# --- Filter and map to config files ---
|
||||
config_files = []
|
||||
support_skipped = []
|
||||
|
||||
for file in changed_files:
|
||||
file = file.strip().replace("\\", "/")
|
||||
if not file:
|
||||
continue
|
||||
|
||||
# Skip service files
|
||||
if file == "ConfigDumpInfo.xml":
|
||||
# Skip service files (not partially loadable). Support-state files are
|
||||
# tracked to warn: support changes apply only via a full load.
|
||||
if file.endswith("ParentConfigurations.bin"):
|
||||
support_skipped.append(file)
|
||||
continue
|
||||
if file == "ConfigDumpInfo.xml" or file.endswith("/ConfigDumpInfo.xml"):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(args.ConfigDir, file)
|
||||
@@ -186,6 +558,12 @@ def main():
|
||||
if rel_path not in config_files:
|
||||
config_files.append(rel_path)
|
||||
|
||||
if support_skipped:
|
||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr)
|
||||
for sf in support_skipped:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr)
|
||||
|
||||
if len(config_files) == 0:
|
||||
print("No configuration files found in changes")
|
||||
sys.exit(0)
|
||||
@@ -205,6 +583,51 @@ def main():
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch (file infobase only; import specific files) ---
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + config_files
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Write list file (UTF-8 with BOM) ---
|
||||
list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(list_file, "w", encoding="utf-8-sig") as f:
|
||||
@@ -214,24 +637,24 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
||||
arguments += ["-listFile", list_file]
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", args.Extension]
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
@@ -241,19 +664,16 @@ def main():
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print("")
|
||||
print("Executing partial configuration load...")
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
@@ -261,7 +681,7 @@ def main():
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
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):
|
||||
try:
|
||||
@@ -274,6 +694,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -30,7 +30,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
|
||||
@@ -45,7 +45,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -59,6 +59,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-load-xml.ps1" <
|
||||
| `-AllExtensions` | нет | Загрузить все расширения |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-UpdateDB` | нет | После загрузки сразу обновить конфигурацию БД (`/UpdateDBCfg`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -80,17 +82,9 @@ Documents/Заказ.xml
|
||||
Documents/Заказ/Forms/ФормаДокумента.xml
|
||||
```
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
## После выполнения
|
||||
|
||||
1. Прочитай лог и покажи результат
|
||||
2. Если `-UpdateDB` не был указан — **предложи выполнить `/db-update`** для применения изменений к БД
|
||||
Если `-UpdateDB` не был указан — **предложи выполнить `/db-update`** для применения изменений к БД
|
||||
|
||||
## Примеры
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-load-xml v1.3 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Загрузка конфигурации 1С из XML-файлов
|
||||
@@ -47,6 +48,12 @@
|
||||
.PARAMETER Format
|
||||
Формат файлов: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Full
|
||||
|
||||
@@ -101,32 +108,328 @@ param(
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$ConfigDir = ConvertTo-CleanPath $ConfigDir '-ConfigDir'
|
||||
$ListFile = ConvertTo-CleanPath $ListFile '-ListFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -148,6 +451,75 @@ $tempDir = Join-Path $env:TEMP "db_load_xml_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
$fileList = @()
|
||||
if ($ListFile) {
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fileList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} elseif ($Files) {
|
||||
$fileList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: -Files or -ListFile required for partial import" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "import", "files") + $fileList
|
||||
$arguments += "--base-dir=$ConfigDir", "--db-path=$InfoBasePath"
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
} else {
|
||||
$arguments = @("infobase", "config", "import", "--db-path=$InfoBasePath")
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
$arguments += "$ConfigDir"
|
||||
}
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "Error loading configuration from files (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
Write-Host "Configuration loaded successfully from: $ConfigDir" -ForegroundColor Green
|
||||
Write-PlatformOutput $output
|
||||
|
||||
if ($UpdateDB) {
|
||||
$applyArgs = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($UserName) { $applyArgs += "--user=$UserName" }
|
||||
if ($Password) { $applyArgs += "--password=$Password" }
|
||||
$applyArgs += "--data=$tempDir"
|
||||
$applyArgs += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $applyArgs $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $applyArgs
|
||||
$applyOut = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $applyOut
|
||||
}
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -168,24 +540,35 @@ try {
|
||||
Write-Host "Executing partial configuration load..."
|
||||
|
||||
# Build list file
|
||||
$generatedListFile = $null
|
||||
$rawList = @()
|
||||
if ($ListFile) {
|
||||
# Use provided list file
|
||||
if (-not (Test-Path $ListFile)) {
|
||||
Write-Host "Error: list file not found: $ListFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$generatedListFile = $ListFile
|
||||
$rawList = @(Get-Content -Path $ListFile -Encoding UTF8 | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} else {
|
||||
# Generate from -Files parameter
|
||||
$fileList = $Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
$rawList = @($Files -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
$supportRe = 'ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$'
|
||||
$supportFiles = @($rawList | Where-Object { $_ -match $supportRe })
|
||||
$fileList = @($rawList | Where-Object { $_ -notmatch $supportRe })
|
||||
if ($supportFiles.Count -gt 0) {
|
||||
Write-Host "[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):" -ForegroundColor Yellow
|
||||
foreach ($sf in $supportFiles) { Write-Host " - $sf" -ForegroundColor Yellow }
|
||||
Write-Host " Смена состояния поддержки применяется только полной загрузкой: -Mode Full." -ForegroundColor Yellow
|
||||
}
|
||||
if ($fileList.Count -eq 0) {
|
||||
Write-Host "Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$generatedListFile = Join-Path $tempDir "load_list.txt"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllLines($generatedListFile, $fileList, $utf8Bom)
|
||||
|
||||
Write-Host "Files to load: $($fileList.Count)"
|
||||
foreach ($f in $fileList) { Write-Host " $f" }
|
||||
}
|
||||
|
||||
$arguments += "-listFile", "`"$generatedListFile`""
|
||||
$arguments += "-partial"
|
||||
@@ -210,11 +593,12 @@ try {
|
||||
$outFile = Join-Path $tempDir "load_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Read log ---
|
||||
$logContent = $null
|
||||
@@ -253,7 +637,7 @@ try {
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Load completed successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error loading configuration (code: $exitCode)" -ForegroundColor Red
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($logContent) {
|
||||
@@ -261,6 +645,7 @@ try {
|
||||
Write-Host $logContent
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
||||
|
||||
@@ -1,35 +1,374 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.3 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
candidates = glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
if candidates:
|
||||
candidates.sort()
|
||||
return candidates[-1]
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return v8path
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
@@ -68,13 +407,43 @@ def main():
|
||||
action="store_true",
|
||||
help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
|
||||
args.ListFile = clean_path(args.ListFile, "-ListFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -88,6 +457,70 @@ def main():
|
||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
||||
elif args.Files:
|
||||
file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
|
||||
else:
|
||||
file_list = []
|
||||
if not file_list:
|
||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
else:
|
||||
arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
arguments.append(args.ConfigDir)
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
exit_code = 0
|
||||
if args.UpdateDB:
|
||||
apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.UserName:
|
||||
apply_args.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
apply_args.append(f"--password={args.Password}")
|
||||
apply_args.append(f"--data={ib_data}")
|
||||
apply_args.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
|
||||
ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
|
||||
exit_code = ar.returncode
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -97,16 +530,16 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", args.ConfigDir]
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
print("Executing full configuration load...")
|
||||
@@ -114,25 +547,35 @@ def main():
|
||||
print("Executing partial configuration load...")
|
||||
|
||||
# Build list file
|
||||
generated_list_file = None
|
||||
if args.ListFile:
|
||||
# Use provided list file
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
generated_list_file = args.ListFile
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
||||
else:
|
||||
# Generate from -Files parameter
|
||||
file_list = [f.strip() for f in args.Files.split(",") if f.strip()]
|
||||
raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]
|
||||
|
||||
# Support-state service files are NOT partially loadable — exclude with a hint.
|
||||
support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
|
||||
support_files = [x for x in raw_list if support_re.search(x)]
|
||||
file_list = [x for x in raw_list if not support_re.search(x)]
|
||||
if support_files:
|
||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):", file=sys.stderr)
|
||||
for sf in support_files:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
||||
if not file_list:
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||
f.write("\n".join(file_list))
|
||||
|
||||
print(f"Files to load: {len(file_list)}")
|
||||
for fl in file_list:
|
||||
print(f" {fl}")
|
||||
|
||||
arguments += ["-listFile", generated_list_file]
|
||||
arguments += ["-listFile", f'"{generated_list_file}"']
|
||||
arguments.append("-partial")
|
||||
arguments.append("-updateConfigDumpInfo")
|
||||
|
||||
@@ -140,7 +583,7 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments += ["-Extension", args.Extension]
|
||||
arguments += ["-Extension", f'"{args.Extension}"']
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
@@ -150,16 +593,13 @@ def main():
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "load_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Read log ---
|
||||
@@ -198,13 +638,14 @@ def main():
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
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:
|
||||
print("--- Log ---")
|
||||
print(log_content)
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
if silent_failures:
|
||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
||||
print(
|
||||
|
||||
@@ -29,7 +29,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
@@ -52,6 +52,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-run.ps1" <пар
|
||||
| `-Execute <файл.epf>` | нет | Запуск внешней обработки сразу после старта |
|
||||
| `-CParam <строка>` | нет | Параметр запуска (/C) |
|
||||
| `-URL <ссылка>` | нет | Навигационная ссылка (формат `e1cib/...`) |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-run v1.0 — Launch 1C:Enterprise
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Запуск 1С:Предприятие
|
||||
@@ -35,6 +36,12 @@
|
||||
.PARAMETER URL
|
||||
Навигационная ссылка (e1cib/...)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
@@ -72,30 +79,226 @@ param(
|
||||
[string]$CParam,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$URL
|
||||
[string]$URL,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$Execute = ConvertTo-CleanPath $Execute '-Execute'
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
$engine = "1cv8"
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '/Execute' = '-Execute'; '/C' = '-CParam'; '/URL' = '-URL' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# ShellExecute re-joins the argument string, so quote each extra token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
@@ -139,7 +342,28 @@ if ($URL) {
|
||||
|
||||
$argString += " /DisableStartupDialogs"
|
||||
|
||||
# --- Execute (background, no wait) ---
|
||||
Write-Host "Running: 1cv8.exe $argString"
|
||||
Start-Process -FilePath $V8Path -ArgumentList $argString
|
||||
# The display string is built from the same tokens with secret-prone values redacted.
|
||||
$displayString = $argString
|
||||
foreach ($tok in $extraArgs) { $argString += (Format-ArgToken $tok) }
|
||||
foreach ($tok in (Format-ArgsForDisplay $extraArgs $engine)) { $displayString += (Format-ArgToken $tok) }
|
||||
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
$displayArg = Protect-Secrets $displayString @($Password, $UserName)
|
||||
Write-Host "Running: 1cv8.exe $displayArg"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $argString -PassThru
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
$deadline = (Get-Date).AddMilliseconds(1500)
|
||||
while ((Get-Date) -lt $deadline -and -not $proc.HasExited) {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
if ($proc.HasExited) {
|
||||
Write-Host "Error: 1C:Enterprise exited immediately (code: $($proc.ExitCode))" -ForegroundColor Red
|
||||
if ($proc.ExitCode -ne 0) { exit $proc.ExitCode } else { exit 1 }
|
||||
}
|
||||
Write-Host "PID: $($proc.Id)"
|
||||
Write-Host "1C:Enterprise launched" -ForegroundColor Green
|
||||
|
||||
@@ -1,32 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.0 — Launch 1C:Enterprise
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
found = sorted(glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe"))
|
||||
if found:
|
||||
return found[-1]
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -43,10 +275,34 @@ def main():
|
||||
parser.add_argument("-Execute", default="")
|
||||
parser.add_argument("-CParam", default="")
|
||||
parser.add_argument("-URL", default="")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
args.Execute = clean_path(args.Execute, "-Execute")
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
# --- Resolve additional arguments ---
|
||||
# 1C:Enterprise is always launched by 1cv8 — ibcmd has no interactive mode.
|
||||
engine = "1cv8"
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"/Execute": "-Execute",
|
||||
"/C": "-CParam",
|
||||
"/URL": "-URL",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
@@ -83,10 +339,25 @@ def main():
|
||||
arguments.extend(["/URL", args.URL])
|
||||
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(extra_args)
|
||||
|
||||
# --- Execute (background, no wait) ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
subprocess.Popen([v8path] + arguments)
|
||||
# --- Execute (background) ---
|
||||
# Redact the password/user before printing the command line — never leak secrets.
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
proc = subprocess.Popen([v8path] + arguments)
|
||||
|
||||
# --- Bounded early-exit check ---
|
||||
# The launch is a background GUI process, so we don't wait for completion. But a process
|
||||
# that dies within the first ~1.5s never really started (bad base, no display, license) —
|
||||
# report that honestly instead of a blind "launched".
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline and proc.poll() is None:
|
||||
time.sleep(0.2)
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr)
|
||||
sys.exit(rc if rc and rc > 0 else 1)
|
||||
print(f"PID: {proc.pid}")
|
||||
print("1C:Enterprise launched")
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ allowed-tools:
|
||||
2. Если указал базу по имени — ищи по id / alias / name в `.v8-project.json`
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
@@ -42,7 +42,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -53,6 +53,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
| `-Dynamic <+/->` | нет | `+` — динамическое обновление, `-` — отключить |
|
||||
| `-Server` | нет | Обновление на стороне сервера |
|
||||
| `-WarningsAsErrors` | нет | Предупреждения считать ошибками |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
@@ -66,13 +68,6 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-update.ps1" <п
|
||||
| `-BackgroundSuspend` | Приостановить |
|
||||
| `-BackgroundResume` | Возобновить |
|
||||
|
||||
## Коды возврата
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 0 | Успешно |
|
||||
| 1 | Ошибка (см. лог) |
|
||||
|
||||
## Предупреждения
|
||||
|
||||
- Если обновление **не динамическое** — потребуется **монопольный доступ** к базе (все пользователи должны выйти)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# db-update v1.0 — Update 1C database configuration
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Обновление конфигурации базы данных 1С
|
||||
@@ -41,6 +42,12 @@
|
||||
.PARAMETER WarningsAsErrors
|
||||
Предупреждения считать ошибками
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
@@ -82,32 +89,326 @@ param(
|
||||
[switch]$Server,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} elseif (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
Write-Host "Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -117,6 +418,34 @@ $tempDir = Join-Path $env:TEMP "db_update_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if ($AllExtensions) {
|
||||
Write-Host "Error: ibcmd config apply does not support -AllExtensions (use -Extension)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$arguments = @("infobase", "config", "apply", "--db-path=$InfoBasePath", "--force")
|
||||
if ($Dynamic -eq "+") { $arguments += "--dynamic=auto" }
|
||||
elseif ($Dynamic -eq "-") { $arguments += "--dynamic=disable" }
|
||||
if ($Extension) { $arguments += "--extension=$Extension" }
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -153,17 +482,18 @@ try {
|
||||
$outFile = Join-Path $tempDir "update_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Database configuration updated successfully" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)" -ForegroundColor Red
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
if (Test-Path $outFile) {
|
||||
@@ -174,6 +504,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.0 — Update 1C database configuration
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
found = sorted(glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe"))
|
||||
if found:
|
||||
return found[-1]
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def describe_exit(code):
|
||||
"""Annotate an abnormal process exit code so a crash isn't reported as a bare number.
|
||||
Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
|
||||
instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
|
||||
if code is None:
|
||||
return ""
|
||||
win = {
|
||||
3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
|
||||
3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
|
||||
3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
|
||||
}
|
||||
if code in win:
|
||||
return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
|
||||
if -64 <= code < 0:
|
||||
try:
|
||||
import signal
|
||||
name = signal.Signals(-code).name
|
||||
except (ValueError, AttributeError):
|
||||
name = f"signal {-code}"
|
||||
return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
|
||||
"the infobase may be left in an inconsistent state; verify it before retrying")
|
||||
return ""
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -48,15 +389,71 @@ def main():
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.Dynamic == "+":
|
||||
arguments.append("--dynamic=auto")
|
||||
elif args.Dynamic == "-":
|
||||
arguments.append("--dynamic=disable")
|
||||
if args.Extension:
|
||||
arguments.append(f"--extension={args.Extension}")
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, bool(args.UserName))
|
||||
if result.returncode == 0:
|
||||
print("Database configuration updated successfully")
|
||||
else:
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), f"db_update_{random.randint(0, 999999)}")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
@@ -66,14 +463,14 @@ def main():
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments.extend(["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"])
|
||||
arguments.extend(["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"'])
|
||||
else:
|
||||
arguments.extend(["/F", args.InfoBasePath])
|
||||
arguments.extend(["/F", f'"{args.InfoBasePath}"'])
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
@@ -87,29 +484,26 @@ def main():
|
||||
|
||||
# --- Extensions ---
|
||||
if args.Extension:
|
||||
arguments.extend(["-Extension", args.Extension])
|
||||
arguments.extend(["-Extension", f'"{args.Extension}"'])
|
||||
elif args.AllExtensions:
|
||||
arguments.append("-AllExtensions")
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "update_log.txt")
|
||||
arguments.extend(["/Out", out_file])
|
||||
arguments.extend(["/Out", f'"{out_file}"'])
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
if exit_code == 0:
|
||||
print("Database configuration updated successfully")
|
||||
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 os.path.isfile(out_file):
|
||||
try:
|
||||
@@ -122,6 +516,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -34,7 +34,7 @@ allowed-tools:
|
||||
5. Если ветка не совпала — используй `default`
|
||||
6. Если `.v8-project.json` нет или база не найдена — не указывай параметры подключения: скрипт автоматически создаст временную базу. Для EPF со ссылочными типами (CatalogRef, DocumentRef и т.д.) генерируются заглушки метаданных. Временная база удаляется после сборки.
|
||||
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
@@ -47,7 +47,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-build.ps1" <п
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному EPF/ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# epf-build v1.0 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Сборка внешней обработки/отчёта 1С из XML-исходников
|
||||
@@ -32,6 +33,12 @@
|
||||
.PARAMETER OutputFile
|
||||
Путь к выходному EPF/ERF-файлу
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МояОбработка.xml" -OutputFile "build\МояОбработка.epf"
|
||||
|
||||
@@ -63,27 +70,312 @@ param(
|
||||
[string]$SourceFile,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$OutputFile
|
||||
[string]$OutputFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$SourceFile = ConvertTo-CleanPath $SourceFile '-SourceFile'
|
||||
$OutputFile = ConvertTo-CleanPath $OutputFile '-OutputFile'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-OutputNonEmpty {
|
||||
# Postcondition: the platform must have produced a non-empty output file.
|
||||
# Exit code 0 without it (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Leaf) -and ((Get-Item $Path -ErrorAction SilentlyContinue).Length -gt 0)
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd" -and $InfoBaseServer -and $InfoBaseRef) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -94,8 +386,20 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
$autoBasePath = Join-Path $env:TEMP "epf_stub_db_$(Get-Random)"
|
||||
$stubScript = Join-Path $PSScriptRoot "stub-db-create.ps1"
|
||||
Write-Host "No database specified. Creating temporary stub database..."
|
||||
$stubArgs = "-SourceDir `"$sourceDir`" -V8Path `"$V8Path`" -TempBasePath `"$autoBasePath`""
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$stubScript`" $stubArgs" -NoNewWindow -Wait -PassThru
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
# Invoked via -Command, not -File: -File takes the tail literally, so an array
|
||||
# parameter would arrive as a single comma-glued token.
|
||||
$q = { param($s) "'" + ($s -replace "'", "''") + "'" }
|
||||
$stubCmd = "& $(& $q $stubScript) -SourceDir $(& $q $sourceDir) -V8Path $(& $q $V8Path) -TempBasePath $(& $q $autoBasePath)"
|
||||
if ($AdditionalV8Arguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalV8Arguments " + (($AdditionalV8Arguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
if ($AdditionalIbcmdArguments.Count -gt 0) {
|
||||
$stubCmd += " -AdditionalIbcmdArguments " + (($AdditionalIbcmdArguments | ForEach-Object { & $q $_ }) -join ',')
|
||||
}
|
||||
$stubProc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -Command `"$stubCmd`"" -NoNewWindow -Wait -PassThru
|
||||
if ($stubProc.ExitCode -ne 0) {
|
||||
Write-Host "Error: failed to create stub database" -ForegroundColor Red
|
||||
exit 1
|
||||
@@ -121,6 +425,32 @@ $tempDir = Join-Path $env:TEMP "epf_build_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
$srcDir = Split-Path $SourceFile -Parent
|
||||
$arguments = @("infobase", "config", "import", "$srcDir", "--out=$OutputFile", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report built successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -139,15 +469,21 @@ try {
|
||||
$outFile = Join-Path $tempDir "build_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-OutputNonEmpty $OutputFile)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Build completed successfully: $OutputFile" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no non-empty file at $OutputFile — build produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error building (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
@@ -160,6 +496,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.0 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
candidates = glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
if candidates:
|
||||
candidates.sort()
|
||||
return candidates[-1]
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return v8path
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def output_nonempty(path):
|
||||
"""Postcondition: the platform must have produced a non-empty output file.
|
||||
Exit code 0 without it (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isfile(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
@@ -47,10 +368,38 @@ def main():
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-SourceFile", required=True, help="Path to root XML source file")
|
||||
parser.add_argument("-OutputFile", required=True, help="Path to output EPF/ERF file")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.SourceFile = clean_path(args.SourceFile, "-SourceFile")
|
||||
args.OutputFile = clean_path(args.OutputFile, "-OutputFile")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if engine == "ibcmd" and args.InfoBaseServer and args.InfoBaseRef:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
auto_created_base = None
|
||||
@@ -59,10 +408,16 @@ def main():
|
||||
auto_base_path = os.path.join(tempfile.gettempdir(), f"epf_stub_db_{random.randint(0, 999999)}")
|
||||
stub_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stub-db-create.py")
|
||||
print("No database specified. Creating temporary stub database...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path, "-TempBasePath", auto_base_path],
|
||||
capture_output=False,
|
||||
)
|
||||
stub_cmd = [sys.executable, stub_script, "-SourceDir", source_dir, "-V8Path", v8path,
|
||||
"-TempBasePath", auto_base_path]
|
||||
# The stub runs its own platform processes (CREATEINFOBASE, LoadConfigFromFiles,
|
||||
# UpdateDBCfg) — they need the same extra arguments as the final build. Only the
|
||||
# explicit ones are forwarded: the stub reads .v8-project.json itself.
|
||||
if v8_extra:
|
||||
stub_cmd += ["-AdditionalV8Arguments"] + list(v8_extra)
|
||||
if ibcmd_extra:
|
||||
stub_cmd += ["-AdditionalIbcmdArguments"] + list(ibcmd_extra)
|
||||
result = subprocess.run(stub_cmd, capture_output=False)
|
||||
if result.returncode != 0:
|
||||
print("Error: failed to create stub database", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -84,38 +439,67 @@ def main():
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: build EPF/ERF via config import --out ---
|
||||
src_dir = os.path.dirname(os.path.abspath(args.SourceFile))
|
||||
arguments = ["infobase", "config", "import", src_dir, f"--out={args.OutputFile}", f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report built successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", args.SourceFile, args.OutputFile]
|
||||
arguments += ["/LoadExternalDataProcessorOrReportFromFiles", f'"{args.SourceFile}"', f'"{args.OutputFile}"']
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "build_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 without a non-empty output file is a false success.
|
||||
out_missing = exit_code == 0 and not output_nonempty(args.OutputFile)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Build completed successfully: {args.OutputFile}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
@@ -130,6 +514,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# stub-db-create v1.0 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -7,12 +7,162 @@ param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$V8Path,
|
||||
|
||||
[string]$TempBasePath
|
||||
[string]$TempBasePath,
|
||||
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$SourceDir = ConvertTo-CleanPath $SourceDir '-SourceDir'
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$TempBasePath = ConvertTo-CleanPath $TempBasePath '-TempBasePath'
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
$script:V8OwnedKeys = @(
|
||||
'DESIGNER', 'ENTERPRISE', 'CREATEINFOBASE', 'CONFIG',
|
||||
'/F', '/S', '/N', '/P', '/Out', '/DisableStartupDialogs',
|
||||
'/UseTemplate', '/AddToList', '/Execute', '/C', '/URL', '/UC',
|
||||
'/DumpIB', '/RestoreIB', '/DumpCfg', '/LoadCfg',
|
||||
'/DumpConfigToFiles', '/LoadConfigFromFiles', '/UpdateDBCfg',
|
||||
'/DumpExternalDataProcessorOrReportToFiles', '/LoadExternalDataProcessorOrReportFromFiles'
|
||||
)
|
||||
$script:IbcmdOwnedKeys = @(
|
||||
'--db-path', '--data', '--out', '--file', '--load', '--restore',
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
# A token matches a key when it equals the key, or starts with it and the next
|
||||
# character is not a letter — catches glued /N"user" and --password=x, while
|
||||
# keeping /ClearCache distinct from /C.
|
||||
param([string]$Token, [string]$Key)
|
||||
if ($Token.Length -lt $Key.Length) { return $false }
|
||||
if (-not $Token.Substring(0, $Key.Length).Equals($Key, [System.StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
if ($Token.Length -eq $Key.Length) { return $true }
|
||||
return -not [char]::IsLetter($Token[$Key.Length])
|
||||
}
|
||||
|
||||
function Get-ProjectExtraArgs {
|
||||
# v8args / ibcmdargs from .v8-project.json — same upward walk as v8path.
|
||||
param([string]$Name)
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.$Name) { return @($j.$Name | ForEach-Object { [string]$_ }) }
|
||||
} catch {}
|
||||
return @()
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return @()
|
||||
}
|
||||
|
||||
function Assert-ExtraArgs {
|
||||
# The platform accepts only one batch operation, and a duplicate connection or
|
||||
# output key fails with an opaque 1C error — reject what the skill owns itself.
|
||||
param([string[]]$ExtraArgs, [string]$Engine, [hashtable]$Hints)
|
||||
$paramName = if ($Engine -eq 'ibcmd') { '-AdditionalIbcmdArguments' } else { '-AdditionalV8Arguments' }
|
||||
$owned = if ($Engine -eq 'ibcmd') { $script:IbcmdOwnedKeys } else { $script:V8OwnedKeys }
|
||||
foreach ($tok in $ExtraArgs) {
|
||||
if ($Engine -eq 'ibcmd' -and $tok -notmatch '^-') {
|
||||
Write-Host "Error: '$tok' is a positional token — pass values as --key=value ($paramName cannot extend the ibcmd command)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
|
||||
# --- 1. Scan XML files for reference types ---
|
||||
|
||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||
@@ -1252,11 +1402,119 @@ $propsXml </Properties>$childObjLine
|
||||
}
|
||||
}
|
||||
|
||||
# --- 5a. Stub via ibcmd (one call: create [--import --apply]) ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
$stubEngine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-TempBasePath'; '--db-path' = '-TempBasePath' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $stubEngine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
|
||||
function Format-ArgToken {
|
||||
# Start-Process takes these argument lists as one string, so quote each token that needs it.
|
||||
param([string]$Token)
|
||||
if ($Token -match '[\s"]') { return ' "' + ($Token -replace '"', '\"') + '"' }
|
||||
return " $Token"
|
||||
}
|
||||
$extraArgString = -join ($extraArgs | ForEach-Object { Format-ArgToken $_ })
|
||||
if ($stubEngine -eq "ibcmd") {
|
||||
Write-Host "Creating infobase (ibcmd): $TempBasePath"
|
||||
$ibData = Join-Path $env:TEMP "stub_data_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $ibData -Force | Out-Null
|
||||
$ibArgs = @("infobase", "create", "--db-path=$TempBasePath", "--create-database")
|
||||
if ($hasRefTypes) { $ibArgs += "--import=$(Join-Path $TempBasePath 'cfg')", "--apply", "--force" }
|
||||
$ibArgs += "--data=$ibData"
|
||||
$ibArgs += $extraArgs
|
||||
$__ib = Invoke-PlatformProcess $V8Path $ibArgs
|
||||
$ibOut = $__ib.Output
|
||||
$ibRc = $__ib.ExitCode
|
||||
Remove-Item -Path $ibData -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if ($ibRc -ne 0) {
|
||||
Write-PlatformOutput $ibOut
|
||||
Write-Error "Failed to create stub infobase (code: $ibRc)"
|
||||
exit 1
|
||||
}
|
||||
if ($hasRefTypes) { Remove-Item -Path (Join-Path $TempBasePath "cfg") -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
Write-Host "[OK] Stub database created: $TempBasePath"
|
||||
Write-Host $TempBasePath
|
||||
exit 0
|
||||
}
|
||||
|
||||
# --- 5. Create infobase ---
|
||||
Write-Host "Creating infobase: $TempBasePath"
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $createArgs -NoNewWindow -Wait -PassThru
|
||||
$createArgs = "CREATEINFOBASE File=`"$TempBasePath`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($createArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to create infobase (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1267,10 +1525,11 @@ if ($hasRefTypes) {
|
||||
# LoadConfigFromFiles
|
||||
Write-Host "Loading configuration from files..."
|
||||
$loadLog = Join-Path $env:TEMP "stub_load_log.txt"
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $loadArgs -NoNewWindow -Wait -PassThru
|
||||
$loadArgs = "DESIGNER /F`"$TempBasePath`" /LoadConfigFromFiles `"$cfgDir`" /Out `"$loadLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($loadArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $loadLog) { Get-Content $loadLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to load config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
@@ -1278,10 +1537,11 @@ if ($hasRefTypes) {
|
||||
# UpdateDBCfg
|
||||
Write-Host "Updating database configuration..."
|
||||
$updateLog = Join-Path $env:TEMP "stub_update_log.txt"
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs"
|
||||
$proc = Start-Process -FilePath $V8Path -ArgumentList $updateArgs -NoNewWindow -Wait -PassThru
|
||||
$updateArgs = "DESIGNER /F`"$TempBasePath`" /UpdateDBCfg /Out `"$updateLog`" /DisableStartupDialogs" + $extraArgString
|
||||
$proc = Invoke-PlatformProcess $V8Path @($updateArgs) -PreQuoted
|
||||
if ($proc.ExitCode -ne 0) {
|
||||
if (Test-Path $updateLog) { Get-Content $updateLog -Raw -ErrorAction SilentlyContinue | Write-Host }
|
||||
Write-PlatformOutput $proc.Output
|
||||
Write-Error "Failed to update DB config (code: $($proc.ExitCode))"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# stub-db-create v1.0 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,256 @@ import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
# --- 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():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@@ -781,7 +1031,17 @@ def main():
|
||||
parser.add_argument('-SourceDir', required=True)
|
||||
parser.add_argument('-V8Path', required=True)
|
||||
parser.add_argument('-TempBasePath', default='')
|
||||
args = parser.parse_args()
|
||||
parser.add_argument('-AdditionalV8Arguments', nargs='*', default=[],
|
||||
help='Extra 1cv8 arguments, e.g. /UseHwLicenses+')
|
||||
parser.add_argument('-AdditionalIbcmdArguments', nargs='*', default=[],
|
||||
help='Extra ibcmd arguments in --key=value form')
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.SourceDir = clean_path(args.SourceDir, "-SourceDir")
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.TempBasePath = clean_path(args.TempBasePath, "-TempBasePath")
|
||||
|
||||
type_map = scan_ref_types(args.SourceDir)
|
||||
register_columns = scan_register_columns(args.SourceDir)
|
||||
@@ -1034,13 +1294,43 @@ def main():
|
||||
if register_columns:
|
||||
print('WARNING: Register column categories (Dimension/Resource/Attribute) are guessed. Form field bindings may not survive round-trip through a real database.')
|
||||
|
||||
# Stub via ibcmd (one call: create [--import --apply])
|
||||
stub_engine = "ibcmd" if os.path.basename(args.V8Path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {"/F": "-TempBasePath", "--db-path": "-TempBasePath"}
|
||||
extra_args = resolve_extra_args(stub_engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
if stub_engine == "ibcmd":
|
||||
import shutil
|
||||
print(f'Creating infobase (ibcmd): {temp_base}')
|
||||
ib_data = tempfile.mkdtemp(prefix="stub_data_")
|
||||
ib_args = [args.V8Path, 'infobase', 'create', f'--db-path={temp_base}', '--create-database']
|
||||
if has_ref_types:
|
||||
ib_args += [f'--import={os.path.join(temp_base, "cfg")}', '--apply', '--force']
|
||||
ib_args.append(f'--data={ib_data}')
|
||||
ib_args.extend(extra_args)
|
||||
result = run_ibcmd(ib_args, warn_no_user=False)
|
||||
shutil.rmtree(ib_data, ignore_errors=True)
|
||||
if result.returncode != 0:
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr, file=sys.stderr)
|
||||
print(f'Failed to create stub infobase (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if has_ref_types:
|
||||
import shutil
|
||||
shutil.rmtree(os.path.join(temp_base, 'cfg'), ignore_errors=True)
|
||||
print(f'[OK] Stub database created: {temp_base}')
|
||||
print(temp_base)
|
||||
sys.exit(0)
|
||||
|
||||
# Create infobase
|
||||
print(f'Creating infobase: {temp_base}')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'CREATEINFOBASE', f'File={temp_base}', '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['CREATEINFOBASE', f'File="{temp_base}"', '/DisableStartupDialogs']
|
||||
+ [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to create infobase (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1048,21 +1338,18 @@ def main():
|
||||
cfg_dir = os.path.join(temp_base, 'cfg')
|
||||
# LoadConfigFromFiles
|
||||
print('Loading configuration from files...')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/LoadConfigFromFiles', cfg_dir, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/LoadConfigFromFiles', f'"{cfg_dir}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
print_platform_output(result)
|
||||
print(f'Failed to load config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# UpdateDBCfg
|
||||
print('Updating database configuration...')
|
||||
update_log = os.path.join(tempfile.gettempdir(), 'stub_update_log.txt')
|
||||
result = subprocess.run(
|
||||
[args.V8Path, 'DESIGNER', f'/F{temp_base}', '/UpdateDBCfg', '/Out', update_log, '/DisableStartupDialogs'],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = run_v8(args.V8Path, ['DESIGNER', f'/F"{temp_base}"', '/UpdateDBCfg', '/Out', f'"{update_log}"',
|
||||
'/DisableStartupDialogs'] + [quote_if_needed(a) for a in extra_args])
|
||||
if result.returncode != 0:
|
||||
if os.path.isfile(update_log):
|
||||
try:
|
||||
@@ -1070,6 +1357,7 @@ def main():
|
||||
print(f.read())
|
||||
except Exception:
|
||||
pass
|
||||
print_platform_output(result)
|
||||
print(f'Failed to update DB config (code: {result.returncode})', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ allowed-tools:
|
||||
5. Если ветка не совпала — используй `default`
|
||||
6. Если `.v8-project.json` нет или база не найдена — **сообщи пользователю об ошибке**. Для dump база обязательна: в пустой базе ссылочные типы (CatalogRef, DocumentRef и т.д.) безвозвратно сбрасываются в строки. Предложи указать базу или зарегистрировать через `/db-list add`.
|
||||
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
@@ -46,7 +46,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы (или полный путь к 1cv8.exe) |
|
||||
| `-V8Path <путь>` | нет | Каталог bin платформы, или полный путь к `1cv8.exe` / `ibcmd.exe` |
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С (для серверной базы) |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
@@ -55,6 +55,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/epf-dump.ps1" <па
|
||||
| `-InputFile <путь>` | да | Путь к EPF/ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# epf-dump v1.0 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Разборка внешней обработки/отчёта 1С в XML-исходники
|
||||
@@ -35,6 +36,12 @@
|
||||
.PARAMETER Format
|
||||
Формат выгрузки: Hierarchical или Plain (по умолчанию Hierarchical)
|
||||
|
||||
.PARAMETER AdditionalV8Arguments
|
||||
Дополнительные аргументы запуска 1cv8.exe (например /UseHwLicenses+)
|
||||
|
||||
.PARAMETER AdditionalIbcmdArguments
|
||||
Дополнительные аргументы запуска ibcmd (форма --ключ=значение)
|
||||
|
||||
.EXAMPLE
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МояОбработка.epf" -OutputDir "src"
|
||||
|
||||
@@ -70,27 +77,217 @@ param(
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical"
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalIbcmdArguments = @()
|
||||
)
|
||||
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
} else {
|
||||
Write-Host "Error: 1cv8.exe not found. Specify -V8Path" -ForegroundColor Red
|
||||
# --- 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
|
||||
}
|
||||
} elseif (Test-Path $V8Path -PathType Container) {
|
||||
foreach ($k in $owned) {
|
||||
if (Test-ArgKeyMatch $tok $k) {
|
||||
$hint = ''
|
||||
if ($Hints -and $Hints.ContainsKey($k)) { $hint = " (use $($Hints[$k]))" }
|
||||
Write-Host "Error: $k is controlled by the skill and cannot be passed via $paramName$hint" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-ExtraArgs {
|
||||
# Pick the argument list for the selected engine and validate it. An explicitly passed
|
||||
# parameter for the other engine is an error; the same keys coming from .v8-project.json
|
||||
# simply do not apply — a project may describe both engines.
|
||||
param([string]$Engine, [string[]]$V8Extra, [string[]]$IbcmdExtra, [hashtable]$Hints)
|
||||
# powershell.exe -File — how skills are invoked — cannot bind an array parameter:
|
||||
# space-separated values spill into positional ones, a comma-joined list arrives as a
|
||||
# single token. So accept the repo's list convention (comma-separated) and split here;
|
||||
# a native array call keeps working. A value containing a comma is not supported.
|
||||
$V8Extra = @($V8Extra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
$IbcmdExtra = @($IbcmdExtra | ForEach-Object { $_ -split ',' } | Where-Object { $_ -ne '' })
|
||||
if ($Engine -eq 'ibcmd' -and $V8Extra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd (use -AdditionalIbcmdArguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -ne 'ibcmd' -and $IbcmdExtra.Count -gt 0) {
|
||||
Write-Host "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 (use -AdditionalV8Arguments)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Engine -eq 'ibcmd') {
|
||||
$extra = @(Get-ProjectExtraArgs 'ibcmdargs') + @($IbcmdExtra)
|
||||
} else {
|
||||
$extra = @(Get-ProjectExtraArgs 'v8args') + @($V8Extra)
|
||||
}
|
||||
if ($extra.Count -gt 0) { Assert-ExtraArgs $extra $Engine $Hints }
|
||||
# Plain return, no comma trick: the caller re-collects with @(...), and ,@() there
|
||||
# would nest the array — the tokens would then be glued into one argument.
|
||||
return $extra
|
||||
}
|
||||
|
||||
function Format-ArgsForDisplay {
|
||||
# Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
# Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
# a leaked password does.
|
||||
param([string[]]$ArgList, [string]$Engine)
|
||||
$keys = if ($Engine -eq 'ibcmd') { $script:IbcmdSecretKeys } else { $script:V8SecretKeys }
|
||||
$res = @()
|
||||
$maskNext = $false
|
||||
foreach ($tok in $ArgList) {
|
||||
if ($maskNext) { $res += '***'; $maskNext = $false; continue }
|
||||
$hit = $null
|
||||
foreach ($k in $keys) {
|
||||
if ($tok.Length -ge $k.Length -and $tok.Substring(0, $k.Length).Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $hit = $k; break }
|
||||
}
|
||||
if (-not $hit) { $res += $tok; continue }
|
||||
if ($tok.Length -eq $hit.Length) { $res += $tok; $maskNext = $true }
|
||||
elseif ($tok[$hit.Length] -eq '=') { $res += ($hit + '=***') }
|
||||
else { $res += ($hit + '***') }
|
||||
}
|
||||
return ,$res
|
||||
}
|
||||
|
||||
function ConvertTo-CleanPath {
|
||||
# Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
# surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
# inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
# 1C answer with its opaque "Неверные или отсутствующие параметры соединения".
|
||||
param([string]$Value, [string]$ParamName)
|
||||
if (-not $Value) { return $Value }
|
||||
$v = $Value.Trim()
|
||||
if ($v.Length -ge 2 -and $v[0] -eq $v[-1] -and ($v[0] -eq '"' -or $v[0] -eq "'")) {
|
||||
$v = $v.Substring(1, $v.Length - 2).Trim()
|
||||
}
|
||||
if ($v.Length -gt 3 -and ($v[-1] -eq '\' -or $v[-1] -eq '/')) { $v = $v.Substring(0, $v.Length - 1) }
|
||||
if ($v.Contains('"')) {
|
||||
Write-Host "Error: $ParamName contains a quote character: $Value" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
$V8Path = ConvertTo-CleanPath $V8Path '-V8Path'
|
||||
$InfoBasePath = ConvertTo-CleanPath $InfoBasePath '-InfoBasePath'
|
||||
$InputFile = ConvertTo-CleanPath $InputFile '-InputFile'
|
||||
$OutputDir = ConvertTo-CleanPath $OutputDir '-OutputDir'
|
||||
|
||||
function Assert-InfoBaseExists {
|
||||
# These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
# "Неверные или отсутствующие параметры соединения" after a launch.
|
||||
param([string]$Path)
|
||||
if (-not $Path) { return }
|
||||
if (-not (Test-Path (Join-Path $Path "1Cv8.1CD"))) {
|
||||
Write-Host "Error: information base not found at $Path (no 1Cv8.1CD)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
Assert-InfoBaseExists $InfoBasePath
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
function Find-ProjectV8Path {
|
||||
$dir = (Get-Location).Path
|
||||
while ($dir) {
|
||||
$pf = Join-Path $dir ".v8-project.json"
|
||||
if (Test-Path $pf) {
|
||||
try {
|
||||
$j = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($j.v8path) { return [string]$j.v8path }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
$parent = Split-Path $dir -Parent
|
||||
if (-not $parent -or $parent -eq $dir) { break }
|
||||
$dir = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $V8Path) {
|
||||
$V8Path = Find-ProjectV8Path
|
||||
}
|
||||
if (-not $V8Path) {
|
||||
$found = Get-ChildItem @("C:\Program Files\1cv8\*\bin\1cv8.exe", "C:\Program Files (x86)\1cv8\*\bin\1cv8.exe") -ErrorAction SilentlyContinue |
|
||||
Sort-Object { try { [version]$_.Directory.Parent.Name } catch { [version]"0.0" } } -Descending |
|
||||
Select-Object -First 1
|
||||
if ($found) {
|
||||
$V8Path = $found.FullName
|
||||
Write-Host "Auto-selected platform $($found.Directory.Parent.Name): $V8Path" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "Error: 1C executable not found. Specify -V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
if (Test-Path $V8Path -PathType Container) {
|
||||
$V8Path = Join-Path $V8Path "1cv8.exe"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $V8Path)) {
|
||||
Write-Host "Error: 1cv8.exe not found at $V8Path" -ForegroundColor Red
|
||||
Write-Host "Error: 1C executable not found at $V8Path" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -101,6 +298,107 @@ if (-not $InfoBasePath -and (-not $InfoBaseServer -or -not $InfoBaseRef)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect engine (ibcmd vs 1cv8) by exe name ---
|
||||
function ConvertFrom-PlatformBytes {
|
||||
# ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
# OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — guessing
|
||||
# one of them outright mangles Cyrillic.
|
||||
param([byte[]]$Bytes)
|
||||
if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
|
||||
try {
|
||||
$strict = New-Object System.Text.UTF8Encoding($false, $true)
|
||||
return $strict.GetString($Bytes)
|
||||
} catch {
|
||||
return [System.Text.Encoding]::GetEncoding(866).GetString($Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-PlatformProcess {
|
||||
# Run the platform non-interactively and capture its console output. A closed stdin pipe
|
||||
# (EOF) makes an auth prompt fast-fail instead of hanging; capturing keeps the child's
|
||||
# text out of our stream until we print it labelled (and out of the wrong encoding).
|
||||
# Returns @{ Output; ExitCode }.
|
||||
#
|
||||
# Quoting differs by engine, so the caller says which it built:
|
||||
# ibcmd — tokens are bare (--db-path=C:\a b), the whole token gets quoted here;
|
||||
# 1cv8 — -PreQuoted: the caller already put quotes inside the token (File="C:\a b"),
|
||||
# which is where 1C's own parser expects them; quoting again breaks the value.
|
||||
param([string]$Exe, [string[]]$ProcArgs, [switch]$PreQuoted)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $Exe
|
||||
$psi.Arguments = if ($PreQuoted) {
|
||||
$ProcArgs -join ' '
|
||||
} else {
|
||||
($ProcArgs | ForEach-Object { if ($_ -match '[\s"]') { '"' + ($_ -replace '"', '\"') + '"' } else { $_ } }) -join ' '
|
||||
}
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$p = [System.Diagnostics.Process]::Start($psi)
|
||||
$p.StandardInput.Close()
|
||||
# stderr is drained in parallel: reading the streams one after another deadlocks
|
||||
# as soon as the other one fills its pipe buffer.
|
||||
$errMs = New-Object System.IO.MemoryStream
|
||||
$errTask = $p.StandardError.BaseStream.CopyToAsync($errMs)
|
||||
$outMs = New-Object System.IO.MemoryStream
|
||||
$p.StandardOutput.BaseStream.CopyTo($outMs)
|
||||
$errTask.Wait()
|
||||
$p.WaitForExit()
|
||||
$out = ConvertFrom-PlatformBytes $outMs.ToArray()
|
||||
$err = ConvertFrom-PlatformBytes $errMs.ToArray()
|
||||
if ($err) { $out += $err }
|
||||
return [pscustomobject]@{ Output = $out; ExitCode = $p.ExitCode }
|
||||
}
|
||||
|
||||
function Write-PlatformOutput {
|
||||
# Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
# silent: in batch mode 1cv8 reports through /Out and prints nothing here.
|
||||
param([string]$Text)
|
||||
if (-not $Text) { return }
|
||||
$t = $Text.TrimEnd()
|
||||
if (-not $t) { return }
|
||||
$limit = 65536
|
||||
if ($t.Length -gt $limit) {
|
||||
$t = "[... обрезано, показаны последние $limit символов ...]`r`n" + $t.Substring($t.Length - $limit)
|
||||
}
|
||||
Write-Host "--- Вывод платформы ---"
|
||||
Write-Host $t
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
|
||||
function Test-DirNonEmpty {
|
||||
# Postcondition: the platform must have written files into the output directory.
|
||||
# Exit code 0 with an empty dir (broken/headless env) is a false success — reject it.
|
||||
param([string]$Path)
|
||||
return (Test-Path $Path -PathType Container) -and ([bool](Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | Select-Object -First 1))
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
foreach ($s in $Secrets) { if ($s) { $Text = $Text.Replace($s, '***') } }
|
||||
return $Text
|
||||
}
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
$argHints = @{ '/F' = '-InfoBasePath'; '/S' = '-InfoBaseServer + -InfoBaseRef'; '/N' = '-UserName'; '/P' = '-Password'; '--db-path' = '-InfoBasePath'; '--user' = '-UserName'; '--password' = '-Password' }
|
||||
$extraArgs = @(Resolve-ExtraArgs $engine $AdditionalV8Arguments $AdditionalIbcmdArguments $argHints)
|
||||
if ($engine -eq "ibcmd") {
|
||||
if (-not $InfoBasePath) {
|
||||
Write-Host "Error: ibcmd supports file infobases only (use -InfoBasePath)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Format -eq "Plain") {
|
||||
Write-Host "Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Validate input file ---
|
||||
if (-not (Test-Path $InputFile)) {
|
||||
Write-Host "Error: input file not found: $InputFile" -ForegroundColor Red
|
||||
@@ -117,6 +415,31 @@ $tempDir = Join-Path $env:TEMP "epf_dump_$(Get-Random)"
|
||||
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
|
||||
|
||||
try {
|
||||
if ($engine -eq "ibcmd") {
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
$arguments = @("infobase", "config", "export", "--file=$InputFile", "$OutputDir", "--db-path=$InfoBasePath")
|
||||
if ($UserName) { $arguments += "--user=$UserName" }
|
||||
if ($Password) { $arguments += "--password=$Password" }
|
||||
$arguments += "--data=$tempDir"
|
||||
$arguments += $extraArgs
|
||||
Write-Host "Running: ibcmd $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__ib = Invoke-PlatformProcess $V8Path $arguments
|
||||
$output = $__ib.Output
|
||||
$exitCode = $__ib.ExitCode
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "External data processor/report dumped successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping external data processor/report (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
Write-PlatformOutput $output
|
||||
exit $exitCode
|
||||
}
|
||||
|
||||
# --- 1cv8 branch ---
|
||||
# --- Build arguments ---
|
||||
$arguments = @("DESIGNER")
|
||||
|
||||
@@ -136,15 +459,21 @@ try {
|
||||
$outFile = Join-Path $tempDir "dump_log.txt"
|
||||
$arguments += "/Out", "`"$outFile`""
|
||||
$arguments += "/DisableStartupDialogs"
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $($arguments -join ' ')"
|
||||
$process = Start-Process -FilePath $V8Path -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||
$exitCode = $process.ExitCode
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
$outMissing = ($exitCode -eq 0) -and -not (Test-DirNonEmpty $OutputDir)
|
||||
if ($outMissing) { $exitCode = 1 }
|
||||
if ($exitCode -eq 0) {
|
||||
Write-Host "Dump completed successfully to: $OutputDir" -ForegroundColor Green
|
||||
} elseif ($outMissing) {
|
||||
Write-Host "Error: exit code 0 but no files under $OutputDir — dump produced no output" -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host "Error dumping (code: $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
@@ -157,6 +486,7 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,35 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.0 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to 1cv8.exe."""
|
||||
if not v8path:
|
||||
candidates = glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
if candidates:
|
||||
candidates.sort()
|
||||
return candidates[-1]
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get("v8path")
|
||||
if v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return None
|
||||
d = parent
|
||||
|
||||
|
||||
# --- Additional platform arguments ---
|
||||
V8_OWNED_KEYS = [
|
||||
"DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
|
||||
"/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
|
||||
"/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
|
||||
"/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
|
||||
"/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
|
||||
"/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
|
||||
]
|
||||
IBCMD_OWNED_KEYS = [
|
||||
"--db-path", "--data", "--out", "--file", "--load", "--restore",
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
/ClearCache distinct from /C."""
|
||||
if len(token) < len(key):
|
||||
return False
|
||||
if token[: len(key)].lower() != key.lower():
|
||||
return False
|
||||
if len(token) == len(key):
|
||||
return True
|
||||
return not token[len(key)].isalpha()
|
||||
|
||||
|
||||
def project_extra_args(name):
|
||||
"""v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
|
||||
d = os.getcwd()
|
||||
while True:
|
||||
pf = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pf):
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
v = data.get(name)
|
||||
if v:
|
||||
return [str(x) for x in v]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
return []
|
||||
d = parent
|
||||
|
||||
|
||||
def assert_extra_args(extra, engine, hints):
|
||||
"""The platform accepts only one batch operation, and a duplicate connection or
|
||||
output key fails with an opaque 1C error — reject what the skill owns itself."""
|
||||
param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
|
||||
owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
|
||||
for tok in extra:
|
||||
if engine == "ibcmd" and not tok.startswith("-"):
|
||||
print(
|
||||
f"Error: '{tok}' is a positional token — pass values as --key=value "
|
||||
f"({param} cannot extend the ibcmd command)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
for k in owned:
|
||||
if arg_key_match(tok, k):
|
||||
hint = f" (use {hints[k]})" if hints and k in hints else ""
|
||||
print(
|
||||
f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def format_args_for_display(arglist, engine):
|
||||
"""Redact values of secret-prone keys in glued, =-joined and separate forms.
|
||||
Matching here is a plain prefix (no letter rule): over-masking costs nothing,
|
||||
a leaked password does."""
|
||||
keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
|
||||
res = []
|
||||
mask_next = False
|
||||
for tok in arglist:
|
||||
if mask_next:
|
||||
res.append("***")
|
||||
mask_next = False
|
||||
continue
|
||||
hit = None
|
||||
for k in keys:
|
||||
if tok[: len(k)].lower() == k.lower():
|
||||
hit = k
|
||||
break
|
||||
if hit is None:
|
||||
res.append(tok)
|
||||
elif len(tok) == len(hit):
|
||||
res.append(tok)
|
||||
mask_next = True
|
||||
elif tok[len(hit)] == "=":
|
||||
res.append(hit + "=***")
|
||||
else:
|
||||
print("Error: 1cv8.exe not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif os.path.isdir(v8path):
|
||||
v8path = os.path.join(v8path, "1cv8.exe")
|
||||
res.append(hit + "***")
|
||||
return res
|
||||
|
||||
|
||||
def extract_extra_args(argv, known_opts):
|
||||
"""argparse refuses values that start with '-' (every ibcmd key does), so pull the two
|
||||
escape-hatch lists out of argv by hand: after the flag, take everything up to the next
|
||||
declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
|
||||
rest, v8, ibcmd = [], [], []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
low = argv[i].lower()
|
||||
if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
|
||||
target = v8 if low == "-additionalv8arguments" else ibcmd
|
||||
i += 1
|
||||
while i < len(argv) and argv[i].lower() not in known_opts:
|
||||
target.append(argv[i])
|
||||
i += 1
|
||||
continue
|
||||
rest.append(argv[i])
|
||||
i += 1
|
||||
return rest, v8, ibcmd
|
||||
|
||||
|
||||
def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
"""Pick the argument list for the selected engine and validate it. An explicitly
|
||||
passed parameter for the other engine is an error; the same keys coming from
|
||||
.v8-project.json simply do not apply — a project may describe both engines.
|
||||
|
||||
Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
|
||||
so that form is the documented one and both ports must accept it. A value containing
|
||||
a comma is not supported."""
|
||||
v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
|
||||
ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
|
||||
if engine == "ibcmd" and v8_extra:
|
||||
print(
|
||||
"Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
|
||||
"(use -AdditionalIbcmdArguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine != "ibcmd" and ibcmd_extra:
|
||||
print(
|
||||
"Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
|
||||
"(use -AdditionalV8Arguments)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
|
||||
else:
|
||||
extra = project_extra_args("v8args") + list(v8_extra)
|
||||
if extra:
|
||||
assert_extra_args(extra, engine, hints)
|
||||
return extra
|
||||
|
||||
|
||||
def _version_dir(p):
|
||||
"""Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
|
||||
parent = os.path.dirname(p)
|
||||
if os.path.basename(parent).lower() == "bin":
|
||||
parent = os.path.dirname(parent)
|
||||
return os.path.basename(parent)
|
||||
|
||||
|
||||
def _version_key(p):
|
||||
"""Numeric sort key from version dir name."""
|
||||
return [int(x) for x in re.findall(r"\d+", _version_dir(p))]
|
||||
|
||||
|
||||
def resolve_v8path(v8path):
|
||||
"""Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
|
||||
if not v8path:
|
||||
v8path = _find_project_v8path()
|
||||
if not v8path:
|
||||
if os.name == "nt":
|
||||
candidates = (
|
||||
glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
|
||||
+ glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
|
||||
)
|
||||
else:
|
||||
# PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
|
||||
candidates = glob.glob("/opt/1cv8/*/1cv8")
|
||||
if candidates:
|
||||
v8path = max(candidates, key=_version_key)
|
||||
print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
|
||||
else:
|
||||
print("Error: 1C executable not found. Specify -V8Path", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1cv8.exe not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
|
||||
IBCMD_NOUSER_HINT = (
|
||||
"[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
|
||||
"On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
|
||||
"call may block instead of failing. If it does not return promptly, abort and "
|
||||
"re-run with -UserName and -Password.\n"
|
||||
)
|
||||
|
||||
|
||||
def decode_platform_bytes(data):
|
||||
"""ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
|
||||
OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
|
||||
code page (what text=True uses) mangles both."""
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return data.decode("cp866", errors="replace")
|
||||
|
||||
|
||||
def assert_infobase_exists(path):
|
||||
"""These skills work on a ready infobase. Saying so up front beats the platform's
|
||||
"Неверные или отсутствующие параметры соединения" after a launch."""
|
||||
if not path:
|
||||
return
|
||||
if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return v8path
|
||||
|
||||
def clean_path(value, param=""):
|
||||
"""Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
|
||||
surrounding quotes that survived shell parsing, a trailing separator. A quote left
|
||||
inside afterwards cannot be part of a real path — reject it by name instead of letting
|
||||
1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
|
||||
if not value:
|
||||
return value
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
|
||||
v = v[1:-1].strip()
|
||||
if len(v) > 3 and v[-1] in "\\/":
|
||||
v = v[:-1]
|
||||
if '"' in v:
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
|
||||
def quote_if_needed(token):
|
||||
"""Extra arguments come from the caller unquoted; the 1cv8 command line is joined
|
||||
verbatim, so a token with a space needs quotes of its own."""
|
||||
if token and (" " in token or "\t" in token) and '"' not in token:
|
||||
return f'"{token}"'
|
||||
return token
|
||||
|
||||
|
||||
def run_v8(v8path, arguments):
|
||||
"""Run 1cv8 in batch mode and capture its console output.
|
||||
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def print_platform_output(result):
|
||||
"""Print what the platform wrote to the console as its own labelled block. Silence stays
|
||||
silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
|
||||
text = ((result.stdout or "") + (result.stderr or "")).rstrip()
|
||||
if not text:
|
||||
return
|
||||
limit = 65536
|
||||
if len(text) > limit:
|
||||
text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
|
||||
print("--- Вывод платформы ---")
|
||||
print(text)
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
|
||||
On Windows without -UserName ibcmd reads the console directly and may still block —
|
||||
that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
|
||||
"""
|
||||
if warn_no_user and os.name == "nt" and not has_username:
|
||||
sys.stderr.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
return r
|
||||
|
||||
|
||||
def dir_nonempty(path):
|
||||
"""Postcondition: the platform must have written files into the output directory.
|
||||
Exit code 0 with an empty dir (broken/headless env) is a false success — reject it."""
|
||||
return os.path.isdir(path) and any(os.scandir(path))
|
||||
|
||||
|
||||
def _redact(text, *secrets):
|
||||
"""Redact literal secret values (password, user) from a display string —
|
||||
precise, never touches lookalike paths."""
|
||||
for s in secrets:
|
||||
if s:
|
||||
text = text.replace(s, "***")
|
||||
return text
|
||||
|
||||
|
||||
def main():
|
||||
@@ -53,16 +374,48 @@ def main():
|
||||
choices=["Hierarchical", "Plain"],
|
||||
help="Dump format (default: Hierarchical)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
assert_infobase_exists(args.InfoBasePath)
|
||||
args.InputFile = clean_path(args.InputFile, "-InputFile")
|
||||
args.OutputDir = clean_path(args.OutputDir, "-OutputDir")
|
||||
|
||||
# --- Resolve V8Path ---
|
||||
v8path = resolve_v8path(args.V8Path)
|
||||
engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
arg_hints = {
|
||||
"/F": "-InfoBasePath",
|
||||
"/S": "-InfoBaseServer + -InfoBaseRef",
|
||||
"/N": "-UserName",
|
||||
"/P": "-Password",
|
||||
"--db-path": "-InfoBasePath",
|
||||
"--user": "-UserName",
|
||||
"--password": "-Password",
|
||||
}
|
||||
extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)
|
||||
|
||||
# --- Validate database connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef", file=sys.stderr)
|
||||
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
@@ -78,39 +431,67 @@ def main():
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
if engine == "ibcmd":
|
||||
# --- ibcmd branch: dump EPF/ERF via config export --file ---
|
||||
arguments = ["infobase", "config", "export", f"--file={args.InputFile}", args.OutputDir, f"--db-path={args.InfoBasePath}"]
|
||||
ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
|
||||
atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
|
||||
if args.UserName:
|
||||
arguments.append(f"--user={args.UserName}")
|
||||
if args.Password:
|
||||
arguments.append(f"--password={args.Password}")
|
||||
arguments.append(f"--data={ib_data}")
|
||||
arguments.extend(extra_args)
|
||||
print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_ibcmd([v8path] + arguments, warn_no_user=False)
|
||||
exit_code = result.returncode
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"External data processor/report dumped successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
arguments = ["DESIGNER"]
|
||||
|
||||
if args.InfoBaseServer and args.InfoBaseRef:
|
||||
arguments += ["/S", f"{args.InfoBaseServer}/{args.InfoBaseRef}"]
|
||||
arguments += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
|
||||
else:
|
||||
arguments += ["/F", args.InfoBasePath]
|
||||
arguments += ["/F", f'"{args.InfoBasePath}"']
|
||||
|
||||
if args.UserName:
|
||||
arguments.append(f"/N{args.UserName}")
|
||||
arguments.append(f'/N"{args.UserName}"')
|
||||
if args.Password:
|
||||
arguments.append(f"/P{args.Password}")
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", args.OutputDir, args.InputFile]
|
||||
arguments += ["/DumpExternalDataProcessorOrReportToFiles", f'"{args.OutputDir}"', f'"{args.InputFile}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
# --- Output ---
|
||||
out_file = os.path.join(temp_dir, "dump_log.txt")
|
||||
arguments += ["/Out", out_file]
|
||||
arguments += ["/Out", f'"{out_file}"']
|
||||
arguments.append("/DisableStartupDialogs")
|
||||
arguments.extend(quote_if_needed(a) for a in extra_args)
|
||||
|
||||
# --- Execute ---
|
||||
print(f"Running: 1cv8.exe {' '.join(arguments)}")
|
||||
result = subprocess.run(
|
||||
[v8path] + arguments,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
# --- Result ---
|
||||
# Postcondition: exit 0 with an empty output directory is a false success.
|
||||
out_missing = exit_code == 0 and not dir_nonempty(args.OutputDir)
|
||||
if out_missing:
|
||||
exit_code = 1
|
||||
if exit_code == 0:
|
||||
print(f"Dump completed successfully to: {args.OutputDir}")
|
||||
elif out_missing:
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output", file=sys.stderr)
|
||||
else:
|
||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
||||
|
||||
@@ -125,6 +506,7 @@ def main():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-validate v1.2 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
param(
|
||||
@@ -185,8 +185,9 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
$version = $root.GetAttribute("version")
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -ne "2.17" -and $version -ne "2.20" -and $version -ne "2.21") {
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17, 2.20 or 2.21)"
|
||||
} elseif ($version -notin @("2.17", "2.18", "2.19", "2.20", "2.21")) {
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
Report-Warn "1. Unusual version '$version' (expected 2.17-2.20 or 2.21)"
|
||||
}
|
||||
|
||||
# Detect type: ExternalDataProcessor or ExternalReport
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-validate v1.2 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
|
||||
@@ -165,8 +165,9 @@ def main():
|
||||
version = root.get("version", "")
|
||||
if not version:
|
||||
report_warn("1. Missing version attribute on MetaDataObject")
|
||||
elif version not in ("2.17", "2.20", "2.21"):
|
||||
report_warn(f"1. Unusual version '{version}' (expected 2.17, 2.20 or 2.21)")
|
||||
elif version not in ("2.17", "2.18", "2.19", "2.20", "2.21"):
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
report_warn(f"1. Unusual version '{version}' (expected 2.17-2.20 or 2.21)")
|
||||
|
||||
# Detect type
|
||||
child_elements = []
|
||||
|
||||
@@ -34,7 +34,7 @@ allowed-tools:
|
||||
5. Если ветка не совпала — используй `default`
|
||||
6. Если `.v8-project.json` нет или база не найдена — не указывай параметры подключения: скрипт автоматически создаст временную базу. Для ERF со ссылочными типами (CatalogRef, DocumentRef и т.д.) генерируются заглушки метаданных. Временная база удаляется после сборки.
|
||||
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-build/scripts/epf-bu
|
||||
| `-Password <пароль>` | нет | Пароль |
|
||||
| `-SourceFile <путь>` | да | Путь к корневому XML-файлу исходников |
|
||||
| `-OutputFile <путь>` | да | Путь к выходному ERF-файлу |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — опционально. Если не указано — автоматически создаётся временная база со заглушками метаданных
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ allowed-tools:
|
||||
5. Если ветка не совпала — используй `default`
|
||||
6. Если `.v8-project.json` нет или база не найдена — **сообщи пользователю об ошибке**. Для dump база обязательна: в пустой базе ссылочные типы (CatalogRef, DocumentRef и т.д.) безвозвратно сбрасываются в строки. Предложи указать базу или зарегистрировать через `/db-list add`.
|
||||
|
||||
Если `v8path` не задан — автоопределение: `Get-ChildItem "C:\Program Files\1cv8\*\bin\1cv8.exe" | Sort -Desc | Select -First 1`
|
||||
Если `v8path` не задан — скрипт сам попытается определить платформу (`.v8-project.json` → Program Files).
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
|
||||
## Команда
|
||||
@@ -57,6 +57,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/../epf-dump/scripts/epf-dum
|
||||
| `-InputFile <путь>` | да | Путь к ERF-файлу |
|
||||
| `-OutputDir <путь>` | да | Каталог для выгрузки исходников |
|
||||
| `-Format <формат>` | нет | `Hierarchical` (по умолч.) / `Plain` |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы запуска `1cv8.exe` через запятую, напр. `/UseHwLicenses+` |
|
||||
| `-AdditionalIbcmdArguments <список>` | нет | Доп. аргументы `ibcmd` через запятую, в форме `--ключ=значение` |
|
||||
|
||||
> `*` — обязательно хотя бы одно подключение. Без базы скрипт завершится с ошибкой (dump в пустой базе безвозвратно теряет ссылочные типы)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-add v1.5 — Add managed form to 1C config object
|
||||
# form-add v1.12 — Add managed form to 1C config object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -18,6 +18,137 @@ $ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
# --- Detect XML format version ---
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
@@ -25,7 +156,10 @@ function Detect-FormatVersion([string]$dir) {
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
@@ -55,6 +189,7 @@ if (-not (Test-Path $ObjectPath)) {
|
||||
}
|
||||
|
||||
$objectXmlFull = Resolve-Path $ObjectPath
|
||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
||||
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
@@ -76,7 +211,7 @@ $supportedTypes = @(
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task"
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal"
|
||||
)
|
||||
|
||||
$objectType = $null
|
||||
@@ -352,7 +487,10 @@ if (-not $childObjects) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Добавить <Form>$FormName</Form>
|
||||
# Добавить <Form>$FormName</Form> — идемпотентно (не дублировать уже зарегистрированную)
|
||||
$alreadyRegistered = [bool]$childObjects.SelectSingleNode("md:Form[text()='$FormName']", $nsMgr)
|
||||
|
||||
if (-not $alreadyRegistered) {
|
||||
$formElem = $xmlDoc.CreateElement("Form", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$formElem.InnerText = $FormName
|
||||
|
||||
@@ -406,6 +544,7 @@ if ($insertBefore) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- SetDefault ---
|
||||
|
||||
@@ -471,7 +610,11 @@ Write-Host " Metadata: $objDirName\$objBaseName\Forms\$FormName.xml"
|
||||
Write-Host " Form: $objDirName\$objBaseName\Forms\$FormName\Ext\Form.xml"
|
||||
Write-Host " Module: $objDirName\$objBaseName\Forms\$FormName\Ext\Form\Module.bsl"
|
||||
Write-Host ""
|
||||
if ($alreadyRegistered) {
|
||||
Write-Host "Already registered: <Form>$FormName</Form> in ChildObjects (skipped duplicate)"
|
||||
} else {
|
||||
Write-Host "Registered: <Form>$FormName</Form> in ChildObjects"
|
||||
}
|
||||
if ($defaultUpdated) {
|
||||
Write-Host "${defaultPropName}: $defaultValue"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-add v1.5 — Add managed form to 1C config object
|
||||
# form-add v1.12 — Add managed form to 1C config object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -10,6 +11,183 @@ import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
NSMAP = {
|
||||
"md": "http://v8.1c.ru/8.3/MDClasses",
|
||||
"v8": "http://v8.1c.ru/8.1/data/core",
|
||||
@@ -32,13 +210,49 @@ def detect_format_version(d):
|
||||
return "2.17"
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree to file with UTF-8 BOM."""
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -84,6 +298,7 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
object_xml_full = os.path.abspath(object_path)
|
||||
assert_edit_allowed(object_xml_full, "editable")
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
@@ -94,7 +309,7 @@ def main():
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task",
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal",
|
||||
]
|
||||
|
||||
object_type = None
|
||||
@@ -377,7 +592,10 @@ def main():
|
||||
print(f"Не найден элемент ChildObjects в {object_path}", file=sys.stderr)
|
||||
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.text = form_name
|
||||
|
||||
@@ -462,6 +680,9 @@ def main():
|
||||
print(f" Form: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form.xml")
|
||||
print(f" Module: {obj_dir_name}\\{obj_base_name}\\Forms\\{form_name}\\Ext\\Form\\Module.bsl")
|
||||
print()
|
||||
if already_registered:
|
||||
print(f"Already registered: <Form>{form_name}</Form> in ChildObjects (skipped duplicate)")
|
||||
else:
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
if default_updated:
|
||||
print(f"{default_prop_name}: {default_value}")
|
||||
|
||||
@@ -187,6 +187,7 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
|
||||
| `showTitle: true` | Показывать заголовок группы |
|
||||
| `united: false` | Левый край полей ввода выравнивается только в пределах этой группы (по умолчанию `true` — сквозное выравнивание по самому длинному заголовку, в т.ч. с соседними группами) |
|
||||
| `collapsed: true` | Для `behavior: "collapsible"` / `"popup"` — группа создаётся свёрнутой |
|
||||
| `controlRepresentation` | Отображение управления свёрткой (`behavior: "collapsible"`): `"TitleHyperlink"` (гиперссылка заголовка, по умолчанию) / `"Picture"` (картинка) |
|
||||
| `representation` | `"none"`, `"normal"`, `"weak"`, `"strong"` |
|
||||
| `children: [...]` | Вложенные элементы |
|
||||
|
||||
@@ -549,8 +550,8 @@ PictureField, привязанный к булеву/числу, рисует и
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Компиляция**: `/form-compile` генерирует `Form.xml` и автоматически регистрирует `<Form>` в `ChildObjects` родительского объекта (если OutputPath следует конвенции `.../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml`).
|
||||
2. **Метаданные формы** (`ФормаСписка.xml`) и `Module.bsl` создаёт `/form-add`. Если `/form-add` ещё не вызывался — вызови после `/form-compile`. Он не перезаписывает существующий Form.xml.
|
||||
1. **Каркас**: `/form-add` создаёт метаданные формы (`ФормаСписка.xml`), `Module.bsl` и регистрирует форму у объекта.
|
||||
2. **Компиляция**: `/form-compile` наполняет `Form.xml` элементами.
|
||||
3. **Проверка**: `/form-validate`, `/form-info`.
|
||||
|
||||
## Верификация
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-compile v1.172 — Compile 1C managed form from JSON or object metadata
|
||||
# form-compile v1.176 — Compile 1C managed form from JSON or object metadata
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[string]$JsonPath,
|
||||
@@ -1337,7 +1337,10 @@ function Detect-FormatVersion([string]$dir) {
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
@@ -1347,7 +1350,139 @@ function Detect-FormatVersion([string]$dir) {
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
$script:outPathResolved = if ([System.IO.Path]::IsPathRooted($OutputPath)) { $OutputPath } else { Join-Path (Get-Location) $OutputPath }
|
||||
Assert-EditAllowed $script:outPathResolved 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion ([System.IO.Path]::GetDirectoryName($script:outPathResolved))
|
||||
|
||||
# --- 0. Path normalization and mode dispatch ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-compile v1.172 — Compile 1C managed form from JSON or object metadata
|
||||
# form-compile v1.176 — Compile 1C managed form from JSON or object metadata
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import copy
|
||||
@@ -11,6 +11,184 @@ import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import OrderedDict
|
||||
|
||||
from lxml import etree
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# FROM-OBJECT MODE: functions for metadata parsing, presets, DSL generation
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -6033,6 +6211,7 @@ def main():
|
||||
|
||||
# --- Detect XML format version ---
|
||||
out_path_resolved = args.OutputPath if os.path.isabs(args.OutputPath) else os.path.join(os.getcwd(), args.OutputPath)
|
||||
assert_edit_allowed(out_path_resolved, "editable")
|
||||
format_version = detect_format_version(os.path.dirname(out_path_resolved))
|
||||
|
||||
# --- 0. From-object mode ---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-edit v1.1 — Edit 1C managed form elements
|
||||
# form-edit v1.6 — Edit 1C managed form elements
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -12,6 +12,137 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
# === 1. Load Form.xml ===
|
||||
|
||||
if (-not (Test-Path $FormPath)) {
|
||||
@@ -24,6 +155,7 @@ if (-not (Test-Path $JsonPath)) {
|
||||
}
|
||||
|
||||
$resolvedFormPath = (Resolve-Path $FormPath).Path
|
||||
Assert-EditAllowed $resolvedFormPath 'editable'
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
try {
|
||||
@@ -138,8 +270,11 @@ function X {
|
||||
}
|
||||
|
||||
function Esc-Xml {
|
||||
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
||||
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
||||
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
||||
param([string]$s)
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
function Emit-MLText {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-edit v1.1 — Edit 1C managed form elements (Python port)
|
||||
# form-edit v1.6 — Edit 1C managed form elements (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
@@ -11,6 +11,182 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
# ── arg parsing ──────────────────────────────────────────────
|
||||
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
@@ -50,7 +226,9 @@ def local_name(node):
|
||||
# ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||||
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
# ── 1. Load Form.xml ────────────────────────────────────────
|
||||
@@ -63,6 +241,7 @@ if not os.path.exists(json_path):
|
||||
sys.exit(1)
|
||||
|
||||
resolved_form_path = os.path.abspath(form_path)
|
||||
assert_edit_allowed(resolved_form_path, "editable")
|
||||
xml_parser = etree.XMLParser(remove_blank_text=False)
|
||||
try:
|
||||
tree = etree.parse(resolved_form_path, xml_parser)
|
||||
@@ -1298,13 +1477,39 @@ if elem_events_list:
|
||||
|
||||
# ── 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")
|
||||
# Fix XML declaration quotes
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
if not xml_bytes.endswith(b"\n"):
|
||||
# Восстановить регистр encoding как в оригинале.
|
||||
xml_bytes = xml_bytes.replace(
|
||||
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"
|
||||
# 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:
|
||||
if _fe_bom:
|
||||
f.write(b'\xef\xbb\xbf')
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-info v1.3 — Analyze 1C managed form structure
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
@@ -368,6 +368,81 @@ if ($formsIdx -ge 0 -and ($formsIdx + 1) -lt $parts.Count) {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Support status (Ext/ParentConfigurations.bin) ---
|
||||
# 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
|
||||
# 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) {
|
||||
try {
|
||||
$rp = (Resolve-Path $targetPath).Path
|
||||
$elemUuid = $null
|
||||
$binPath = $null
|
||||
# Reads the uuid of the first metadata element in an .xml file (or $null).
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||
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 $binPath) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $binPath) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return "не на поддержке" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return "снято с поддержки (правки свободны)" }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$h = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $h.Success) { return "не на поддержке" }
|
||||
$G = [int]$h.Groups[1].Value
|
||||
$K = [int]$h.Groups[2].Value
|
||||
if ($K -eq 0) { return "снято с поддержки (правки свободны)" }
|
||||
if ($G -eq 1) { return "конфигурация read-only (возможность изменения выключена) — правки невозможны без включения" }
|
||||
if (-not $elemUuid) { return "не на поддержке" }
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
$best = $null
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
if ($null -eq $best) { return "не на поддержке" }
|
||||
switch ($best) {
|
||||
0 { return "на замке — прямая правка сломает обновления; дорабатывай через cfe-* либо включи редактирование объекта" }
|
||||
1 { return "редактируется с сохранением поддержки" }
|
||||
2 { return "снято с поддержки (правки свободны)" }
|
||||
}
|
||||
return "не на поддержке"
|
||||
} catch { return "не на поддержке" }
|
||||
}
|
||||
|
||||
# --- Collect output ---
|
||||
|
||||
$lines = @()
|
||||
@@ -385,6 +460,8 @@ if ($formTitle) { $header += " — `"$formTitle`"" }
|
||||
if ($objectContext) { $header += " ($objectContext)" }
|
||||
$header += " ==="
|
||||
$lines += $header
|
||||
$support = Get-SupportStatusForPath $FormPath
|
||||
if ($null -ne $support) { $lines += "Поддержка: $support" }
|
||||
|
||||
# --- Form properties (Title excluded — shown in header) ---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-info v1.3 — Analyze 1C managed form structure
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -336,6 +336,93 @@ def build_tree(child_items_node, prefix, tree_lines, expand="", state=None):
|
||||
build_tree(ci, prefix + continuation, tree_lines, expand, state)
|
||||
|
||||
|
||||
# --- Support status (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
|
||||
# uuid of the nearest element meta-xml and the config root bin. Never throws —
|
||||
# degrades to "не на поддержке".
|
||||
def get_support_status_for_path(target_path):
|
||||
try:
|
||||
def root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
def 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)
|
||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||
elem_uuid = root_uuid(rp)
|
||||
if is_external_root(rp):
|
||||
return None
|
||||
bin_path = None
|
||||
d = os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if is_external_root(d + ".xml"):
|
||||
return None
|
||||
if not elem_uuid:
|
||||
elem_uuid = root_uuid(d + ".xml")
|
||||
if not bin_path:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
bin_path = cand
|
||||
if elem_uuid and bin_path:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return "не на поддержке"
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return "снято с поддержки (правки свободны)"
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return "не на поддержке"
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return "снято с поддержки (правки свободны)"
|
||||
if g == 1:
|
||||
return "конфигурация read-only (возможность изменения выключена) — правки невозможны без включения"
|
||||
if not elem_uuid:
|
||||
return "не на поддержке"
|
||||
best = None
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
if best is None:
|
||||
return "не на поддержке"
|
||||
return {
|
||||
0: "на замке — прямая правка сломает обновления; дорабатывай через cfe-* либо включи редактирование объекта",
|
||||
1: "редактируется с сохранением поддержки",
|
||||
2: "снято с поддержки (правки свободны)",
|
||||
}.get(best, "не на поддержке")
|
||||
except Exception:
|
||||
return "не на поддержке"
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
def main():
|
||||
@@ -441,6 +528,9 @@ def main():
|
||||
header += f" ({object_context})"
|
||||
header += " ==="
|
||||
lines.append(header)
|
||||
_support = get_support_status_for_path(form_path)
|
||||
if _support is not None:
|
||||
lines.append(f"Поддержка: {_support}")
|
||||
|
||||
# --- Form properties (Title excluded -- shown in header) ---
|
||||
prop_names = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-remove v1.2 — Remove form from 1C object
|
||||
# form-remove v1.4 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -68,10 +68,14 @@ foreach ($node in $formNodes) {
|
||||
}
|
||||
}
|
||||
|
||||
# Очистить DefaultForm если указывала на эту форму
|
||||
$defaultForm = $xmlDoc.SelectSingleNode("//md:DefaultForm", $nsMgr)
|
||||
if ($defaultForm -and $defaultForm.InnerText -match "Form\.$FormName$") {
|
||||
$defaultForm.InnerText = ""
|
||||
# Очистить любые Default*/Auxiliary* form-слоты, указывавшие на удалённую форму
|
||||
# (form-add пишет свойство по назначению: DefaultObjectForm/DefaultListForm/
|
||||
# DefaultChoiceForm/DefaultRecordForm/DefaultForm — не только generic DefaultForm).
|
||||
$formRefRe = "Form\.$([regex]::Escape($FormName))$"
|
||||
foreach ($node in $xmlDoc.SelectNodes("//md:*", $nsMgr)) {
|
||||
if ($node.LocalName -like "*Form" -and $node.InnerText -and $node.InnerText -match $formRefRe) {
|
||||
$node.InnerText = ""
|
||||
}
|
||||
}
|
||||
|
||||
# Сохранить с BOM
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# remove-form v1.1 — Remove form from 1C object
|
||||
# remove-form v1.4 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -13,13 +13,49 @@ from lxml import etree
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree to file with UTF-8 BOM."""
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -85,11 +121,15 @@ def main():
|
||||
parent.remove(node)
|
||||
break
|
||||
|
||||
# Clear DefaultForm if it pointed to removed form
|
||||
default_form = root.find(".//md:DefaultForm", NSMAP)
|
||||
if default_form is not None and default_form.text:
|
||||
if re.search(rf"Form\.{re.escape(form_name)}$", default_form.text):
|
||||
default_form.text = ""
|
||||
# Clear any Default*/Auxiliary* form slot that pointed to the removed form
|
||||
# (form-add writes the purpose-specific property: DefaultObjectForm / DefaultListForm /
|
||||
# DefaultChoiceForm / DefaultRecordForm / DefaultForm — not just generic DefaultForm).
|
||||
ref_re = re.compile(rf"Form\.{re.escape(form_name)}$")
|
||||
for el in root.iter():
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if etree.QName(el).localname.endswith("Form") and el.text and ref_re.search(el.text):
|
||||
el.text = ""
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, root_xml_full)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-validate v1.7 — Validate 1C managed form
|
||||
# form-validate v1.9 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -127,10 +127,11 @@ if ($root.LocalName -ne "Form") {
|
||||
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
||||
} else {
|
||||
$version = $root.GetAttribute("version")
|
||||
if ($version -eq "2.17" -or $version -eq "2.20") {
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
if ($version -in @("2.17", "2.18", "2.19", "2.20")) {
|
||||
Report-OK "Root element: Form version=$version"
|
||||
} elseif ($version) {
|
||||
Report-Warn "Form version='$version' (expected 2.17 or 2.20)"
|
||||
Report-Warn "Form version='$version' (expected 2.17-2.20)"
|
||||
} else {
|
||||
Report-Warn "Form version attribute missing"
|
||||
}
|
||||
@@ -382,6 +383,10 @@ if (-not $stopped) {
|
||||
$pathChecked = 0
|
||||
$pathBaseSkipped = 0
|
||||
|
||||
# All data-binding tags whose value is an attribute path (root must exist in <Attributes>).
|
||||
$bindingTags = @('DataPath','TitleDataPath','FooterDataPath','HeaderDataPath',
|
||||
'MultipleValueDataPath','MultipleValuePresentDataPath','RowPictureDataPath','MultipleValuePictureDataPath')
|
||||
|
||||
foreach ($el in $allElements) {
|
||||
if ($stopped) { break }
|
||||
$tag = $el.Tag
|
||||
@@ -398,13 +403,15 @@ if (-not $stopped) {
|
||||
try { if ([int]$el.Id -lt 1000000) { $pathBaseSkipped++; continue } } catch {}
|
||||
}
|
||||
|
||||
$dpNode = $node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||
foreach ($bTag in $bindingTags) {
|
||||
if ($stopped) { break }
|
||||
$dpNode = $node.SelectSingleNode("f:$bTag", $nsMgr)
|
||||
if (-not $dpNode) { continue }
|
||||
|
||||
$dataPath = $dpNode.InnerText.Trim()
|
||||
if (-not $dataPath) { continue }
|
||||
|
||||
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||
# Opaque platform-internal shapes — not validatable from Form.xml alone:
|
||||
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||
# - "N/M:<uuid>" — metadata reference by UUID
|
||||
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
|
||||
@@ -423,7 +430,7 @@ if (-not $stopped) {
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if ($rootAttr -eq 'Items') {
|
||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||
continue
|
||||
}
|
||||
$tableName = $segments[1]
|
||||
@@ -435,7 +442,7 @@ if (-not $stopped) {
|
||||
}
|
||||
}
|
||||
if (-not $tableEl) {
|
||||
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
||||
$pathErrors++
|
||||
continue
|
||||
}
|
||||
@@ -450,10 +457,11 @@ if (-not $stopped) {
|
||||
}
|
||||
|
||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
||||
$pathErrors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pathMsg = ""
|
||||
if ($pathChecked -gt 0) { $pathMsg = "$pathChecked paths checked" }
|
||||
@@ -462,9 +470,9 @@ if (-not $stopped) {
|
||||
$pathMsg = if ($pathMsg) { "$pathMsg, $skipNote" } else { $skipNote }
|
||||
}
|
||||
if ($pathErrors -eq 0 -and $pathMsg) {
|
||||
Report-OK "DataPath references: $pathMsg"
|
||||
Report-OK "Data bindings: $pathMsg"
|
||||
} elseif ($pathErrors -eq 0) {
|
||||
Report-OK "DataPath references: none"
|
||||
Report-OK "Data bindings: none"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-validate v1.7 — Validate 1C managed form
|
||||
# form-validate v1.9 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -161,10 +161,11 @@ def main():
|
||||
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
||||
else:
|
||||
version = root.get("version", "")
|
||||
if version in ("2.17", "2.20"):
|
||||
# Лестница версий формата: 2.17 (8.3.20-8.3.24), 2.18 (8.3.25), 2.19 (8.3.26), 2.20 (8.3.27).
|
||||
if version in ("2.17", "2.18", "2.19", "2.20"):
|
||||
report_ok(f"Root element: Form version={version}")
|
||||
elif version:
|
||||
report_warn(f"Form version='{version}' (expected 2.17 or 2.20)")
|
||||
report_warn(f"Form version='{version}' (expected 2.17-2.20)")
|
||||
else:
|
||||
report_warn("Form version attribute missing")
|
||||
|
||||
@@ -379,6 +380,10 @@ def main():
|
||||
path_checked = 0
|
||||
path_base_skipped = 0
|
||||
|
||||
# All data-binding tags whose value is an attribute path (root must exist in <Attributes>).
|
||||
binding_tags = ["DataPath", "TitleDataPath", "FooterDataPath", "HeaderDataPath",
|
||||
"MultipleValueDataPath", "MultipleValuePresentDataPath", "RowPictureDataPath", "MultipleValuePictureDataPath"]
|
||||
|
||||
skip_tags = {"ContextMenu", "ExtendedTooltip", "AutoCommandBar", "SearchStringAddition", "ViewStatusAddition", "SearchControlAddition"}
|
||||
|
||||
for el in all_elements:
|
||||
@@ -399,7 +404,10 @@ def main():
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
dp_node = node.find(f"{{{F_NS}}}DataPath")
|
||||
for b_tag in binding_tags:
|
||||
if stopped:
|
||||
break
|
||||
dp_node = node.find(f"{{{F_NS}}}{b_tag}")
|
||||
if dp_node is None:
|
||||
continue
|
||||
|
||||
@@ -407,7 +415,7 @@ def main():
|
||||
if not data_path:
|
||||
continue
|
||||
|
||||
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||
# Opaque platform-internal shapes — not validatable from Form.xml alone:
|
||||
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||
# - "N/M:<uuid>" — metadata reference by UUID
|
||||
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
|
||||
@@ -416,16 +424,16 @@ def main():
|
||||
path_checked += 1
|
||||
|
||||
clean_path = re.sub(r'\[\d+\]', '', data_path)
|
||||
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
|
||||
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
|
||||
if clean_path.startswith('~'):
|
||||
clean_path = clean_path[1:]
|
||||
segments = clean_path.split(".")
|
||||
root_attr = segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if root_attr == 'Items':
|
||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 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
|
||||
table_name = segments[1]
|
||||
table_el = None
|
||||
@@ -434,7 +442,7 @@ def main():
|
||||
table_el = candidate
|
||||
break
|
||||
if table_el is None:
|
||||
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 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
|
||||
continue
|
||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||
@@ -446,7 +454,7 @@ def main():
|
||||
root_attr = table_dp.split(".")[0]
|
||||
|
||||
if root_attr not in attr_map:
|
||||
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
|
||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
||||
path_errors += 1
|
||||
|
||||
path_msg = ""
|
||||
@@ -456,7 +464,7 @@ def main():
|
||||
skip_note = f"{path_base_skipped} base skipped"
|
||||
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
||||
if path_errors == 0 and path_msg:
|
||||
report_ok(f"DataPath references: {path_msg}")
|
||||
report_ok(f"Data bindings: {path_msg}")
|
||||
|
||||
# --- Check 6: Button command references ---
|
||||
if not stopped:
|
||||
@@ -724,7 +732,7 @@ def main():
|
||||
# ExternalDataProcessorObject/ExternalReportObject valid only in EPF/ERF context
|
||||
if is_config_context and prefix in ('ExternalDataProcessorObject', 'ExternalReportObject'):
|
||||
report_error(f'12. Type "{tv}": External* type in configuration context (use DataProcessorObject/ReportObject instead)')
|
||||
type_invalid += 1
|
||||
type_error_count += 1
|
||||
else:
|
||||
report_warn(f'12. Type "{tv}": unrecognized cfg prefix')
|
||||
type_warn_count += 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# help-add v1.4 — Add built-in help to 1C object
|
||||
# help-add v1.9 — Add built-in help to 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -13,6 +13,137 @@ $ErrorActionPreference = "Stop"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow.
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
# --- Detect format version ---
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
@@ -20,7 +151,8 @@ function Detect-FormatVersion([string]$dir) {
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
@@ -48,6 +180,8 @@ if (Test-Path $helpXmlPath) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
Assert-EditAllowed $objectDir 'editable'
|
||||
|
||||
# --- Кодировка ---
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-help v1.4 — Add built-in help to 1C object
|
||||
# add-help v1.9 — Add built-in help to 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
@@ -12,6 +13,182 @@ from lxml import etree
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
@@ -28,13 +205,49 @@ def detect_format_version(d):
|
||||
return "2.17"
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree to file with UTF-8 BOM."""
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_with_bom(tree, path):
|
||||
"""Save XML tree preserving the existing file's BOM/EOL/encoding-case/final-newline."""
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -74,6 +287,8 @@ def main():
|
||||
print(f"Справка уже существует: {help_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
assert_edit_allowed(object_dir, "editable")
|
||||
|
||||
# --- 1. Help.xml ---
|
||||
|
||||
help_xml = (
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# img-grid v1.1 — Overlay numbered grid on image
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
||||
|
||||
Usage: python overlay-grid.py <image> [-c COLS] [-r ROWS] [-o OUTPUT]
|
||||
@@ -29,6 +32,11 @@ def main():
|
||||
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cols <= 0:
|
||||
parser.error("--cols must be greater than 0")
|
||||
if args.rows < 0:
|
||||
parser.error("--rows must be greater than or equal to 0")
|
||||
|
||||
src = Image.open(args.image).convert("RGBA")
|
||||
sw, sh = src.size
|
||||
|
||||
@@ -36,7 +44,7 @@ def main():
|
||||
step_x = sw / cols
|
||||
rows = args.rows
|
||||
if rows == 0:
|
||||
rows = round(sh / step_x)
|
||||
rows = max(1, round(sh / step_x))
|
||||
step_y = sh / rows
|
||||
|
||||
# Canvas with margins for labels
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# interface-edit v1.3 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||
@@ -23,6 +23,140 @@ if (-not [System.IO.Path]::IsPathRooted($CIPath)) {
|
||||
}
|
||||
$resolvedPath = $CIPath
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
# read-only configs unless allowed. Trigger = bin present; reaction from
|
||||
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
|
||||
# throws — guard errors degrade to allow. Walk-up from Subsystems/X/Ext/
|
||||
# CommandInterface.xml reaches Subsystems/X.xml (owning subsystem uuid).
|
||||
function Get-RootUuid([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $null }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
|
||||
} catch {}
|
||||
return $null
|
||||
}
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Get-EditMode([string]$cfgDir) {
|
||||
try {
|
||||
$pj = Find-V8Project (Get-Location).Path
|
||||
if (-not $pj) { $pj = Find-V8Project $cfgDir }
|
||||
if (-not $pj) { return 'deny' }
|
||||
$proj = Get-Content -Raw $pj | ConvertFrom-Json
|
||||
$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
|
||||
if ($proj.databases) {
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($db.configSrc) {
|
||||
$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
|
||||
if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
|
||||
if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
|
||||
return 'deny'
|
||||
} catch { return 'deny' }
|
||||
}
|
||||
function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
try {
|
||||
$rp = $targetPath
|
||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if (Test-ExternalObjectRoot $rp) { return }
|
||||
$elemUuid = Get-RootUuid $rp
|
||||
$cfgDir = $null; $binPath = $null
|
||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||
if (-not $cfgDir) {
|
||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||
if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
|
||||
}
|
||||
if ($elemUuid -and $cfgDir) { break }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
# New object (no element file): fall back to config root uuid.
|
||||
if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
|
||||
if (-not $binPath -or -not (Test-Path $binPath)) { return }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($binPath)
|
||||
if ($bytes.Length -le 32) { return }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
|
||||
$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
|
||||
if (-not $hm.Success) { return }
|
||||
$G = [int]$hm.Groups[1].Value
|
||||
$K = [int]$hm.Groups[2].Value
|
||||
if ($K -eq 0) { return }
|
||||
$best = $null
|
||||
if ($elemUuid) {
|
||||
$u = [regex]::Escape($elemUuid.ToLower())
|
||||
foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
|
||||
$f1 = [int]$m.Groups[1].Value
|
||||
if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
|
||||
}
|
||||
}
|
||||
$blocked = $false; $code = ""; $reason = ""
|
||||
if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
|
||||
elseif ($require -eq 'removed') {
|
||||
if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
|
||||
}
|
||||
else {
|
||||
if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
|
||||
}
|
||||
if (-not $blocked) { return }
|
||||
$mode = Get-EditMode $cfgDir
|
||||
if ($mode -eq 'off') { return }
|
||||
# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
|
||||
# latter throws and would be swallowed by this function's own catch.
|
||||
if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
|
||||
$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if ($code -eq "capability-off") {
|
||||
$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
|
||||
$fix = "Либо снять защиту явно (навык support-edit, два шага):`n 1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n 2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
} elseif ($code -eq "not-removed") {
|
||||
$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
$fix = "Либо сначала снять объект с поддержки, затем удалять:`n support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
|
||||
} else {
|
||||
$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
|
||||
}
|
||||
[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
|
||||
exit 1
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
Assert-EditAllowed $CIPath 'editable'
|
||||
|
||||
# --- Detect format version ---
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
@@ -30,7 +164,10 @@ function Detect-FormatVersion([string]$dir) {
|
||||
while ($d) {
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$head = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8).Substring(0, [Math]::Min(2000, (Get-Item $cfgPath).Length))
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-edit v1.3 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,183 @@ import subprocess
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
|
||||
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
|
||||
# ============================================================
|
||||
|
||||
def _sg_root_uuid(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return None
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str) and child.get("uuid"):
|
||||
return child.get("uuid")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
|
||||
def _sg_get_edit_mode(cfg_dir):
|
||||
try:
|
||||
pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
|
||||
if not pj:
|
||||
return "deny"
|
||||
proj = json.loads(open(pj, encoding="utf-8-sig").read())
|
||||
cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
|
||||
for db in proj.get("databases", []):
|
||||
src = db.get("configSrc")
|
||||
if src:
|
||||
src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
|
||||
if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
|
||||
if db.get("editingAllowedCheck"):
|
||||
return db["editingAllowedCheck"]
|
||||
if proj.get("editingAllowedCheck"):
|
||||
return proj["editingAllowedCheck"]
|
||||
return "deny"
|
||||
except Exception:
|
||||
return "deny"
|
||||
|
||||
|
||||
def assert_edit_allowed(target_path, require):
|
||||
try:
|
||||
rp = os.path.abspath(target_path)
|
||||
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||
if _sg_is_external_root(rp):
|
||||
return
|
||||
elem_uuid = _sg_root_uuid(rp)
|
||||
cfg_dir = None
|
||||
bin_path = None
|
||||
d = rp if os.path.isdir(rp) else os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return
|
||||
if not elem_uuid:
|
||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||
if not cfg_dir:
|
||||
cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
|
||||
if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
|
||||
cfg_dir = d
|
||||
bin_path = cand
|
||||
if elem_uuid and cfg_dir:
|
||||
break
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
if not elem_uuid and cfg_dir:
|
||||
elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
|
||||
if not bin_path or not os.path.exists(bin_path):
|
||||
return
|
||||
data = open(bin_path, "rb").read()
|
||||
if len(data) <= 32:
|
||||
return
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
data = data[3:]
|
||||
text = data.decode("utf-8", "replace")
|
||||
h = re.match(r"\{6,(\d+),(\d+),", text)
|
||||
if not h:
|
||||
return
|
||||
g = int(h.group(1))
|
||||
k = int(h.group(2))
|
||||
if k == 0:
|
||||
return
|
||||
best = None
|
||||
if elem_uuid:
|
||||
for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
|
||||
f1 = int(m.group(1))
|
||||
if best is None or f1 < best:
|
||||
best = f1
|
||||
blocked = False
|
||||
code = ""
|
||||
reason = ""
|
||||
if g == 1:
|
||||
blocked = True
|
||||
code = "capability-off"
|
||||
reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
|
||||
elif require == "removed":
|
||||
if best is not None and best != 2:
|
||||
blocked = True
|
||||
code = "not-removed"
|
||||
reason = "объект не снят с поддержки — удаление сломает обновления"
|
||||
else:
|
||||
if best is not None and best == 0:
|
||||
blocked = True
|
||||
code = "locked"
|
||||
reason = "объект на замке — редактирование сломает обновления"
|
||||
if not blocked:
|
||||
return
|
||||
mode = _sg_get_edit_mode(cfg_dir)
|
||||
if mode == "off":
|
||||
return
|
||||
if mode == "warn":
|
||||
sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
|
||||
return
|
||||
head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
|
||||
cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
|
||||
off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
|
||||
if code == "capability-off":
|
||||
state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
|
||||
fix = (
|
||||
"Либо снять защиту явно (навык support-edit, два шага):\n"
|
||||
f' 1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
|
||||
f' 2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
|
||||
" Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
|
||||
)
|
||||
elif code == "not-removed":
|
||||
state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
|
||||
fix = (
|
||||
"Либо сначала снять объект с поддержки, затем удалять:\n"
|
||||
f' support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
|
||||
)
|
||||
else:
|
||||
state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
|
||||
fix = (
|
||||
"Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
|
||||
f' support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
|
||||
f' support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
|
||||
)
|
||||
sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
|
||||
sys.exit(1)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
@@ -110,12 +287,48 @@ def parse_value_list(val):
|
||||
return [val]
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = xml_bytes.replace(b"<?xml version='1.0' encoding='UTF-8'?>", b'<?xml version="1.0" encoding="utf-8"?>')
|
||||
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)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
|
||||
def save_xml_bom(tree, path):
|
||||
style = _detect_xml_style(path)
|
||||
xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
f.write(b"\xef\xbb\xbf")
|
||||
f.write(xml_bytes)
|
||||
|
||||
@@ -208,6 +421,8 @@ def main():
|
||||
ci_path = os.path.join(os.getcwd(), ci_path)
|
||||
resolved_path = ci_path
|
||||
|
||||
assert_edit_allowed(ci_path, "editable")
|
||||
|
||||
# --- Create if missing ---
|
||||
if not os.path.isfile(ci_path):
|
||||
if args.CreateIfMissing:
|
||||
@@ -504,7 +719,7 @@ def main():
|
||||
if os.path.isfile(validate_script):
|
||||
print()
|
||||
print("--- Running interface-validate ---")
|
||||
subprocess.run([sys.executable, validate_script, "-CIPath", "-Path", resolved_path])
|
||||
subprocess.run([sys.executable, validate_script, "-CIPath", resolved_path])
|
||||
|
||||
# --- Summary ---
|
||||
print()
|
||||
|
||||
@@ -9,18 +9,19 @@ allowed-tools:
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /meta-compile — генерация объектов метаданных из JSON DSL
|
||||
# /meta-compile — генерация объектов метаданных из JSON
|
||||
|
||||
Принимает JSON-определение объекта метаданных → генерирует XML + модули в структуре выгрузки конфигурации + регистрирует в Configuration.xml.
|
||||
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
||||
регистрирует объект в `Configuration.xml`.
|
||||
|
||||
`ConfigDumpInfo.xml` намеренно не трогается: это служебный файл версий объектов, которым управляет
|
||||
платформа (для инкрементальной выгрузки).
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. Составь JSON по синтаксису и примерам ниже → запиши во временный файл
|
||||
2. Запусти скрипт meta-compile
|
||||
3. Если нужно изменить созданный объект — `/meta-edit`
|
||||
4. Если нужно проверить — `/meta-validate`
|
||||
|
||||
## Команда
|
||||
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
||||
2. Запусти скрипт.
|
||||
3. Изменить созданный объект — `/meta-edit`; проверить — `/meta-validate`.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-compile.ps1" -JsonPath "<json>" -OutputDir "<ConfigDir>"
|
||||
@@ -28,92 +29,110 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-compile.ps1" -
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `JsonPath` | Путь к JSON-файлу (один объект `{...}` или массив `[{...}, ...]`) |
|
||||
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/` и т.д.) |
|
||||
| `JsonPath` | Путь к JSON-файлу |
|
||||
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/`, …) |
|
||||
|
||||
## JSON DSL
|
||||
## Формат JSON
|
||||
|
||||
### Общая структура
|
||||
**Один объект** `{ ... }` или **массив** объектов `[{ ... }, { ... }]` (batch — несколько объектов за прогон).
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Номенклатура", ...свойства типа... }
|
||||
{ "type": "Catalog", "name": "Номенклатура", "...свойства типа...": "..." }
|
||||
```
|
||||
|
||||
`type` и `name` — обязательные. `synonym` генерируется из `name` автоматически (CamelCase → слова через пробел). Можно задать явно: `"synonym": "Мой синоним"`.
|
||||
`type` и `name` — обязательные. Остальное — по типу (см. индекс ниже). `synonym` по умолчанию выводится из
|
||||
`name` (CamelCase → слова через пробел); можно задать явно строкой или мультиязычно: `"synonym": { "ru": "…", "en": "…" }`.
|
||||
|
||||
### Shorthand реквизитов
|
||||
## Реквизиты (shorthand)
|
||||
|
||||
Используется в `attributes`, `dimensions`, `resources`, `tabularSections`:
|
||||
Массивы `attributes`, `dimensions`, `resources` и колонки в `tabularSections` задаются строками:
|
||||
|
||||
```
|
||||
"ИмяРеквизита" → String(10) по умолчанию
|
||||
"ИмяРеквизита: Тип" → с типом
|
||||
"ИмяРеквизита: Тип | req, index" → с флагами
|
||||
"Имя" → String(10)
|
||||
"Имя: Тип" → с типом
|
||||
"Имя: Тип | req, index" → с флагами
|
||||
```
|
||||
|
||||
Типы: `String(100)`, `Number(15,2)`, `Boolean`, `Date`, `DateTime`, `CatalogRef.Xxx`, `DocumentRef.Xxx`, `EnumRef.Xxx`, `DefinedType.Xxx` и др. ссылочные.
|
||||
**Типы:** `String(100)`, `String(10, fixed)` (фикс. длина), `Number(15,2)`, `Boolean`, `Date`, `DateTime`,
|
||||
`Time`, ссылочные `CatalogRef.Xxx` / `DocumentRef.Xxx` / `EnumRef.Xxx` / `DefinedType.Xxx` и т.п.
|
||||
Составной тип — через `+`: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
||||
|
||||
Составной тип: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
||||
**Флаги** (после `|`, через запятую):
|
||||
|
||||
Флаги: `req`, `index`, `indexAdditional`, `nonneg`, `master`, `mainFilter`, `denyIncomplete`, `useInTotals`.
|
||||
| Флаг | Значение | Где |
|
||||
|------|----------|-----|
|
||||
| `req` | обязательное заполнение | attributes, dimensions, resources |
|
||||
| `index` | индексировать | attributes, dimensions |
|
||||
| `indexAdditional` | индекс с доп. упорядочиванием | attributes |
|
||||
| `multiline` | многострочное поле | attributes |
|
||||
| `nonneg` | неотрицательное (Number) | attributes, resources |
|
||||
| `master` | ведущее измерение | dimensions (регистры) |
|
||||
| `mainFilter` | основной отбор | dimensions (регистры) |
|
||||
| `denyIncomplete` | запрет незаполненных | dimensions |
|
||||
| `useInTotals` | использовать в итогах | dimensions (регистр накопления) |
|
||||
|
||||
### Свойства по типам
|
||||
Реквизиту нужны свойства сверх shorthand (значение заполнения, параметры выбора, формат, подсказка, …) —
|
||||
задаётся **объектной формой**, см. `reference/attributes.md`.
|
||||
|
||||
Примеров и shorthand-синтаксиса выше достаточно для типовых задач. Если нужны свойства типа, не показанные в примерах, и их допустимые значения — см. reference-файл:
|
||||
|
||||
- `reference/types-basic.md` — Catalog, Document, Enum, Constant, DefinedType, Report, DataProcessor
|
||||
- `reference/types-registers.md` — InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes
|
||||
- `reference/types-process.md` — BusinessProcess, Task, ExchangePlan, CommonModule, ScheduledJob, EventSubscription, DocumentJournal
|
||||
- `reference/types-web.md` — HTTPService, WebService
|
||||
|
||||
Эта инструкция и reference-файлы — полная документация для генерации. Не ищи примеры XML в выгрузках конфигураций.
|
||||
|
||||
## Примеры паттернов DSL
|
||||
|
||||
### Минимальный объект
|
||||
## Табличные части
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Валюты" }
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
||||
```
|
||||
|
||||
### С реквизитами
|
||||
Ключ — имя ТЧ, значение — массив колонок (shorthand) ЛИБО объект со свойствами ТЧ (см. `reference/attributes.md`).
|
||||
|
||||
## Индекс: свойства по типам
|
||||
|
||||
Для каждого типа — свой reference-файл со свойствами, дефолтами и допустимыми значениями:
|
||||
|
||||
| Тип(ы) | Файл |
|
||||
|--------|------|
|
||||
| Catalog (справочник) | `reference/catalog.md` |
|
||||
| Document, DocumentJournal, Sequence, DocumentNumerator | `reference/document.md` |
|
||||
| InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister | `reference/registers.md` |
|
||||
| ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes | `reference/charts.md` |
|
||||
| ExchangePlan | `reference/exchangeplan.md` |
|
||||
| BusinessProcess, Task | `reference/process.md` |
|
||||
| Report, DataProcessor | `reference/report-dataprocessor.md` |
|
||||
| CommonModule, ScheduledJob, EventSubscription | `reference/code.md` |
|
||||
| HTTPService, WebService | `reference/web.md` |
|
||||
| Enum, Constant, DefinedType | `reference/simple.md` |
|
||||
| FunctionalOption, FilterCriterion, SettingsStorage, CommonForm, CommonPicture, CommonTemplate, служебные | `reference/other-types.md` |
|
||||
|
||||
Кросс-типовые детали:
|
||||
- **`reference/attributes.md`** — объектная форма реквизита и колонки ТЧ (значение заполнения, параметры
|
||||
выбора, формат, подсказка, границы, …) + свойства самой ТЧ.
|
||||
- **`reference/blocks.md`** — блоки объекта: представления, команды (+ характеристики/стандартные реквизиты).
|
||||
|
||||
Эта инструкция и reference-файлы — полная документация. Не ищи примеры XML в выгрузках конфигураций.
|
||||
|
||||
## Примеры
|
||||
|
||||
Справочник с реквизитами:
|
||||
```json
|
||||
{
|
||||
"type": "Catalog", "name": "Организации",
|
||||
"descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"]
|
||||
}
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"] }
|
||||
```
|
||||
|
||||
### С табличной частью
|
||||
|
||||
Документ с движениями и ТЧ:
|
||||
```json
|
||||
{
|
||||
"type": "Document", "name": "ПриходнаяНакладная",
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] }
|
||||
}
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] } }
|
||||
```
|
||||
|
||||
### Регистровый паттерн (измерения + ресурсы)
|
||||
|
||||
Регистр сведений:
|
||||
```json
|
||||
{
|
||||
"type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"]
|
||||
}
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
### Batch — несколько объектов в одном файле
|
||||
|
||||
Batch:
|
||||
```json
|
||||
[
|
||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
||||
[ { "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
||||
{ "type": "Catalog", "name": "Валюты" },
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||
]
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" } ]
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Объектная форма реквизита и табличной части
|
||||
|
||||
Когда реквизиту (в `attributes` / `dimensions` / `resources` / колонках ТЧ) нужны свойства сверх
|
||||
shorthand — вместо строки задаётся объект:
|
||||
|
||||
```json
|
||||
{ "name": "Цена", "type": "Number(15,2)", "tooltip": "Цена за единицу", "fillValue": 0 }
|
||||
```
|
||||
|
||||
`name` и `type` обязательны (тип можно задать и раздельно: `"type": "Number", "length": 15, "precision": 2`).
|
||||
Остальные ключи — ниже, все со значением по умолчанию (не задавать, если устраивает дефолт).
|
||||
|
||||
## Свойства реквизита
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML (строка или `{ru,en}`) |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (то же, что флаг `req`) |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fillFromFillingValue` | `false` | bool |
|
||||
| `fillValue` | по типу (см. ниже) | значение заполнения |
|
||||
| `createOnInput` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `quickChoice` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `dataHistory` | `Use` | `Use` / `DontUse` |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (реквизит иерархического справочника) |
|
||||
| `passwordMode` | `false` | bool |
|
||||
| `multiLine` | `false` | bool (то же, что флаг `multiline`) |
|
||||
| `extendedEdit` | `false` | bool (расширенное редактирование — многострочный ввод) |
|
||||
| `mask` | пусто | строка маски ввода |
|
||||
| `format` / `editFormat` | пусто | форматная строка 1С (ML) |
|
||||
| `markNegatives` | `false` | bool (выделять отрицательные, для Number) |
|
||||
| `minValue` / `maxValue` | не задано | граница диапазона (см. ниже) |
|
||||
| `choiceParameterLinks` | пусто | связи параметров выбора (см. ниже) |
|
||||
| `choiceParameters` | пусто | параметры выбора (см. ниже) |
|
||||
| `choiceForm` | пусто | ссылка на форму выбора `Тип.Объект.Form.ИмяФормы` |
|
||||
| `choiceFoldersAndItems` | `Items` | `Items` / `Folders` / `FoldersAndItems` (что выбирать в иерарх. справочнике) |
|
||||
|
||||
Индексирование задаётся флагом `index` / `indexAdditional` в shorthand, либо в объекте — как и в строковой форме,
|
||||
через `"type": "… | index"`.
|
||||
|
||||
### `fillValue` — значение заполнения
|
||||
|
||||
Пустое значение по типу компилятор подставляет сам — ключ **не задают**:
|
||||
|
||||
| Тип реквизита | Пустое значение |
|
||||
|---------------|-----------------|
|
||||
| String | пустая строка |
|
||||
| Number | `0` |
|
||||
| Boolean, Date, ссылочный, составной | не задано (nil) |
|
||||
|
||||
Ключ `fillValue` задают для **конкретного** значения — интерпретируется по типу реквизита:
|
||||
|
||||
- **Boolean** — `true` / `false`.
|
||||
- **Number** — число (`21`, `1.5`).
|
||||
- **String** — строка.
|
||||
- **Date** — ISO-строка `"2020-01-01T00:00:00"`.
|
||||
- **Ссылочный** — путь: `"Catalog.Валюты.EmptyRef"` (пустая ссылка), `"Enum.Периодичность.EnumValue.Месяц"`
|
||||
(значение перечисления), `"Catalog.СтраныМира.Россия"` (предопределённый элемент).
|
||||
- **`null`** — явно «значение не задано» (nil), когда нужно перекрыть непустой дефолт типа.
|
||||
- **`{ "emptyRef": true }`** — пустая ссылка для реквизита типа `DefinedType.X` (когда тип из пути не выводится).
|
||||
|
||||
> Пустая ссылка (`EmptyRef`) и `null` — разное: платформа хранит их отдельно.
|
||||
|
||||
### `minValue` / `maxValue` — границы диапазона
|
||||
|
||||
Число → числовая граница; строка → строковая (напр. год `"2000"`). Без ключа — граница не задана.
|
||||
|
||||
### `choiceParameterLinks` — связи параметров выбора
|
||||
|
||||
Связывают параметр выбора этого реквизита с другим реквизитом объекта. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameterLinks": ["Отбор.Организация=Организация", "Отбор.Договор=Договор:DontChange"]
|
||||
"choiceParameterLinks": [{ "name": "Отбор.Организация", "dataPath": "Организация", "valueChange": "Clear" }]
|
||||
```
|
||||
|
||||
- `dataPath` — реквизит **того же объекта**: имя обычного реквизита (`"Организация"`) или стандартного
|
||||
(`"Владелец"`, `"Ссылка"`).
|
||||
- `valueChange` — `Clear` (по умолчанию) / `DontChange`.
|
||||
|
||||
### `choiceParameters` — параметры выбора
|
||||
|
||||
Фиксируют параметр выбора значением. Массив строк или объектов:
|
||||
|
||||
```json
|
||||
"choiceParameters": ["Отбор.ЭтоГруппа=false"]
|
||||
"choiceParameters": [{ "name": "Отбор.Владелец", "value": "Catalog.Организации.EmptyRef" }]
|
||||
```
|
||||
|
||||
- `value` — bool / число / строка / ссылочный путь (несёт тип) ИЛИ массив (список фиксированных значений).
|
||||
- Для набора голых имён-значений добавьте `type` (тип поля-фильтра), чтобы они стали ссылками:
|
||||
`{ "name": "Отбор.Тип", "type": "EnumRef.ТипыВЕТИС", "value": ["EmptyRef", "ТТН"] }`.
|
||||
|
||||
### Редкие ключи
|
||||
|
||||
`linkByType` — связь по типу (тип реквизита-Характеристики берётся из другого реквизита):
|
||||
`{ "dataPath": "Свойство", "linkItem": 0 }` или строка-путь. Применяется для реквизитов-характеристик.
|
||||
|
||||
---
|
||||
|
||||
## Табличная часть — объектная форма
|
||||
|
||||
Значение в `tabularSections` — массив колонок ЛИБО объект со свойствами самой ТЧ:
|
||||
|
||||
```json
|
||||
"tabularSections": {
|
||||
"Товары": {
|
||||
"synonym": { "ru": "Товары", "en": "Goods" },
|
||||
"tooltip": "Строки заказа",
|
||||
"fillChecking": "ShowError",
|
||||
"attributes": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (обязательность заполнения ТЧ) |
|
||||
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
||||
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
||||
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||
| `lineNumberLength` | по режиму совместимости | `5`…`9` — разрядность номера строки: `5` → до 99 999 строк, `9` → до 999 999 999. Требует формата 2.20 (платформа 8.3.27) |
|
||||
|
||||
### `lineNumber` — стандартный реквизит НомерСтроки
|
||||
|
||||
У каждой ТЧ есть стандартный реквизит НомерСтроки. По умолчанию все его свойства типовые. Ключ `lineNumber`
|
||||
на объектной форме ТЧ их переопределяет:
|
||||
|
||||
```json
|
||||
"Строки": { "lineNumber": { "synonym": "Номер п/п", "fullTextSearch": "DontUse" }, "attributes": [...] }
|
||||
```
|
||||
|
||||
Переопределяемые: `synonym`, `comment`, `fullTextSearch` (`Use`/`DontUse`), `tooltip`, `format`, `editFormat`,
|
||||
`choiceHistoryOnInput` (`Auto`/`DontUse`).
|
||||
@@ -0,0 +1,109 @@
|
||||
# Блоки объекта
|
||||
|
||||
Кросс-типовые блоки уровня объекта (применимы к ссылочным типам — Catalog, Document, ChartOf*, ExchangePlan,
|
||||
BusinessProcess, Task и др.).
|
||||
|
||||
## Представления
|
||||
|
||||
Тексты представления объекта в интерфейсе (ML — строка или `{ru,en}`, по умолчанию пусто):
|
||||
|
||||
| Ключ | Смысл |
|
||||
|------|-------|
|
||||
| `objectPresentation` | представление объекта |
|
||||
| `extendedObjectPresentation` | расширенное представление объекта |
|
||||
| `listPresentation` | представление списка |
|
||||
| `extendedListPresentation` | расширенное представление списка |
|
||||
| `explanation` | пояснение |
|
||||
|
||||
Набор доступных ключей зависит от типа (у списочных без формы объекта нет `objectPresentation` и т.п.).
|
||||
|
||||
```json
|
||||
"listPresentation": "Организации", "objectPresentation": { "ru": "Организация", "en": "Company" }
|
||||
```
|
||||
|
||||
## Команды
|
||||
|
||||
Команды объекта. Ключ — имя команды, значение — объект свойств (map `имя → объект` или массив `[{name, …}]`).
|
||||
Для каждой команды создаётся заготовка модуля с обработчиком `ОбработкаКоманды`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `synonym` | из имени | ML |
|
||||
| `tooltip` | пусто | ML |
|
||||
| `comment` | пусто | строка |
|
||||
| `group` | **обязательно** | группа размещения (см. ниже) |
|
||||
| `commandParameterType` | пусто | тип параметра (напр. `CatalogRef.Номенклатура`) — **только для групп формы** |
|
||||
| `parameterUseMode` | `Single` | `Single` / `Multiple` |
|
||||
| `modifiesData` | `false` | bool |
|
||||
| `representation` | `Auto` | вид отображения |
|
||||
| `picture` | пусто | ссылка на картинку (`StdPicture.Print`, `CommonPicture.Загрузка`) |
|
||||
| `shortcut` | пусто | сочетание клавиш |
|
||||
|
||||
```json
|
||||
"commands": {
|
||||
"ПечатьЭтикеток": { "synonym": "Печать этикеток", "group": "FormCommandBarImportant",
|
||||
"commandParameterType": "CatalogRef.Номенклатура", "picture": "StdPicture.Print" }
|
||||
}
|
||||
```
|
||||
|
||||
**Группа (`group`) обязательна** — каждая команда размещается в группе командного интерфейса:
|
||||
|
||||
- **Командный интерфейс раздела** (панель навигации / панель действий; `commandParameterType` **недоступен**):
|
||||
`NavigationPanelImportant` / `NavigationPanelOrdinary` / `NavigationPanelSeeAlso`,
|
||||
`ActionsPanelCreate` / `ActionsPanelReports` / `ActionsPanelTools`.
|
||||
- **Командный интерфейс формы** (`commandParameterType` допустим): `FormCommandBarImportant` /
|
||||
`FormCommandBarCreateBasedOn`, `FormNavigationPanelImportant` / `FormNavigationPanelGoTo` / `FormNavigationPanelSeeAlso`.
|
||||
- **Кастомная группа:** `CommandGroup.<Имя>` (параметр допустим).
|
||||
|
||||
Группа раздела вместе с `commandParameterType` → ошибка.
|
||||
|
||||
## `inputByString` / `dataLockFields` / `basedOn`
|
||||
|
||||
Списки полей/объектов уровня объекта. Поля — по имени реквизита объекта (обычного или стандартного).
|
||||
|
||||
- **`inputByString`** — поля быстрого ввода по строке. По умолчанию выводятся из Кода/Наименования — ключ не нужен;
|
||||
задать при другом наборе/порядке, либо `[]` для отключения.
|
||||
```json
|
||||
"inputByString": ["Код", "Наименование", "Контрагент"]
|
||||
```
|
||||
- **`dataLockFields`** — поля управляемой блокировки данных (по умолчанию пусто).
|
||||
```json
|
||||
"dataLockFields": ["Организация", "Контрагент"]
|
||||
```
|
||||
- **`basedOn`** — «ввод на основании»: список ссылок на объекты метаданных (по умолчанию пусто).
|
||||
```json
|
||||
"basedOn": ["Catalog.Контрагенты", "Document.ЗаказПоставщику"]
|
||||
```
|
||||
|
||||
## `standardAttributes` — кастомизация стандартных реквизитов
|
||||
|
||||
Стандартные реквизиты объекта (Наименование, Код, Владелец, …) переопределяются блоком
|
||||
`standardAttributes` — объект `{ ИмяРеквизита: { переопределения } }`. Имена — как в 1С: `Description`, `Code`,
|
||||
`Owner`, `Parent`, `DeletionMark`, `Ref` и т.д. (для Document — `Date`, `Number`, `Posted`).
|
||||
|
||||
Переопределяемые поля — как у обычного реквизита (`synonym`, `tooltip`, `fillChecking`, `fillValue`,
|
||||
`choiceParameters`, `comment`, `mask`, `choiceForm`; полный набор — `attributes.md`).
|
||||
|
||||
```json
|
||||
"standardAttributes": {
|
||||
"Description": { "synonym": "Наименование контрагента" },
|
||||
"Code": { "fillChecking": "ShowError" }
|
||||
}
|
||||
```
|
||||
|
||||
## `characteristics` — «Дополнительные реквизиты и сведения»
|
||||
|
||||
Привязка плана видов характеристик. Массив; каждый элемент связывает **источник типов** (где определены
|
||||
характеристики) и **источник значений** (где хранятся значения).
|
||||
|
||||
```json
|
||||
"characteristics": [{
|
||||
"types": { "from": "Catalog.НаборыДопРеквизитов.ДополнительныеРеквизиты",
|
||||
"key": "Свойство", "filterField": "Ссылка", "filterValue": "Справочник_Организации" },
|
||||
"values": { "from": "Catalog.Организации.TabularSection.ДополнительныеРеквизиты",
|
||||
"object": "Ссылка", "type": "Свойство", "value": "Значение" }
|
||||
}]
|
||||
```
|
||||
|
||||
- `from` — таблица-источник; `key`/`filterField`/`object`/`type`/`value` — поля источника (по имени реквизита).
|
||||
- `filterValue` — значение фильтра типов: имя предопределённого набора (строка) или путь к элементу.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Catalog (Справочник)
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||
"attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
||||
```
|
||||
|
||||
## Свойства
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `hierarchyType` | `HierarchyFoldersAndItems` | `HierarchyFoldersAndItems` / `HierarchyOfItems` |
|
||||
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||
| `owners` | `[]` | массив владельцев: `["Catalog.Контрагенты"]` |
|
||||
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||
| `codeType` | `String` | `String` / `Number` |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `codeSeries` | `WholeCatalog` | `WholeCatalog` / `WithinSubordination` / `WithinOwnerSubordination` |
|
||||
| `autonumbering` | `true` | bool (автонумерация) |
|
||||
| `checkUnique` | `false` | bool (контроль уникальности кода) |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `defaultPresentation` | `AsDescription` | `AsDescription` / `AsCode` |
|
||||
| `quickChoice` | `true` | bool (быстрый выбор) |
|
||||
| `choiceMode` | `BothWays` | `BothWays` / `QuickChoice` / `FromForm` |
|
||||
| `editType` | `InDialog` | `InDialog` / `InList` / `BothWays` |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `fullTextSearchOnInputByString` | `DontUse` | `Use` / `DontUse` |
|
||||
| `searchStringModeOnInputByString` | `Begin` | `Begin` / `AnyPart` |
|
||||
| `predefinedDataUpdate` | `Auto` | `Auto` / `DontAutoUpdate` / `AutoUpdate` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` | `[]` | реквизиты (shorthand / объектная форма) |
|
||||
| `tabularSections` | `{}` | табличные части |
|
||||
|
||||
**Формы.** Ссылка на форму — `Тип.Объект.Form.ИмяФормы` (напр. `Catalog.Организации.Form.ФормаЭлемента`).
|
||||
Слоты основных форм: `defaultObjectForm`, `defaultFolderForm`, `defaultListForm`, `defaultChoiceForm`,
|
||||
`defaultFolderChoiceForm`; вспомогательных — те же имена с префиксом `auxiliary` (`auxiliaryObjectForm`, …).
|
||||
|
||||
## `predefined` — предопределённые элементы
|
||||
|
||||
Массив предопределённых элементов → `Ext/Predefined.xml`. Элемент — строка (плоский случай) или объект (иерархия).
|
||||
|
||||
**Строка:** `"(Код) Имя [Наименование]"` — `Имя` обязательно; `(Код)` и `[Наименование]` опциональны.
|
||||
Без `[...]` наименование выводится из имени; `[]` — пустое; `[текст]` — заданное.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"Основной",
|
||||
"(1) ДокументОПриемке [Документ о приемке]",
|
||||
{ "name": "Группа1", "isFolder": true, "description": "Прочие",
|
||||
"childItems": ["Факс", "(7) Скайп"] }
|
||||
]
|
||||
```
|
||||
|
||||
**Объект:** `name` (обязательно), `code`, `description` (наименование), `isFolder` (признак группы),
|
||||
`childItems` (вложенные, рекурсивно). Тип кода — по свойству `codeType`.
|
||||
|
||||
## Дополнительно
|
||||
|
||||
- Свойства реквизитов и табличных частей — `attributes.md`.
|
||||
- Представления (`objectPresentation`, `listPresentation`, …), команды объекта, характеристики
|
||||
(«ДопРеквизиты и сведения»), кастомизация стандартных реквизитов, `inputByString` / `dataLockFields` /
|
||||
`basedOn` — `blocks.md`.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Планы: ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes
|
||||
|
||||
Все три — ссылочные типы (наследуют слой Catalog: коды, `standardAttributes`, `characteristics`, `inputByString`,
|
||||
формы, представления — см. `catalog.md` / `attributes.md` / `blocks.md`) с предопределёнными элементами и своими
|
||||
специальными свойствами.
|
||||
|
||||
## ChartOfCharacteristicTypes (План видов характеристик)
|
||||
|
||||
Хранит определения характеристик (видов). Иерархический (папки+элементы).
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | любой примитив | тип значения характеристики (составной — строка `"A + B"` или массив `valueTypes`) |
|
||||
| `characteristicExtValues` | пусто | ссылка на справочник доп. значений |
|
||||
| `hierarchical` | `false` | bool |
|
||||
| `foldersOnTop` | `true` | bool |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `codeSeries` | `WholeCharacteristicKind` | серия кодов |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды (несут тип значения — см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Предопределённые виды** несут **тип значения на элемент** — короткой строкой после `:`
|
||||
(`"(Код) Имя [Наименование]: Тип"`, составной через `+`) или объектной формой с ключом `type`:
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
"(000001) Цвет: CatalogRef.Цвета",
|
||||
"(000002) Размер [Размер одежды]: String(50) + Number(3,0)",
|
||||
{ "name": "Группа", "isFolder": true, "type": "" }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfAccounts (План счетов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `extDimensionTypes` | пусто | ссылка на ПВХ видов субконто `ChartOfCharacteristicTypes.X` |
|
||||
| `maxExtDimensionCount` | `0` (без ПВХ) / `3` (с ПВХ) | макс. число субконто |
|
||||
| `codeMask` | пусто | маска кода счёта (напр. `"@@@.@@"`) |
|
||||
| `codeLength` | `9` | длина кода |
|
||||
| `descriptionLength` | `25` | длина наименования |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `codeSeries` | `WholeChartOfAccounts` | серия кодов |
|
||||
| `defaultPresentation` | `AsCode` | `AsCode` / `AsDescription` |
|
||||
| `autoOrderByCode` | `true` | bool |
|
||||
| `orderLength` | `9` | длина строки упорядочивания |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `accountingFlags` | `[]` | признаки учёта (как реквизиты, тип по умолчанию Boolean; массив имён/реквизитов) |
|
||||
| `extDimensionAccountingFlags` | `[]` | признаки учёта субконто (как реквизиты) |
|
||||
| `predefined` | `[]` | предопределённые счета (см. ниже) |
|
||||
|
||||
**Предопределённый счёт** (объектная форма):
|
||||
|
||||
| Поле | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `name` | — | имя (обязательно) |
|
||||
| `code` | пусто | код счёта |
|
||||
| `description` | из имени | наименование |
|
||||
| `accountType` | `ActivePassive` | `Active` / `Passive` / `ActivePassive` |
|
||||
| `offBalance` | `false` | bool (забалансовый) |
|
||||
| `order` | — | строка сортировки |
|
||||
| `flags` | `[]` | включённые признаки учёта (только TRUE) |
|
||||
| `subconto` | `[]` | виды субконто (см. ниже) |
|
||||
| `childItems` | `[]` | подчинённые счета |
|
||||
|
||||
`subconto` — строка `"Вид | Признак1, Признак2"` (после `|` — включённые признаки учёта субконто; токен `Turnover` —
|
||||
«только обороты») или объект `{ type, turnover, flags }`. `Вид` — имя предопределённого вида из ПВХ `extDimensionTypes`.
|
||||
|
||||
```json
|
||||
"predefined": [
|
||||
{ "name": "ОсновныеСредства", "code": "01", "accountType": "Active", "order": " 01",
|
||||
"flags": ["Количественный"], "subconto": ["Номенклатура | Суммовой, Валютный"],
|
||||
"childItems": [ { "name": "ОСВОрганизации", "code": "01.01", "accountType": "Active", "order": " 01.01" } ] }
|
||||
]
|
||||
```
|
||||
|
||||
## ChartOfCalculationTypes (План видов расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `codeLength` | `5` | длина кода |
|
||||
| `descriptionLength` | `100` | длина наименования |
|
||||
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `dependenceOnCalculationTypes` | `DontUse` | `DontUse` / `OnPeriod` / `OnActionPeriod` |
|
||||
| `baseCalculationTypes` | `[]` | базовые виды расчёта (список ссылок `ChartOfCalculationTypes.X`) |
|
||||
| `actionPeriodUse` | `false` | bool (использовать период действия) |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `predefined` | `[]` | предопределённые виды расчёта (см. ниже) |
|
||||
|
||||
**Предопределённый вид расчёта** — плоский: строка `"(Код) Имя [Наименование]"` или объект
|
||||
`{ name, code, description, actionPeriodIsBase }` (`actionPeriodIsBase` — bool, по умолчанию `false`).
|
||||
|
||||
```json
|
||||
"predefined": [ "(00001) Оклад [Оклад по дням]", { "name": "Премия", "code": "00002", "actionPeriodIsBase": true } ]
|
||||
```
|
||||
|
||||
> **ChartOfAccounts** ссылается на ПВХ через `extDimensionTypes`. Регистр бухгалтерии/расчёта требует
|
||||
> соответствующий план (см. `registers.md`).
|
||||
@@ -0,0 +1,56 @@
|
||||
# CommonModule, ScheduledJob, EventSubscription (объекты, привязанные к коду)
|
||||
|
||||
## CommonModule (Общий модуль)
|
||||
|
||||
Флаги контекста выполнения (все bool, по умолчанию `false`). Создаёт пустой `Ext/Module.bsl`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `context` | — | шорткат флагов (см. ниже) |
|
||||
| `global` | `false` | bool |
|
||||
| `server` | `false` | bool |
|
||||
| `serverCall` | `false` | bool (вызов сервера) |
|
||||
| `clientManagedApplication` | `false` | bool (клиент управляемого приложения) |
|
||||
| `clientOrdinaryApplication` | `false` | bool (клиент обычного приложения) |
|
||||
| `externalConnection` | `false` | bool |
|
||||
| `privileged` | `false` | bool |
|
||||
| `returnValuesReuse` | `DontUse` | `DontUse` / `DuringRequest` / `DuringSession` |
|
||||
|
||||
Шорткат `context`: `"server"` → Server+ServerCall; `"client"` → ClientManagedApplication;
|
||||
`"serverClient"` → Server+ClientManagedApplication.
|
||||
|
||||
```json
|
||||
{ "type": "CommonModule", "name": "ОбменДаннымиСервер", "context": "server", "returnValuesReuse": "DuringRequest" }
|
||||
```
|
||||
|
||||
## ScheduledJob (Регламентное задание)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `methodName` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
| `description` | пусто | наименование задания |
|
||||
| `key` | пусто | ключ |
|
||||
| `use` | `false` | bool (использование) |
|
||||
| `predefined` | `false` | bool (предопределённое) |
|
||||
| `restartCountOnFailure` | `3` | число повторов при сбое |
|
||||
| `restartIntervalOnFailure` | `10` | интервал повтора, сек |
|
||||
|
||||
```json
|
||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить", "use": true }
|
||||
```
|
||||
|
||||
## EventSubscription (Подписка на событие)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `source` | `[]` | объекты-источники: `["CatalogObject.Контрагенты", "DocumentObject.Реализация"]` |
|
||||
| `event` | `BeforeWrite` | `BeforeWrite` / `OnWrite` / `BeforeDelete` / `OnReadAtServer` / `FillCheckProcessing` … |
|
||||
| `handler` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||
|
||||
```json
|
||||
{ "type": "EventSubscription", "name": "ПередЗаписьюКонтрагента",
|
||||
"source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite",
|
||||
"handler": "ОбщегоНазначенияСервер.ПередЗаписьюКонтрагента" }
|
||||
```
|
||||
|
||||
> Процедура-обработчик (`methodName` / `handler`) должна существовать в указанном общем модуле (экспортная).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Document, DocumentJournal, Sequence, DocumentNumerator
|
||||
|
||||
## Document (Документ)
|
||||
|
||||
```json
|
||||
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] } }
|
||||
```
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `numerator` | пусто | ссылка на нумератор `DocumentNumerator.X` |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / `Month` / `Quarter` / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `posting` | `Allow` | `Allow` / `Deny` (проведение) |
|
||||
| `realTimePosting` | `Deny` | `Allow` / `Deny` (оперативное проведение) |
|
||||
| `registerRecordsDeletion` | `AutoDelete` | `AutoDelete` / `AutoDeleteOnUnpost` / `AutoDeleteOff` |
|
||||
| `registerRecordsWritingOnPost` | `WriteSelected` | `WriteModified` / `WriteSelected` / `WriteAll` |
|
||||
| `sequenceFilling` | `AutoFill` | заполнение последовательностей |
|
||||
| `postInPrivilegedMode` | `true` | bool |
|
||||
| `unpostInPrivilegedMode` | `true` | bool |
|
||||
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `registerRecords` | `[]` | движения: список ссылок `["AccumulationRegister.ОстаткиТоваров", "InformationRegister.Цены"]` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Формы: `defaultObjectForm`, `defaultListForm`, `defaultChoiceForm`, `auxiliary*` (см. `catalog.md`).
|
||||
Реквизиты и ТЧ — `attributes.md`. Представления, команды, характеристики, `basedOn`, `standardAttributes`,
|
||||
`inputByString`, `dataLockFields` — `blocks.md`.
|
||||
|
||||
## DocumentJournal (Журнал документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registeredDocuments` | `[]` | документы журнала: `["Document.Встреча", "Document.Звонок"]` |
|
||||
| `columns` | `[]` | графы журнала (см. ниже) |
|
||||
|
||||
Графа — строка `"Имя"` или объект `{ name, synonym, indexing, references }`, где `indexing` — `Index`/`DontIndex`,
|
||||
`references` — пути к реквизитам документов, отображаемым в графе.
|
||||
|
||||
```json
|
||||
{ "type": "DocumentJournal", "name": "Взаимодействия",
|
||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
||||
"columns": [{ "name": "Организация", "indexing": "Index",
|
||||
"references": ["Document.Встреча.Attribute.Организация"] }] }
|
||||
```
|
||||
|
||||
## Sequence (Последовательность документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `moveBoundaryOnPosting` | `DontMove` | сдвиг границы при проведении |
|
||||
| `documents` | `[]` | документы последовательности (список ссылок) |
|
||||
| `registerRecords` | `[]` | движения (список ссылок) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` | `[]` | измерения `{name, type, documentMap[], registerRecordsMap[]}` |
|
||||
|
||||
`documentMap` / `registerRecordsMap` — пути к реквизитам документов / движениям, соответствующим измерению.
|
||||
|
||||
## DocumentNumerator (Нумератор документов)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / … / `Year` |
|
||||
| `checkUnique` | `true` | bool |
|
||||
@@ -0,0 +1,41 @@
|
||||
# ExchangePlan (План обмена)
|
||||
|
||||
Близок к справочнику (без иерархии/владельцев), плюс состав объектов обмена. Наследует слой Catalog:
|
||||
`codeLength`, `codeAllowedLength`, `descriptionLength`, `defaultPresentation`, `editType`, `quickChoice`,
|
||||
`choiceMode`, формы, `standardAttributes`, `characteristics`, `inputByString`, `basedOn`, представления —
|
||||
см. `catalog.md` / `attributes.md` / `blocks.md`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `distributedInfoBase` | `false` | bool (распределённая ИБ — РИБ) |
|
||||
| `includeConfigurationExtensions` | `false` | bool (включать расширения конфигурации) |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `content` | `[]` | состав обмена (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
## `content` — состав плана обмена
|
||||
|
||||
Список объектов-участников обмена; у каждого — признак авторегистрации изменений (по умолчанию выключена).
|
||||
Элемент — ссылка на объект метаданных (строка) или объект с признаком:
|
||||
|
||||
```json
|
||||
"content": [
|
||||
"Catalog.Организации", // авторегистрация выключена
|
||||
"InformationRegister.Курсы: autoRecord", // авторегистрация включена (токен)
|
||||
{ "metadata": "Document.РеализацияТоваров", "autoRecord": true }
|
||||
]
|
||||
```
|
||||
|
||||
- Строка `"Тип.Имя"` — авторегистрация выключена; суффикс `: autoRecord` — включена.
|
||||
- Объект: `metadata` (ссылка), `autoRecord` (bool или `Allow`/`Deny`).
|
||||
|
||||
```json
|
||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "distributedInfoBase": false,
|
||||
"content": ["Catalog.Номенклатура: autoRecord", "Catalog.Контрагенты: autoRecord"],
|
||||
"attributes": ["АдресСервера: String(200)"] }
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Прочие типы
|
||||
|
||||
Редкие/служебные объекты. Каждый — минимальный набор свойств.
|
||||
|
||||
## FunctionalOption (Функциональная опция)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `location` | пусто | где хранится значение: `Constant.X` / `InformationRegister.X.Resource.Y` / `<Тип>.X.Attribute.Y` |
|
||||
| `content` | `[]` | реквизиты/измерения/ресурсы, зависящие от опции (полные пути к объектам) |
|
||||
| `privilegedGetMode` | `true` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FunctionalOption", "name": "ВестиУчетПоСкладам", "location": "Constant.ВестиУчетПоСкладам",
|
||||
"content": ["Document.РеализацияТоваров.TabularSection.Товары.Attribute.Склад"] }
|
||||
```
|
||||
|
||||
## FilterCriterion (Критерий отбора)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | — | тип значения отбора (составной через `+`) |
|
||||
| `content` | `[]` | реквизиты, по которым идёт отбор (пути к объектам) |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "FilterCriterion", "name": "ДокументыПоКонтрагенту", "valueType": "CatalogRef.Контрагенты",
|
||||
"content": ["Document.Реализация.Attribute.Контрагент"] }
|
||||
```
|
||||
|
||||
## SettingsStorage (Хранилище настроек)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `defaultSaveForm` / `defaultLoadForm` | пусто | формы сохранения / загрузки |
|
||||
| `auxiliarySaveForm` / `auxiliaryLoadForm` | пусто | вспомогательные формы |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
## CommonForm (Общая форма)
|
||||
|
||||
Создаёт метаданные + заготовку формы. Содержимое формы наполняется `/form-compile` или `/form-edit`.
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `formType` | `Managed` | тип формы |
|
||||
| `usePurposes` | `[PlatformApplication, MobilePlatformApplication]` | назначение (массив) |
|
||||
| `useStandardCommands` | `false` | bool |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `comment` | пусто | строка |
|
||||
|
||||
```json
|
||||
{ "type": "CommonForm", "name": "НастройкиОбмена", "usePurposes": ["PlatformApplication"] }
|
||||
```
|
||||
|
||||
## CommonPicture / CommonTemplate (Общие картинки и макеты)
|
||||
|
||||
Только метаданные + регистрация; содержимое (`Ext/Picture*`, `Ext/Template.*`) импортируется отдельно
|
||||
(для табличного макета — `/mxl-compile`).
|
||||
|
||||
- **CommonPicture** — `availabilityForChoice` / `availabilityForAppearance` (bool, по умолчанию `false`).
|
||||
- **CommonTemplate** — `templateType` (`SpreadsheetDocument` по умолчанию / `TextDocument` / `HTMLDocument` /
|
||||
`BinaryData` / `AddIn` / `DataCompositionSchema` / `DataCompositionAppearanceTemplate` / `GraphicalSchema`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonTemplate", "name": "ПечатьЗаказа", "templateType": "SpreadsheetDocument" }
|
||||
```
|
||||
|
||||
## Служебные типы
|
||||
|
||||
- **SessionParameter** (параметр сеанса) — `valueType` (тип значения, составной через `+`).
|
||||
- **FunctionalOptionsParameter** (параметр функциональной опции) — `use` (массив измерений/реквизитов).
|
||||
- **WSReference** (WS-ссылка) — `locationURL` (URL WSDL).
|
||||
- **CommandGroup** (группа команд) — `category` (по умолч. `NavigationPanel`) — где размещается группа:
|
||||
`NavigationPanel` / `ActionsPanel` (командный интерфейс раздела) или `FormCommandBar` / `FormNavigationPanel`
|
||||
(командный интерфейс формы); `representation` (`Auto`), `tooltip` (ML), `picture`. Команды объекта ссылаются на
|
||||
группу через `group: "CommandGroup.<Имя>"` (см. `blocks.md`).
|
||||
- **CommonCommand** (общая команда) — `group`, `representation`, `tooltip`, `picture`, `shortcut`,
|
||||
`commandParameterType`, `parameterUseMode` (`Single`/`Multiple`), `modifiesData`, `includeHelpInContents`.
|
||||
Создаёт `Ext/CommandModule.bsl`.
|
||||
- **CommonAttribute** (общий реквизит) — `valueType` (по умолчанию `String(0)`) + свойства реквизита
|
||||
(`attributes.md`) + `content` (объекты, куда входит реквизит) + свойства разделения данных
|
||||
(`dataSeparation`, `separatedDataUse`, `usersSeparation`, … — по умолчанию `DontUse`/`Independently`).
|
||||
|
||||
```json
|
||||
{ "type": "CommonAttribute", "name": "Организация", "valueType": "CatalogRef.Организации",
|
||||
"autoUse": "Use", "content": ["Document.РеализацияТоваров", "Document.ПоступлениеТоваров"] }
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# BusinessProcess, Task (Бизнес-процессы и Задачи)
|
||||
|
||||
Ссылочные типы. Наследуют слой Catalog (нумерация, формы, `standardAttributes`, `characteristics`, `basedOn`,
|
||||
представления — см. `catalog.md` / `attributes.md` / `blocks.md`). Бизнес-процесс всегда связан с задачей.
|
||||
|
||||
## BusinessProcess (Бизнес-процесс)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `task` | пусто | ссылка на задачу `Task.X` (обязательна для рабочего БП) |
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `11` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
Создаётся с картой маршрута (`Ext/Flowchart.xml`) и модулем объекта.
|
||||
|
||||
```json
|
||||
{ "type": "BusinessProcess", "name": "Согласование", "task": "Task.ЗадачаИсполнителя",
|
||||
"attributes": ["Документ: DocumentRef.ЗаявкаНаРасход"] }
|
||||
```
|
||||
|
||||
## Task (Задача)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `numberType` | `String` | `String` / `Number` |
|
||||
| `numberLength` | `14` | длина номера |
|
||||
| `checkUnique` | `true` | bool |
|
||||
| `autonumbering` | `true` | bool |
|
||||
| `descriptionLength` | `150` | длина наименования |
|
||||
| `addressing` | пусто | ссылка на регистр сведений адресации `InformationRegister.X` |
|
||||
| `mainAddressingAttribute` | пусто | основной реквизит адресации (имя реквизита адресации) |
|
||||
| `currentPerformer` | пусто | реквизит текущего исполнителя |
|
||||
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `addressingAttributes` | `[]` | реквизиты адресации (см. ниже) |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
**Реквизит адресации** — shorthand `"Имя: Тип"` или объект `{ name, type, addressingDimension }`
|
||||
(`addressingDimension` — измерение регистра адресации).
|
||||
|
||||
```json
|
||||
{ "type": "Task", "name": "ЗадачаИсполнителя",
|
||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи", "Роль: CatalogRef.Роли"] }
|
||||
```
|
||||
@@ -0,0 +1,76 @@
|
||||
# Регистры: Information, Accumulation, Accounting, Calculation
|
||||
|
||||
**Измерения и ресурсы** задаются как реквизиты (shorthand `"Имя: Тип | флаги"` или объектная форма, см.
|
||||
`attributes.md`). Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (регистр накопления).
|
||||
|
||||
```json
|
||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
```
|
||||
|
||||
## InformationRegister (Регистр сведений)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `writeMode` | `Independent` | `Independent` / `RecorderSubordinate` |
|
||||
| `periodicity` | `Nonperiodical` | `Nonperiodical` / `Second` / `Day` / `Month` / `Quarter` / `Year` / `RecorderPosition` |
|
||||
| `mainFilterOnPeriod` | `false` | bool (основной отбор по периоду) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||
```
|
||||
|
||||
## AccumulationRegister (Регистр накопления)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `registerType` | `Balance` | `Balance` (остатки) / `Turnovers` (обороты) |
|
||||
| `enableTotalsSplitting` | `true` | bool (разделение итогов) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура", "Склад: CatalogRef.Склады"],
|
||||
"resources": ["Количество: Number(15,3)"] }
|
||||
```
|
||||
|
||||
## AccountingRegister (Регистр бухгалтерии)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfAccounts` | — | **обязательно**: ссылка на план счетов `ChartOfAccounts.X` |
|
||||
| `correspondence` | `false` | bool (корреспонденция) |
|
||||
| `periodAdjustmentLength` | `0` | длина периода корректировки |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "AccountingRegister", "name": "Хозрасчетный",
|
||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
||||
"dimensions": ["Организация: CatalogRef.Организации"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
## CalculationRegister (Регистр расчёта)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `chartOfCalculationTypes` | — | **обязательно**: ссылка на ПВР `ChartOfCalculationTypes.X` |
|
||||
| `periodicity` | `Month` | периодичность |
|
||||
| `actionPeriod` | `false` | bool (период действия) |
|
||||
| `basePeriod` | `false` | bool (базовый период) |
|
||||
| `schedule` | пусто | ссылка на регистр сведений графиков |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||
|
||||
```json
|
||||
{ "type": "CalculationRegister", "name": "Начисления",
|
||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления", "periodicity": "Month",
|
||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"], "resources": ["Сумма: Number(15,2)"] }
|
||||
```
|
||||
|
||||
> **AccountingRegister** требует план счетов, **CalculationRegister** — план видов расчёта (и оба — документ-регистратор).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Report, DataProcessor (Отчёты и Обработки)
|
||||
|
||||
Почти идентичны по составу: реквизиты, табличные части, формы, макеты, команды. Модуль объекта — `Ext/ObjectModule.bsl`.
|
||||
Реквизиты и ТЧ — `attributes.md`; команды — `blocks.md`.
|
||||
|
||||
Ссылки на формы/схемы/хранилища пишутся **как есть** (имя формы может быть буквально «Форма»).
|
||||
|
||||
## Report (Отчёт)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool (доступность через стандартный командный интерфейс) |
|
||||
| `mainDataCompositionSchema` | пусто | основной макет СКД (`Report.X.Template.ОсновнаяСхемаКомпоновкиДанных`) |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `defaultSettingsForm` / `auxiliarySettingsForm` / `defaultVariantForm` | пусто | формы настроек / вариантов |
|
||||
| `variantsStorage` / `settingsStorage` | пусто | хранилища вариантов / настроек (`SettingsStorage.X`) |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "Report", "name": "АнализПродаж", "useStandardCommands": false,
|
||||
"mainDataCompositionSchema": "Report.АнализПродаж.Template.ОсновнаяСхемаКомпоновкиДанных",
|
||||
"attributes": ["Период: StandardPeriod"] }
|
||||
```
|
||||
|
||||
## DataProcessor (Обработка)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `comment` | пусто | строка |
|
||||
| `useStandardCommands` | `true` | bool |
|
||||
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||
| `includeHelpInContents` | `false` | bool |
|
||||
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||
|
||||
```json
|
||||
{ "type": "DataProcessor", "name": "ЗагрузкаТаблиц", "useStandardCommands": false,
|
||||
"attributes": [{ "name": "Таблица", "type": "ValueTree" }, { "name": "Произвольные", "type": "" }] }
|
||||
```
|
||||
|
||||
> Реквизиты отчётов/обработок допускают платформенные типы-коллекции: `ValueTable`, `ValueTree`, `ValueList`,
|
||||
> `StandardPeriod`, `SpreadsheetDocument` и др., а также `"type": ""` — реквизит без типа.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Enum, Constant, DefinedType
|
||||
|
||||
## Enum (Перечисление)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `values` | `[]` | значения перечисления (массив имён или объектов) |
|
||||
|
||||
Значение — строка `"ИмяЗначения"` или объект `{ name, synonym }`.
|
||||
|
||||
```json
|
||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
||||
```
|
||||
|
||||
## Constant (Константа)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueType` | `String` | тип значения (shorthand типа) |
|
||||
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||
|
||||
`valueType` принимает shorthand: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`,
|
||||
составной через `+`.
|
||||
|
||||
```json
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||
```
|
||||
|
||||
## DefinedType (Определяемый тип)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `valueTypes` | `[]` | состав типа (массив shorthand-типов) |
|
||||
| `valueType` | — | то же одной строкой (`"A + B"`) или строкой одного типа |
|
||||
|
||||
```json
|
||||
{ "type": "DefinedType", "name": "ДенежныеСредства",
|
||||
"valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
||||
```
|
||||
@@ -1,116 +0,0 @@
|
||||
# Базовые типы: Catalog, Document, Enum, Constant, DefinedType, Report, DataProcessor
|
||||
|
||||
## Catalog
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `hierarchical` | `false` | Hierarchical |
|
||||
| `hierarchyType` | `HierarchyFoldersAndItems` | HierarchyType |
|
||||
| `limitLevelCount` | `false` | LimitLevelCount |
|
||||
| `levelCount` | `2` | LevelCount |
|
||||
| `foldersOnTop` | `true` | FoldersOnTop |
|
||||
| `codeLength` | `9` | CodeLength |
|
||||
| `codeType` | `String` | CodeType |
|
||||
| `codeAllowedLength` | `Variable` | CodeAllowedLength |
|
||||
| `codeSeries` | `WholeCatalog` | CodeSeries |
|
||||
| `descriptionLength` | `25` | DescriptionLength |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `checkUnique` | `false` | CheckUnique |
|
||||
| `defaultPresentation` | `AsDescription` | DefaultPresentation |
|
||||
| `subordinationUse` | `ToItems` | SubordinationUse |
|
||||
| `quickChoice` | `false` | QuickChoice |
|
||||
| `choiceMode` | `BothWays` | ChoiceMode |
|
||||
| `owners` | `[]` | Owners |
|
||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
||||
|
||||
```json
|
||||
{ "type": "Catalog", "name": "Организации", "attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
||||
```
|
||||
|
||||
## Document
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `numberType` | `String` | NumberType |
|
||||
| `numberLength` | `11` | NumberLength |
|
||||
| `numberAllowedLength` | `Variable` | NumberAllowedLength |
|
||||
| `numberPeriodicity` | `Year` | NumberPeriodicity |
|
||||
| `checkUnique` | `true` | CheckUnique |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `posting` | `Allow` | Posting |
|
||||
| `realTimePosting` | `Deny` | RealTimePosting |
|
||||
| `registerRecordsDeletion` | `AutoDelete` | RegisterRecordsDeletion |
|
||||
| `registerRecordsWritingOnPost` | `WriteModified` | RegisterRecordsWritingOnPost |
|
||||
| `postInPrivilegedMode` | `true` | PostInPrivilegedMode |
|
||||
| `unpostInPrivilegedMode` | `true` | UnpostInPrivilegedMode |
|
||||
| `registerRecords` | `[]` | RegisterRecords |
|
||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
||||
|
||||
RegisterRecords — массив строк: `"AccumulationRegister.Продажи"`, `"InformationRegister.Цены"`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Document", "name": "ПриходнаяНакладная",
|
||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
||||
}
|
||||
```
|
||||
|
||||
## Enum
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `values` | `[]` | → EnumValue в ChildObjects |
|
||||
|
||||
```json
|
||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
||||
```
|
||||
|
||||
## Constant
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `valueType` | `String` | Type |
|
||||
|
||||
`valueType` принимает shorthand типа: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`.
|
||||
|
||||
```json
|
||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||
```
|
||||
|
||||
## DefinedType
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `valueTypes` | `[]` | Type (составной тип) |
|
||||
| `valueType` | — | Алиас для `valueTypes` (строка или массив) |
|
||||
|
||||
```json
|
||||
{ "type": "DefinedType", "name": "ДенежныеСредства", "valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
||||
```
|
||||
|
||||
## Report
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
||||
|
||||
```json
|
||||
{ "type": "Report", "name": "ОстаткиТоваров" }
|
||||
```
|
||||
|
||||
## DataProcessor
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
||||
|
||||
```json
|
||||
{ "type": "DataProcessor", "name": "ЗагрузкаДанных", "attributes": ["ПутьКФайлу: String(500)"] }
|
||||
```
|
||||
@@ -1,136 +0,0 @@
|
||||
# Процессы и сервисные: BusinessProcess, Task, ExchangePlan, CommonModule, ScheduledJob, EventSubscription, DocumentJournal
|
||||
|
||||
## BusinessProcess
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `task` | `""` | Task (ссылка `Task.XXX`) |
|
||||
| `numberType` | `String` | NumberType |
|
||||
| `numberLength` | `11` | NumberLength |
|
||||
| `checkUnique` | `true` | CheckUnique |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
|
||||
Модули: `Ext/ObjectModule.bsl`, `Ext/Flowchart.xml`.
|
||||
|
||||
```json
|
||||
{ "type": "BusinessProcess", "name": "Задание", "task": "Task.ЗадачаИсполнителя", "attributes": ["Описание: String(200)"] }
|
||||
```
|
||||
|
||||
## Task
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `numberType` | `String` | NumberType |
|
||||
| `numberLength` | `14` | NumberLength |
|
||||
| `checkUnique` | `true` | CheckUnique |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `descriptionLength` | `150` | DescriptionLength |
|
||||
| `addressing` | `""` | Addressing (ссылка на РС адресации) |
|
||||
| `mainAddressingAttribute` | `""` | MainAddressingAttribute |
|
||||
| `currentPerformer` | `""` | CurrentPerformer |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
| `addressingAttributes` | `[]` | → AddressingAttribute (shorthand или объект) |
|
||||
|
||||
AddressingAttribute — shorthand `"Имя: Тип"` или объект `{ "name", "type", "addressingDimension" }`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Task", "name": "ЗадачаИсполнителя",
|
||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи"]
|
||||
}
|
||||
```
|
||||
|
||||
## ExchangePlan
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `codeLength` | `9` | CodeLength |
|
||||
| `descriptionLength` | `100` | DescriptionLength |
|
||||
| `distributedInfoBase` | `false` | DistributedInfoBase |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
|
||||
Модули: `Ext/ObjectModule.bsl`, `Ext/Content.xml`.
|
||||
|
||||
```json
|
||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "attributes": ["АдресСервера: String(200)"] }
|
||||
```
|
||||
|
||||
## CommonModule
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `context` | — | Шорткат (см. ниже) |
|
||||
| `global` | `false` | Global |
|
||||
| `server` | `false` | Server |
|
||||
| `serverCall` | `false` | ServerCall |
|
||||
| `clientManagedApplication` | `false` | ClientManagedApplication |
|
||||
| `externalConnection` | `false` | ExternalConnection |
|
||||
| `privileged` | `false` | Privileged |
|
||||
| `returnValuesReuse` | `DontUse` | ReturnValuesReuse |
|
||||
|
||||
Шорткаты `context`: `"server"` → Server+ServerCall, `"client"` → ClientManagedApplication, `"serverClient"` → Server+ClientManagedApplication.
|
||||
|
||||
```json
|
||||
{ "type": "CommonModule", "name": "ОбщиеФункции", "context": "serverClient" }
|
||||
```
|
||||
|
||||
## ScheduledJob
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `methodName` | `""` | MethodName |
|
||||
| `description` | = synonym | Description |
|
||||
| `use` | `false` | Use |
|
||||
| `predefined` | `false` | Predefined |
|
||||
| `restartCountOnFailure` | `3` | RestartCountOnFailure |
|
||||
| `restartIntervalOnFailure` | `10` | RestartIntervalOnFailure |
|
||||
|
||||
Формат `methodName`: `"МодульСервер.Процедура"` — авто-дополняется до `CommonModule.МодульСервер.Процедура`.
|
||||
|
||||
```json
|
||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить" }
|
||||
```
|
||||
|
||||
## EventSubscription
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `source` | `[]` | Source (массив, формат `XxxObject.Name`) |
|
||||
| `event` | `BeforeWrite` | Event |
|
||||
| `handler` | `""` | Handler |
|
||||
|
||||
Формат `handler`: `"МодульСервер.Процедура"` — авто-дополняется до `CommonModule.МодульСервер.Процедура`.
|
||||
|
||||
Значения `event`: `BeforeWrite`, `OnWrite`, `BeforeDelete`, `OnReadAtServer`, `FillCheckProcessing`.
|
||||
|
||||
Формат `source`: `"CatalogObject.Xxx"`, `"DocumentObject.Xxx"`.
|
||||
|
||||
```json
|
||||
{ "type": "EventSubscription", "name": "ПередЗаписью", "source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite", "handler": "ОбщиеФункции.ПередЗаписью" }
|
||||
```
|
||||
|
||||
## DocumentJournal
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `registeredDocuments` | `[]` | RegisteredDocuments (массив `"Document.Xxx"`) |
|
||||
| `columns` | `[]` | → Column |
|
||||
|
||||
Колонки — строка `"Имя"` или объект `{ "name", "synonym", "indexing": "Index"/"DontIndex", "references": ["Document.Xxx.Attribute.Yyy"] }`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "DocumentJournal", "name": "Взаимодействия",
|
||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
||||
"columns": [{ "name": "Организация", "indexing": "Index", "references": ["Document.Встреча.Attribute.Организация"] }]
|
||||
}
|
||||
```
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **ScheduledJob/EventSubscription** — процедура-обработчик должна существовать в модуле (экспортная)
|
||||
- **BusinessProcess** → `Task` (задача должна существовать)
|
||||
@@ -1,174 +0,0 @@
|
||||
# Регистры и планы: InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes
|
||||
|
||||
## Измерения и ресурсы (общее)
|
||||
|
||||
Синтаксис аналогичен реквизитам (shorthand `"Имя: Тип | флаги"`).
|
||||
|
||||
Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (AccumulationRegister only, default `true`).
|
||||
|
||||
```json
|
||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## InformationRegister
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `writeMode` | `Independent` | WriteMode |
|
||||
| `periodicity` | `Nonperiodical` | InformationRegisterPeriodicity |
|
||||
| `mainFilterOnPeriod` | авто* | MainFilterOnPeriod |
|
||||
| `dimensions` | `[]` | → Dimension |
|
||||
| `resources` | `[]` | → Resource |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
|
||||
\* `mainFilterOnPeriod` = `true` если `periodicity` != `Nonperiodical`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"]
|
||||
}
|
||||
```
|
||||
|
||||
## AccumulationRegister
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `registerType` | `Balance` | RegisterType (`Balance` / `Turnovers`) |
|
||||
| `enableTotalsSplitting` | `true` | EnableTotalsSplitting |
|
||||
| `dimensions` | `[]` | → Dimension |
|
||||
| `resources` | `[]` | → Resource |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура"],
|
||||
"resources": ["Количество: Number(15,3)"]
|
||||
}
|
||||
```
|
||||
|
||||
## AccountingRegister
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `chartOfAccounts` | `""` | ChartOfAccounts (**обязательная** ссылка на план счетов) |
|
||||
| `correspondence` | `false` | Correspondence |
|
||||
| `periodAdjustmentLength` | `0` | PeriodAdjustmentLength |
|
||||
| `dimensions` | `[]` | → Dimension |
|
||||
| `resources` | `[]` | → Resource |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AccountingRegister", "name": "Хозрасчетный",
|
||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
||||
"dimensions": ["Организация: CatalogRef.Организации"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
}
|
||||
```
|
||||
|
||||
## CalculationRegister
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `chartOfCalculationTypes` | `""` | ChartOfCalculationTypes (**обязательная** ссылка на ПВР) |
|
||||
| `periodicity` | `Month` | Periodicity |
|
||||
| `actionPeriod` | `false` | ActionPeriod |
|
||||
| `basePeriod` | `false` | BasePeriod |
|
||||
| `schedule` | `""` | Schedule (ссылка на РС графиков) |
|
||||
| `dimensions` | `[]` | → Dimension |
|
||||
| `resources` | `[]` | → Resource |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "CalculationRegister", "name": "Начисления",
|
||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления",
|
||||
"periodicity": "Month",
|
||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"],
|
||||
"resources": ["Сумма: Number(15,2)"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChartOfCharacteristicTypes
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `codeLength` | `9` | CodeLength |
|
||||
| `descriptionLength` | `25` | DescriptionLength |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `checkUnique` | `false` | CheckUnique |
|
||||
| `characteristicExtValues` | `""` | CharacteristicExtValues |
|
||||
| `valueTypes` | авто* | Type (составной тип значений характеристик) |
|
||||
| `hierarchical` | `false` | Hierarchical |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
|
||||
\* По умолчанию: Boolean, String(100), Number(15,2), DateTime.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ChartOfCharacteristicTypes", "name": "ВидыСубконто",
|
||||
"valueTypes": ["CatalogRef.Номенклатура", "CatalogRef.Контрагенты", "Boolean", "String", "Number(15,2)"]
|
||||
}
|
||||
```
|
||||
|
||||
## ChartOfAccounts
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `extDimensionTypes` | `""` | ExtDimensionTypes (ссылка на ПВХ) |
|
||||
| `maxExtDimensionCount` | `3` | MaxExtDimensionCount |
|
||||
| `codeMask` | `""` | CodeMask |
|
||||
| `codeLength` | `8` | CodeLength |
|
||||
| `descriptionLength` | `120` | DescriptionLength |
|
||||
| `codeSeries` | `WholeChartOfAccounts` | CodeSeries |
|
||||
| `autoOrderByCode` | `true` | AutoOrderByCode |
|
||||
| `orderLength` | `5` | OrderLength |
|
||||
| `hierarchical` | `false` | Hierarchical |
|
||||
| `accountingFlags` | `[]` | → AccountingFlag (Boolean-тип, массив имён) |
|
||||
| `extDimensionAccountingFlags` | `[]` | → ExtDimensionAccountingFlag (Boolean-тип, массив имён) |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ChartOfAccounts", "name": "Хозрасчетный",
|
||||
"extDimensionTypes": "ChartOfCharacteristicTypes.ВидыСубконто", "maxExtDimensionCount": 3,
|
||||
"codeLength": 8, "codeMask": "@@@.@@.@",
|
||||
"accountingFlags": ["Валютный", "Количественный"],
|
||||
"extDimensionAccountingFlags": ["Суммовой", "Валютный"]
|
||||
}
|
||||
```
|
||||
|
||||
## ChartOfCalculationTypes
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `codeLength` | `9` | CodeLength |
|
||||
| `descriptionLength` | `25` | DescriptionLength |
|
||||
| `autonumbering` | `true` | Autonumbering |
|
||||
| `checkUnique` | `false` | CheckUnique |
|
||||
| `dependenceOnCalculationTypes` | `DontUse` | DependenceOnCalculationTypes |
|
||||
| `actionPeriodUse` | `false` | ActionPeriodUse |
|
||||
| `attributes` | `[]` | → Attribute |
|
||||
| `tabularSections` | `{}` | → TabularSection |
|
||||
|
||||
`dependenceOnCalculationTypes`: `DontUse`, `OnActionPeriod`.
|
||||
|
||||
```json
|
||||
{ "type": "ChartOfCalculationTypes", "name": "Начисления", "dependenceOnCalculationTypes": "OnActionPeriod" }
|
||||
```
|
||||
|
||||
## Зависимости
|
||||
|
||||
- **AccountingRegister** требует `ChartOfAccounts` (и документ-регистратор)
|
||||
- **CalculationRegister** требует `ChartOfCalculationTypes` (и документ-регистратор)
|
||||
- **ChartOfAccounts** ссылается на `ChartOfCharacteristicTypes` через `extDimensionTypes`
|
||||
@@ -1,103 +0,0 @@
|
||||
# Веб-сервисы: HTTPService, WebService
|
||||
|
||||
## HTTPService
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `rootURL` | `= name.toLower()` | RootURL |
|
||||
| `reuseSessions` | `DontUse` | ReuseSessions |
|
||||
| `sessionMaxAge` | `20` | SessionMaxAge |
|
||||
| `urlTemplates` | `{}` | → URLTemplate |
|
||||
|
||||
Модули: `Ext/Module.bsl`.
|
||||
|
||||
### urlTemplates — вложенная структура
|
||||
|
||||
`urlTemplates` — объект `{ "TemplateName": templateDef, ... }`.
|
||||
|
||||
Каждый `templateDef`:
|
||||
- Строка — URL-шаблон: `"/v1/users"` (без методов)
|
||||
- Объект:
|
||||
|
||||
| Поле | Умолчание | Описание |
|
||||
|------|----------|----------|
|
||||
| `template` | `"/templatename"` | URL-путь (с параметрами `{id}`) |
|
||||
| `methods` | `{}` | Методы: `{ "MethodName": "HTTPMethod" }` |
|
||||
|
||||
Допустимые HTTPMethod: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||
|
||||
Обработчик метода генерируется автоматически: `{TemplateName}{MethodName}` — должен быть реализован в `Ext/Module.bsl`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "HTTPService", "name": "API", "rootURL": "api",
|
||||
"urlTemplates": {
|
||||
"Users": {
|
||||
"template": "/v1/users/{id}",
|
||||
"methods": { "Get": "GET", "Create": "POST", "Update": "PUT", "Delete": "DELETE" }
|
||||
},
|
||||
"Health": "/health"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## WebService
|
||||
|
||||
| Поле JSON | Умолчание | XML элемент |
|
||||
|-----------|----------|-------------|
|
||||
| `namespace` | `""` | Namespace (URI пространства имён WSDL) |
|
||||
| `xdtoPackages` | `""` | XDTOPackages |
|
||||
| `reuseSessions` | `DontUse` | ReuseSessions |
|
||||
| `sessionMaxAge` | `20` | SessionMaxAge |
|
||||
| `operations` | `{}` | → Operation |
|
||||
|
||||
Модули: `Ext/Module.bsl`.
|
||||
|
||||
### operations — вложенная структура
|
||||
|
||||
`operations` — объект `{ "OperationName": operationDef, ... }`.
|
||||
|
||||
Каждый `operationDef`:
|
||||
- Строка — тип возврата: `"xs:boolean"` (параметров нет, обработчик = имя операции)
|
||||
- Объект:
|
||||
|
||||
| Поле | Умолчание | Описание |
|
||||
|------|----------|----------|
|
||||
| `returnType` | `xs:string` | XDTO-тип возврата |
|
||||
| `nillable` | `false` | Может ли вернуть null |
|
||||
| `transactioned` | `false` | Выполнять в транзакции |
|
||||
| `handler` | `= operationName` | Имя процедуры в модуле |
|
||||
| `parameters` | `{}` | Параметры операции |
|
||||
|
||||
### parameters — параметры операции
|
||||
|
||||
`parameters` — объект `{ "ParamName": paramDef, ... }`.
|
||||
|
||||
Каждый `paramDef`:
|
||||
- Строка — XDTO-тип: `"xs:string"` (direction = In, nillable = true)
|
||||
- Объект:
|
||||
|
||||
| Поле | Умолчание | Описание |
|
||||
|------|----------|----------|
|
||||
| `type` | `xs:string` | XDTO-тип параметра |
|
||||
| `nillable` | `true` | Может ли быть null |
|
||||
| `direction` | `In` | Направление: `In`, `Out`, `InOut` |
|
||||
|
||||
Стандартные XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "WebService", "name": "DataExchange",
|
||||
"namespace": "http://www.1c.ru/DataExchange",
|
||||
"operations": {
|
||||
"TestConnection": {
|
||||
"returnType": "xs:boolean",
|
||||
"handler": "ПроверкаПодключения",
|
||||
"parameters": {
|
||||
"ErrorMessage": { "type": "xs:string", "direction": "Out" }
|
||||
}
|
||||
},
|
||||
"GetVersion": "xs:string"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# HTTPService, WebService (Веб-сервисы)
|
||||
|
||||
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
||||
|
||||
## HTTPService (HTTP-сервис)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||
|
||||
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||
- строка — URL-путь без методов: `"/health"`;
|
||||
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `synonym`, `comment`,
|
||||
`methods` — `{ "ИмяМетода": def }`.
|
||||
|
||||
`methods` — значение либо строка (только HTTP-метод), либо объект: `httpMethod`, `handler`,
|
||||
`synonym`, `comment`.
|
||||
|
||||
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||
Обработчик по умолчанию именуется `{ИмяШаблона}{ИмяМетода}`; в типовых конфигурациях он часто
|
||||
произвольный — тогда задавайте `handler` явно.
|
||||
|
||||
```json
|
||||
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
||||
"urlTemplates": {
|
||||
"Users": { "template": "/v1/users/{id}", "methods": { "Get": "GET", "Create": "POST", "Delete": "DELETE" } },
|
||||
"Health": "/health"
|
||||
} }
|
||||
```
|
||||
|
||||
## WebService (Веб-сервис, SOAP)
|
||||
|
||||
| Ключ | Умолчание | Значения |
|
||||
|------|-----------|----------|
|
||||
| `namespace` | пусто | URI пространства имён WSDL |
|
||||
| `xdtoPackages` | пусто | список пакетов (см. ниже) |
|
||||
| `descriptorFileName` | `= name` + `.1cws` | имя файла дескриптора |
|
||||
| `reuseSessions` | `DontUse` | `DontUse` / `Use` / `AutoUse` |
|
||||
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||
| `operations` | `{}` | операции (см. ниже) |
|
||||
|
||||
`xdtoPackages` — **массив** значений: `"XDTOPackage.Имя"` — пакет конфигурации, любое другое
|
||||
значение — URI внешнего пространства имён (например `"http://v8.1c.ru/8.3/data/ext"`).
|
||||
|
||||
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||
`procedureName` (имя процедуры, по умолчанию = имя операции; синоним ключа — `handler`),
|
||||
`dataLockControlMode` (по умолчанию `Managed`), `synonym`, `comment`, `parameters`.
|
||||
|
||||
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||
- строка — XDTO-тип (`direction` = `In`);
|
||||
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`),
|
||||
`direction` (`In` / `Out` / `InOut`), `synonym`, `comment`.
|
||||
|
||||
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||
Тип из собственного пространства имён задаётся в нотации Кларка — `"{http://ваш.uri}ИмяТипа"`;
|
||||
компилятор сам объявит локальный `xmlns` в теге, как это делает платформа.
|
||||
|
||||
```json
|
||||
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
||||
"operations": {
|
||||
"TestConnection": { "returnType": "xs:boolean", "handler": "ПроверкаПодключения",
|
||||
"parameters": { "ErrorMessage": { "type": "xs:string", "direction": "Out" } } },
|
||||
"GetVersion": "xs:string"
|
||||
} }
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: meta-decompile
|
||||
description: Декомпиляция объекта метаданных 1С в JSON-заготовку формата meta-compile. Используй когда нужно получить черновик DSL-описания нового объекта по образцу другого. Не сохраняет UUID/модули/формы.
|
||||
argument-hint: <ObjectPath> [-OutputPath <out.json>]
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Write
|
||||
- Glob
|
||||
---
|
||||
|
||||
# /meta-decompile — DSL-заготовка из XML объекта метаданных
|
||||
|
||||
Читает XML объекта метаданных (`Catalogs/Имя.xml` и т.п.) и эмитит компактный JSON в формате `/meta-compile`. Назначение — **взять существующий объект образцом и собрать по нему НОВЫЙ**: декомпилировать → поправить → скомпилировать под другим именем.
|
||||
|
||||
## ⚠️ Главное: это НЕ обратимая выгрузка
|
||||
|
||||
Компиляция черновика создаёт **новый объект с новой идентичностью**, а не копию исходного. В JSON **не** попадают: UUID (идентичность самого объекта и всех дочерних), тела модулей, формы, макеты, права. Захватываются только структура и свойства.
|
||||
|
||||
Отсюда правило: **никогда не компилируй черновик поверх объекта-источника и не выдавай его за «реимпорт»** — у пересобранного объекта другие UUID, поэтому все ссылки на исходный объект (из кода, других объектов, состава подсистем, предопределённых данных) сломаются, а код модулей и формы пропадут.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
**Собрать новый объект по образцу существующего** — получить DSL-заготовку рабочего объекта, переименовать и адаптировать состав, скомпилировать в новый. Быстрее, чем писать DSL с нуля для богатого объекта.
|
||||
|
||||
## Когда **не** использовать
|
||||
|
||||
- **Точечная правка существующего объекта** (добавить реквизит, ТЧ, свойство) → `/meta-edit`. Цикл decompile→compile тут вреден: даёт объект с новой идентичностью и теряет модули/формы.
|
||||
- **Сохранить / восстановить / перенести тот же объект** (бэкап, миграция между конфигурациями с сохранением ссылок) → штатная выгрузка 1С (`/db-dump-xml` ↔ `/db-load-xml`, CF), а не decompile.
|
||||
- **Просто понять структуру** объекта (реквизиты, ТЧ, типы) без пересборки → `/meta-info` (дешевле, не плодит файл).
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `ObjectPath` | Путь к XML объекта (`Catalogs/Имя.xml`), обязательный |
|
||||
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-decompile.ps1" -ObjectPath "<Объект.xml>" -OutputPath "<out.json>"
|
||||
```
|
||||
|
||||
Неподдерживаемый тип объекта или не-`MetaDataObject` root → ненулевой код выхода и сообщение в stderr.
|
||||
|
||||
## Workflow (сборка нового объекта по образцу)
|
||||
|
||||
1. `/meta-decompile <Образец.xml> -OutputPath draft.json` — получить заготовку.
|
||||
2. В `draft.json` **сменить `name`** на имя нового объекта и адаптировать состав (реквизиты/ТЧ/свойства). Ссылки на *другие* объекты (владельцы, ввод на основании, типы) — по имени, сохраняются как есть.
|
||||
3. `/meta-compile -JsonPath draft.json -OutputDir <ConfigDir>` — собрать (объект получит свежие UUID).
|
||||
4. `/meta-validate` + `/meta-info` — проверить.
|
||||
5. Модули, формы, макеты, права — добавить отдельно (в черновик они не попадают).
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user