mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-18 07:35:52 +03:00
Compare commits
275
Commits
@@ -1,5 +1,6 @@
|
||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.23 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||
[string]$DefinitionFile,
|
||||
@@ -10,6 +11,70 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Разбор пользовательского JSON ---
|
||||
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||
# Возврат через -NoEnumerate: без него одноэлементный
|
||||
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||
try {
|
||||
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||
$parsed = $text | ConvertFrom-Json
|
||||
} catch {
|
||||
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||
if ($Inline) {
|
||||
$got = ($text -replace '\s+', ' ').Trim()
|
||||
$label = 'got'
|
||||
if (-not $got) { $got = '(empty)' }
|
||||
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||
$what = "${what}, ${label}: ${got}"
|
||||
}
|
||||
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||
exit 1
|
||||
}
|
||||
Write-Output -NoEnumerate $parsed
|
||||
}
|
||||
|
||||
# --- Чтение входного JSON-файла ---
|
||||
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||
# угаданное имя уйдёт в метаданные так же молча.
|
||||
function Read-JsonInputFile([string]$path) {
|
||||
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||
exit 1
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
try {
|
||||
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||
} catch {
|
||||
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Mode validation ---
|
||||
@@ -163,6 +228,8 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
Assert-EditAllowed $resolvedPath 'editable'
|
||||
|
||||
# --- Load XML with PreserveWhitespace ---
|
||||
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
|
||||
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
|
||||
$script:xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$script:xmlDoc.PreserveWhitespace = $true
|
||||
$script:xmlDoc.Load($resolvedPath)
|
||||
@@ -637,10 +704,7 @@ function Do-SetPanels($valArg) {
|
||||
# Accept string (JSON), PSCustomObject, or hashtable
|
||||
$layout = $valArg
|
||||
if ($layout -is [string]) {
|
||||
try { $layout = $layout | ConvertFrom-Json } catch {
|
||||
Write-Error "set-panels value must be valid JSON object, got: $valArg"
|
||||
exit 1
|
||||
}
|
||||
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
|
||||
}
|
||||
if (-not $layout) {
|
||||
Write-Error "set-panels value is empty"
|
||||
@@ -691,7 +755,9 @@ $bodyBlock$declarations
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote panel layout: $caiPath"
|
||||
}
|
||||
@@ -822,9 +888,7 @@ $indent</Item>
|
||||
function Do-SetHomePage($valArg) {
|
||||
$layout = $valArg
|
||||
if ($layout -is [string]) {
|
||||
try { $layout = $layout | ConvertFrom-Json } catch {
|
||||
Write-Error "set-home-page value must be valid JSON object"; exit 1
|
||||
}
|
||||
$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
|
||||
}
|
||||
if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }
|
||||
|
||||
@@ -880,7 +944,9 @@ $rightXml
|
||||
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||
$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml, $utf8Bom)
|
||||
# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
|
||||
$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$script:modifyCount++
|
||||
Info "Wrote home page layout: $hpPath"
|
||||
}
|
||||
@@ -936,8 +1002,8 @@ if ($DefinitionFile) {
|
||||
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
|
||||
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
|
||||
}
|
||||
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile
|
||||
$ops = $jsonText | ConvertFrom-Json
|
||||
$jsonText = Read-JsonInputFile $DefinitionFile
|
||||
$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
|
||||
if ($ops -is [System.Array]) {
|
||||
foreach ($op in $ops) { $operations += $op }
|
||||
} else {
|
||||
@@ -982,6 +1048,14 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-edit v1.11 — Edit 1C configuration root (Configuration.xml)
|
||||
# cf-edit v1.23 — Edit 1C configuration root (Configuration.xml)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,127 @@ import uuid as _uuid
|
||||
from html import escape as html_escape
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -341,21 +462,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -379,7 +501,7 @@ def main():
|
||||
parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page"])
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
if args.DefinitionFile and args.Operation:
|
||||
print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
|
||||
@@ -761,11 +883,8 @@ def main():
|
||||
nonlocal modify_count
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
except json.JSONDecodeError:
|
||||
print(f"set-panels value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
layout = ci_json(parse_json_input(
|
||||
layout, "-Value for operation 'set-panels'", "a JSON object with panel layout", inline=True))
|
||||
if not isinstance(layout, dict) or not layout:
|
||||
print("set-panels value must be non-empty object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -916,11 +1035,8 @@ def main():
|
||||
nonlocal modify_count
|
||||
layout = value
|
||||
if isinstance(layout, str):
|
||||
try:
|
||||
layout = json.loads(layout)
|
||||
except json.JSONDecodeError:
|
||||
print("set-home-page value must be valid JSON object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
layout = ci_json(parse_json_input(
|
||||
layout, "-Value for operation 'set-home-page'", "a JSON object with home page layout", inline=True))
|
||||
if not isinstance(layout, dict) or not layout:
|
||||
print("set-home-page value must be non-empty object", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -984,8 +1100,7 @@ def main():
|
||||
def_file = args.DefinitionFile
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
||||
ops = json.loads(fh.read())
|
||||
ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
@@ -995,23 +1110,25 @@ def main():
|
||||
|
||||
for op in operations:
|
||||
op_name = op.get("operation", args.Operation or "")
|
||||
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||
op_key = str(op_name).lower()
|
||||
op_value = op.get("value", args.Value or "")
|
||||
|
||||
if op_name == "modify-property":
|
||||
if op_key == "modify-property":
|
||||
do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-childObject":
|
||||
elif op_key == "add-childobject":
|
||||
do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-childObject":
|
||||
elif op_key == "remove-childobject":
|
||||
do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "add-defaultRole":
|
||||
elif op_key == "add-defaultrole":
|
||||
do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "remove-defaultRole":
|
||||
elif op_key == "remove-defaultrole":
|
||||
do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-defaultRoles":
|
||||
elif op_key == "set-defaultroles":
|
||||
do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
|
||||
elif op_name == "set-panels":
|
||||
elif op_key == "set-panels":
|
||||
do_set_panels(op_value)
|
||||
elif op_name == "set-home-page":
|
||||
elif op_key == "set-home-page":
|
||||
do_set_home_page(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# cf-info v1.7 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||
[Parameter(Mandatory=$true, Position=0)][Alias('Path')][string]$ConfigPath,
|
||||
[ValidateSet("overview","brief","full")]
|
||||
[string]$Mode = "overview",
|
||||
[Alias('Name')]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||
# cf-info v1.7 — Compact summary of 1C configuration root
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -12,6 +12,28 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Argument parsing ---
|
||||
parser = argparse.ArgumentParser(description="Analyze 1C configuration structure", allow_abbrev=False)
|
||||
parser.add_argument("-ConfigPath", "-Path", required=True, help="Path to Configuration.xml or directory")
|
||||
@@ -20,7 +42,7 @@ parser.add_argument("-Section", "-Name", choices=["home-page"], default=None, he
|
||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||
parser.add_argument("-Offset", type=int, default=0, help="Lines to skip")
|
||||
parser.add_argument("-OutFile", default="", help="Write output to file")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Output helper (collect all, paginate at the end) ---
|
||||
lines_buf = []
|
||||
@@ -39,11 +61,11 @@ if os.path.isdir(config_path):
|
||||
if os.path.isfile(candidate):
|
||||
config_path = candidate
|
||||
else:
|
||||
print(f"[ERROR] No Configuration.xml found in directory: {config_path}", file=sys.stderr)
|
||||
print(f"[ERROR] No Configuration.xml found in directory: {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isfile(config_path):
|
||||
print(f"[ERROR] File not found: {config_path}", file=sys.stderr)
|
||||
print(f"[ERROR] File not found: {config_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Load XML ---
|
||||
@@ -60,12 +82,12 @@ NS = {
|
||||
|
||||
md_root = xml_root # root is MetaDataObject itself
|
||||
if etree.QName(md_root.tag).localname != "MetaDataObject":
|
||||
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)", file=sys.stderr)
|
||||
print("[ERROR] Not a valid 1C metadata XML file (no MetaDataObject root)")
|
||||
sys.exit(1)
|
||||
|
||||
cfg_node = md_root.find("md:Configuration", NS)
|
||||
if cfg_node is None:
|
||||
print("[ERROR] No <Configuration> element found", file=sys.stderr)
|
||||
print("[ERROR] No <Configuration> element found")
|
||||
sys.exit(1)
|
||||
|
||||
version = md_root.get("version", "")
|
||||
|
||||
@@ -22,6 +22,21 @@ allowed-tools:
|
||||
| `Version` | Версия конфигурации |
|
||||
| `Vendor` | Поставщик |
|
||||
| `CompatibilityMode` | Режим совместимости (default: `Version8_3_24`) |
|
||||
| `FormatVersion` | Версия формата выгрузки (default: `2.17`) |
|
||||
|
||||
Оба параметра задаются от **целевой платформы** — той, на которой конфигурация будет работать, — но по
|
||||
разным правилам.
|
||||
|
||||
`FormatVersion` — **не выше** версии формата целевой платформы: 8.3.24 — `2.17`, 8.3.25 — `2.18`,
|
||||
8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно: платформа читает свой формат и любой
|
||||
более старый, поэтому дефолт `2.17` подходит для всей линейки 8.3.24 и выше.
|
||||
|
||||
`CompatibilityMode` влияет на доступную функциональность. Если в задаче не оговорено иное — **равен
|
||||
целевой платформе** (`Version8_3_27` для 8.3.27); это то же самое, что «Не использовать» в
|
||||
Конфигураторе, и именно такое значение платформа проставляет новой базе. Более низкий режим берут
|
||||
осознанно — когда конфигурация должна работать и на старых платформах; он отключает возможности,
|
||||
появившиеся позже. Выше целевой платформы — нельзя: такая конфигурация загрузится, но работать на ней
|
||||
не будет.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name "МояКонфигурация"
|
||||
@@ -36,8 +51,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cf-init.ps1" -Name
|
||||
# С версией и поставщиком
|
||||
... -Name TestCfg -Synonym "Тестовая" -Version "1.0.0.1" -Vendor "Фирма 1С" -OutputDir test-tmp/cf2
|
||||
|
||||
# Другой режим совместимости
|
||||
... -Name TestCfg -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
# Под платформу 8.3.27 — версия формата и режим совместимости вместе
|
||||
... -Name TestCfg -FormatVersion 2.20 -CompatibilityMode Version8_3_27 -OutputDir test-tmp/cf3
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# cf-init v1.4 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
@@ -9,15 +10,54 @@ param(
|
||||
[string]$Vendor,
|
||||
[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")]
|
||||
# совместимости она не зависит. Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if ($CompatibilityMode -and $CompatibilityMode.ToLowerInvariant() -eq 'dontuse') {
|
||||
[Console]::Error.WriteLine("WARNING: CompatibilityMode 'DontUse' is not `"no restrictions`" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).")
|
||||
}
|
||||
|
||||
# --- Resolve output dir ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($OutputDir)) {
|
||||
$OutputDir = Join-Path (Get-Location).Path $OutputDir
|
||||
@@ -43,6 +83,11 @@ $co6 = [guid]::NewGuid().ToString()
|
||||
$co7 = [guid]::NewGuid().ToString()
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
$is221 = ($formatRank -ge 221)
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
$is218 = ($formatRank -ge 218)
|
||||
|
||||
$mobileFuncs = @(
|
||||
@("Biometrics","true"), @("Location","false"), @("BackgroundLocation","false"),
|
||||
@("BluetoothPrinters","false"), @("WiFiPrinters","false"), @("Contacts","false"),
|
||||
@@ -59,6 +104,12 @@ $mobileFuncs = @(
|
||||
@("DocumentScanning","false"), @("SpeechToText","false"), @("Geofences","false"),
|
||||
@("IncomingShareRequests","false"), @("AllIncomingShareRequestsTypesProcessing","false")
|
||||
)
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if ($is218) { $mobileFuncs += ,@("TextToSpeech","false") }
|
||||
|
||||
$mobileXml = ""
|
||||
foreach ($mf in $mobileFuncs) {
|
||||
@@ -68,17 +119,43 @@ foreach ($mf in $mobileFuncs) {
|
||||
# --- Synonym XML ---
|
||||
$synonymXml = ""
|
||||
if ($Synonym) {
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Свойства и пространство имён формата 2.21 (платформа 8.5) ---
|
||||
# Значения и ПОЗИЦИИ сняты с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники,
|
||||
# выгруженные с 8.3.27 и с 8.5.1, различаются ровно этим. Порядок важен — вставки идут
|
||||
# на своё место, а не в конец.
|
||||
$nl = "`r`n"
|
||||
$f221AuxForms = ""; $f221WindowVariant = ""; $f221OpenVariant = ""; $f221Captions = ""; $f221Migration = ""
|
||||
$palNs = ""
|
||||
if ($is221) {
|
||||
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
# Скобки вокруг -join обязательны: без них `$nl + (массив) -join $nl` разбирается как
|
||||
# `($nl + массив) -join $nl`, массив склеивается пробелами и все теги уезжают в одну строку.
|
||||
$f221AuxForms = $nl + ((@(
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"
|
||||
) | ForEach-Object { "`t`t`t$_" }) -join $nl)
|
||||
$f221WindowVariant = $nl + "`t`t`t<MainClientApplicationWindowInterfaceVariant>NavigationLeft</MainClientApplicationWindowInterfaceVariant>" +
|
||||
$nl + "`t`t`t<ClientApplicationTheme>Auto</ClientApplicationTheme>"
|
||||
$f221OpenVariant = $nl + "`t`t`t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs</ClientApplicationWindowsOpenVariant>"
|
||||
$f221Captions = $nl + "`t`t`t<Caption/>" + $nl + "`t`t`t<ShortCaption/>"
|
||||
$f221Migration = $nl + "`t`t`t<Version85InterfaceMigrationMode>DontUse</Version85InterfaceMigrationMode>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -111,7 +188,7 @@ $cfgXml = @"
|
||||
</xr:ContainedObject>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
@@ -122,8 +199,8 @@ $cfgXml = @"
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles/>
|
||||
<Vendor>$vendorXml</Vendor>
|
||||
<Version>$versionXml</Version>
|
||||
$vendorEl
|
||||
$versionEl
|
||||
<UpdateCatalogAddress/>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
@@ -145,15 +222,15 @@ $cfgXml = @"
|
||||
<DefaultDataHistoryChangeHistoryForm/>
|
||||
<DefaultDataHistoryVersionDataForm/>
|
||||
<DefaultDataHistoryVersionDifferencesForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>
|
||||
<DefaultCollaborationSystemUsersChoiceForm/>$f221AuxForms
|
||||
<RequiredMobileApplicationPermissions/>
|
||||
<UsedMobileApplicationFunctionalities>$mobileXml
|
||||
</UsedMobileApplicationFunctionalities>
|
||||
<StandaloneConfigurationRestrictionRoles/>
|
||||
<MobileApplicationURLs/>
|
||||
<AllowedIncomingShareRequestTypes/>
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||
<DefaultInterface/>
|
||||
<AllowedIncomingShareRequestTypes/>$f221WindowVariant
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>$f221OpenVariant
|
||||
<DefaultInterface/>$f221Captions
|
||||
<DefaultStyle/>
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
@@ -165,7 +242,7 @@ $cfgXml = @"
|
||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>$f221Migration
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>$CompatibilityMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
@@ -180,7 +257,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="$FormatVersion">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"$palNs xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$FormatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<Properties>
|
||||
<Name>Русский</Name>
|
||||
@@ -240,11 +317,20 @@ if (-not (Test-Path $extDir)) {
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
$caiFile = Join-Path $extDir "ClientApplicationInterface.xml"
|
||||
[System.IO.File]::WriteAllText($caiFile, $caiXml, $enc)
|
||||
Write-XmlFile $caiFile $caiXml $enc
|
||||
|
||||
# --- Output ---
|
||||
Write-Host "[OK] Создана конфигурация: $Name"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-init v1.4 — Create empty 1C configuration scaffold
|
||||
# cf-init v1.15 — Create empty 1C configuration scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, argparse, re, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -24,12 +70,32 @@ def main():
|
||||
parser.add_argument('-Version', dest='Version', default='')
|
||||
parser.add_argument('-Vendor', dest='Vendor', default='')
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
# Версия формата выгрузки (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()
|
||||
# Версия формата выгрузки (MDClasses) — её задаёт ПЛАТФОРМА, а не режим совместимости.
|
||||
# Дефолт 2.17 — нижняя граница проверенного диапазона.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
# «Не использовать» в Конфигураторе хранится как версия ТЕКУЩЕЙ платформы, а не как DontUse:
|
||||
# свежая база получает Version8_3_<своя>, и ни одна типовая в корпусе DontUse не содержит.
|
||||
# Само значение легально — платформа принимает его без ошибок, — но не выживает: замерено на
|
||||
# 8.3.25 и 8.3.27, выгрузка обоих возвращает Version8_3_8. Поэтому предупреждение, а не запрет.
|
||||
# Сравнение регистронезависимо ЯВНО: в PS -eq таков по умолчанию, в py — нет, и молчаливое
|
||||
# расхождение портов началось бы прямо здесь.
|
||||
if (args.CompatibilityMode or "").lower() == "dontuse":
|
||||
print("WARNING: CompatibilityMode 'DontUse' is not \"no restrictions\" — the platform stores it as Version8_3_8. For no compatibility restrictions use the target platform version (e.g. Version8_3_27 for 8.3.27).", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -54,6 +120,11 @@ def main():
|
||||
co = [new_uuid() for _ in range(7)]
|
||||
|
||||
# --- Mobile functionalities ---
|
||||
# Версия формата как число — по ней ниже включаются вставки 2.21.
|
||||
is_221 = format_rank_value >= 221
|
||||
# TextToSpeech приехал раньше остальных вставок 8.5 — своей ступенью, поэтому гейт отдельный.
|
||||
is_218 = format_rank_value >= 218
|
||||
|
||||
mobile_funcs = [
|
||||
("Biometrics","true"), ("Location","false"), ("BackgroundLocation","false"),
|
||||
("BluetoothPrinters","false"), ("WiFiPrinters","false"), ("Contacts","false"),
|
||||
@@ -70,6 +141,13 @@ def main():
|
||||
("DocumentScanning","false"), ("SpeechToText","false"), ("Geofences","false"),
|
||||
("IncomingShareRequests","false"), ("AllIncomingShareRequestsTypesProcessing","false"),
|
||||
]
|
||||
# TextToSpeech — возможность мобильного приложения, добавленная форматом 2.18 (8.3.25),
|
||||
# последней в списке; в 2.21 список не менялся. Замерено выгрузками пустой ИБ шести платформ:
|
||||
# 2.13/2.17 — 37 записей без неё, 2.18-2.21 — 38 с ней. Гейт обязателен и в обе стороны:
|
||||
# на 2.17 тег ломает загрузку XDTO-ошибкой (проверено на 8.3.24), без тега на 2.18+ платформа
|
||||
# подставит дефолт false и допишет его при выгрузке — то есть разойдётся роундтрип.
|
||||
if is_218:
|
||||
mobile_funcs.append(("TextToSpeech", "false"))
|
||||
|
||||
mobile_xml = ""
|
||||
for func_name, func_use in mobile_funcs:
|
||||
@@ -78,10 +156,12 @@ def main():
|
||||
# --- Synonym XML ---
|
||||
synonym_xml = ""
|
||||
if synonym:
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
||||
version_xml = esc_xml(version) if version else ""
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
class_ids = [
|
||||
"9cd510cd-abfc-11d4-9434-004095e12fc7",
|
||||
@@ -93,6 +173,28 @@ def main():
|
||||
"fb282519-d103-4dd3-bc12-cb271d631dfc",
|
||||
]
|
||||
|
||||
# Свойства и пространство имён формата 2.21 (платформа 8.5). Значения и ПОЗИЦИИ сняты
|
||||
# с выгрузки 8.5.1 (debug/fmt221/dump_v851): те же исходники, выгруженные с 8.3.27 и
|
||||
# с 8.5.1, различаются ровно этим. Порядок важен — вставки идут на своё место.
|
||||
pal_ns = ""
|
||||
f221_aux_forms = f221_window_variant = f221_open_variant = f221_captions = f221_migration = ""
|
||||
if is_221:
|
||||
pal_ns = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
f221_aux_forms = "\r\n" + "\r\n".join(
|
||||
f"\t\t\t{t}" for t in (
|
||||
"<AuxiliaryReportForm/>", "<AuxiliaryReportVariantForm/>", "<AuxiliaryReportSettingsForm/>",
|
||||
"<AuxiliaryDynamicListSettingsForm/>", "<AuxiliaryDataHistoryChangeHistoryForm/>",
|
||||
"<AuxiliaryDataHistoryVersionDataForm/>", "<AuxiliaryDataHistoryVersionDifferencesForm/>",
|
||||
"<AuxiliaryCollaborationSystemUsersChoiceForm/>"))
|
||||
f221_window_variant = ("\r\n\t\t\t<MainClientApplicationWindowInterfaceVariant>NavigationLeft"
|
||||
"</MainClientApplicationWindowInterfaceVariant>"
|
||||
"\r\n\t\t\t<ClientApplicationTheme>Auto</ClientApplicationTheme>")
|
||||
f221_open_variant = ("\r\n\t\t\t<ClientApplicationWindowsOpenVariant>OpenDataInDialogs"
|
||||
"</ClientApplicationWindowsOpenVariant>")
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
f221_migration = ("\r\n\t\t\t<Version85InterfaceMigrationMode>DontUse"
|
||||
"</Version85InterfaceMigrationMode>")
|
||||
|
||||
contained_objects = ""
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
@@ -101,12 +203,12 @@ def main():
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
@@ -117,8 +219,8 @@ def main():
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles/>
|
||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
||||
\t\t\t<Version>{version_xml}</Version>
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}
|
||||
\t\t\t<UpdateCatalogAddress/>
|
||||
\t\t\t<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
\t\t\t<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
@@ -140,15 +242,15 @@ def main():
|
||||
\t\t\t<DefaultDataHistoryChangeHistoryForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDataForm/>
|
||||
\t\t\t<DefaultDataHistoryVersionDifferencesForm/>
|
||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>
|
||||
\t\t\t<DefaultCollaborationSystemUsersChoiceForm/>{f221_aux_forms}
|
||||
\t\t\t<RequiredMobileApplicationPermissions/>
|
||||
\t\t\t<UsedMobileApplicationFunctionalities>{mobile_xml}
|
||||
\t\t\t</UsedMobileApplicationFunctionalities>
|
||||
\t\t\t<StandaloneConfigurationRestrictionRoles/>
|
||||
\t\t\t<MobileApplicationURLs/>
|
||||
\t\t\t<AllowedIncomingShareRequestTypes/>
|
||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||
\t\t\t<DefaultInterface/>
|
||||
\t\t\t<AllowedIncomingShareRequestTypes/>{f221_window_variant}
|
||||
\t\t\t<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>{f221_open_variant}
|
||||
\t\t\t<DefaultInterface/>{f221_captions}
|
||||
\t\t\t<DefaultStyle/>
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
@@ -160,7 +262,7 @@ def main():
|
||||
\t\t\t<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
\t\t\t<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>{f221_migration}
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>{compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
@@ -173,7 +275,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"{pal_ns} xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{args.FormatVersion}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>Русский</Name>
|
||||
@@ -222,11 +324,11 @@ def main():
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_utf8_bom(cfg_file, cfg_xml)
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_utf8_bom(lang_file, lang_xml)
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
cai_file = os.path.join(ext_dir, "ClientApplicationInterface.xml")
|
||||
write_utf8_bom(cai_file, cai_xml)
|
||||
write_xml_file(cai_file, cai_xml)
|
||||
|
||||
print(f"[OK] Создана конфигурация: {name}")
|
||||
print(f" Каталог: {output_dir}")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# cf-validate v1.5 — Validate 1C configuration root structure
|
||||
# cf-validate v1.8 — Validate 1C configuration root structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ConfigPath,
|
||||
|
||||
@@ -89,6 +90,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||
@@ -203,11 +217,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -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)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cf-validate v1.5 — Validate 1C configuration XML structure
|
||||
# cf-validate v1.8 — Validate 1C configuration XML structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -110,6 +132,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -170,7 +206,7 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
config_path = args.ConfigPath
|
||||
max_errors = args.MaxErrors
|
||||
@@ -230,11 +266,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.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)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
|
||||
@@ -31,6 +31,7 @@ allowed-tools:
|
||||
| `ExtensionPath` | Путь к каталогу расширения (обязат.) |
|
||||
| `ConfigPath` | Путь к конфигурации-источнику (обязат.) |
|
||||
| `Object` | Что заимствовать (обязат.), batch через `;;` |
|
||||
| `Module` | Создать пустые модули объекта: `ObjectModule`, `ManagerModule`, `RecordSetModule`, `ValueManagerModule` (через запятую) или `None`. У типов с единственным модулем (`CommonModule`, `HTTPService`, `WebService`) он создаётся и без параметра |
|
||||
| `BorrowMainAttribute` | Заимствовать основной реквизит формы. Без параметра — не заимствует. `Form` — реквизиты, используемые на форме. `All` — все реквизиты объекта. Требует форму в -Object |
|
||||
|
||||
## Формат -Object
|
||||
@@ -41,7 +42,6 @@ allowed-tools:
|
||||
- `Enum.ВидыОплат` — перечисление
|
||||
- `Catalog.Контрагенты.Form.ФормаЭлемента` — форма объекта (заимствование формы)
|
||||
- `Catalog.X ;; CommonModule.Y ;; Enum.Z` — несколько объектов
|
||||
Поддерживаются все 44 типа объектов конфигурации.
|
||||
|
||||
### Заимствование форм
|
||||
|
||||
@@ -66,36 +66,44 @@ allowed-tools:
|
||||
2. `/meta-edit` — добавить новый реквизит в объект расширения
|
||||
3. `/form-edit` — вывести реквизит на заимствованную форму
|
||||
|
||||
**Защита существующих данных**: если зависимый объект уже заимствован с содержимым (реквизитами, формами) — скрипт не перезаписывает его, а добавляет только недостающее.
|
||||
**Защита существующих данных**: уже заимствованный объект не перезаписывается — добавляется только недостающее. Повторный вызов безопасен: собственные реквизиты расширения, заимствованные подобъекты и код в модулях сохраняются.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-borrow.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Заимствовать один объект
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты"
|
||||
|
||||
# Заимствовать справочник вместе с модулями объекта и менеджера
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты" -Module ObjectModule,ManagerModule
|
||||
|
||||
# Общий модуль без файла модуля
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "CommonModule.РаботаСФайлами" -Module None
|
||||
|
||||
# Заимствовать форму (автоматически заимствует родительский объект)
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты.Form.ФормаЭлемента"
|
||||
|
||||
# Несколько объектов за раз
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Контрагенты ;; CommonModule.ОбщийМодуль ;; Enum.ВидыОплат"
|
||||
|
||||
# Заимствовать форму с основным реквизитом (реквизиты по DataPath формы)
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute
|
||||
|
||||
# Заимствовать форму с ВСЕМИ реквизитами объекта
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Object "Catalog.Номенклатура.Form.ФормаЭлемента" -BorrowMainAttribute All
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
Конфигурацию-источник передавай и валидатору: заимствованные формы он проверяет по ней.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
```
|
||||
|
||||
## Mode A — обзор расширения
|
||||
@@ -50,8 +50,8 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-diff.ps1" -Exte
|
||||
|
||||
```powershell
|
||||
# Обзор — что изменено в расширении
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode A
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode A
|
||||
|
||||
# Проверка переноса — все ли #Вставка перенесены
|
||||
... -ExtensionPath src -ConfigPath C:\cfsrc\erp -Mode B
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Mode B
|
||||
```
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[string]$ExtensionPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
@@ -49,7 +50,9 @@ $childTypeDirMap = @{
|
||||
"SettingsStorage"="SettingsStorages"; "FilterCriterion"="FilterCriteria"
|
||||
"CommandGroup"="CommandGroups"; "DocumentNumerator"="DocumentNumerators"
|
||||
"Sequence"="Sequences"; "IntegrationService"="IntegrationServices"
|
||||
"CommonAttribute"="CommonAttributes"
|
||||
"CommonAttribute"="CommonAttributes"; "Style"="Styles"; "XDTOPackage"="XDTOPackages"
|
||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||
"Bot"="Bots"
|
||||
}
|
||||
|
||||
# --- Parse extension Configuration.xml ---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-diff v1.0 — Analyze and compare 1C configuration extension (CFE)
|
||||
# cfe-diff v1.3 — Analyze and compare 1C configuration extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ import re
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Namespace maps ---
|
||||
|
||||
MD_NSMAP = {
|
||||
@@ -60,6 +82,12 @@ CHILD_TYPE_DIR_MAP = {
|
||||
"Sequence": "Sequences",
|
||||
"IntegrationService": "IntegrationServices",
|
||||
"CommonAttribute": "CommonAttributes",
|
||||
"Style": "Styles",
|
||||
"XDTOPackage": "XDTOPackages",
|
||||
"WebService": "WebServices",
|
||||
"HTTPService": "HTTPServices",
|
||||
"WSReference": "WSReferences",
|
||||
"Bot": "Bots",
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +496,7 @@ def main():
|
||||
parser.add_argument("-ExtensionPath", required=True, help="Path to extension dump root")
|
||||
parser.add_argument("-ConfigPath", required=True, help="Path to base config dump root")
|
||||
parser.add_argument("-Mode", choices=["A", "B"], default="A", help="A=overview, B=transfer check")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
config_path = args.ConfigPath
|
||||
|
||||
@@ -33,39 +33,39 @@ allowed-tools:
|
||||
| `Name` | Имя расширения (обязат.) | — |
|
||||
| `Synonym` | Синоним | = Name |
|
||||
| `NamePrefix` | Префикс собственных объектов | = Name + "_" |
|
||||
| `OutputDir` | Каталог для создания | `src` |
|
||||
| `OutputDir` | Каталог для создания; клади расширение в свой подкаталог, названный по имени: `src\cfe\<Name>` | `src` |
|
||||
| `Purpose` | `Patch` (исправление) / `Customization` (доработка) / `AddOn` (дополнение) | `Customization` |
|
||||
| `Version` | Версия расширения | — |
|
||||
| `Vendor` | Поставщик | — |
|
||||
| `CompatibilityMode` | Режим совместимости | `Version8_3_24` |
|
||||
| `CompatibilityMode` | Режим совместимости; при заданном `ConfigPath` определяется по базовой конфигурации и этот параметр не нужен | `Version8_3_24` |
|
||||
| `ConfigPath` | Путь к выгрузке базовой конфигурации (авто-определяет CompatibilityMode и Language UUID) | — |
|
||||
| `NoRole` | Без основной роли | false |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-init.ps1" -Name "МоёРасширение" -OutputDir "src\cfe\МоёРасширение" -ConfigPath "src\cf"
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Расширение для ERP с авто-определением совместимости из базовой конфигурации
|
||||
... -Name Расш1 -ConfigPath C:\WS\tasks\cfsrc\erp_8.3.24 -OutputDir src
|
||||
... -Name Расш1 -ConfigPath src\cf -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-исправление с явным режимом совместимости
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src
|
||||
... -Name Расш1 -Purpose Patch -CompatibilityMode Version8_3_17 -OutputDir src\cfe\Расш1
|
||||
|
||||
# Расширение-доработка с версией
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src
|
||||
... -Name МоёРасширение -Version "1.0.0.1" -Vendor "Компания" -OutputDir src\cfe\МоёРасширение
|
||||
|
||||
# Без роли, с явным префиксом
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src
|
||||
... -Name ИсправлениеБага -NamePrefix "ИБ_" -Purpose Patch -NoRole -OutputDir src\cfe\ИсправлениеБага
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <OutputDir>
|
||||
/cfe-validate <OutputDir> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Name,
|
||||
@@ -16,6 +17,12 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Default NamePrefix ---
|
||||
@@ -121,20 +128,23 @@ $co7 = [guid]::NewGuid().ToString()
|
||||
# --- Synonym XML ---
|
||||
$synonymXml = ""
|
||||
if ($Synonym) {
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$([System.Security.SecurityElement]::Escape($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
$synonymXml = "`r`n`t`t`t`t<v8:item>`r`n`t`t`t`t`t<v8:lang>ru</v8:lang>`r`n`t`t`t`t`t<v8:content>$(Esc-XmlText ($Synonym))</v8:content>`r`n`t`t`t`t</v8:item>`r`n`t`t`t"
|
||||
}
|
||||
|
||||
# --- Optional properties ---
|
||||
$vendorXml = if ($Vendor) { [System.Security.SecurityElement]::Escape($Vendor) } else { "" }
|
||||
$versionXml = if ($Version) { [System.Security.SecurityElement]::Escape($Version) } else { "" }
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
$vendorEl = if ($Vendor) { "<Vendor>$(Esc-XmlText ($Vendor))</Vendor>" } else { "<Vendor/>" }
|
||||
$versionEl = if ($Version) { "<Version>$(Esc-XmlText ($Version))</Version>" } else { "<Version/>" }
|
||||
|
||||
# --- Role name ---
|
||||
$roleName = "${NamePrefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
$defaultRolesXml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
$defaultRolesEl = "<DefaultRoles/>"
|
||||
if (-not $NoRole) {
|
||||
$defaultRolesXml = "`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t"
|
||||
$defaultRolesEl = "<DefaultRoles>`r`n`t`t`t`t<xr:Item xsi:type=`"xr:MDObjectRef`">Role.$roleName</xr:Item>`r`n`t`t`t</DefaultRoles>"
|
||||
}
|
||||
|
||||
# --- ChildObjects ---
|
||||
@@ -144,10 +154,32 @@ if (-not $NoRole) {
|
||||
}
|
||||
$childObjectsXml += "`r`n`t`t"
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
$f221Captions = ""
|
||||
if ((Get-FormatRank $formatVersion) -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$f221Captions = "`r`n`t`t`t<Caption/>`r`n`t`t`t<ShortCaption/>"
|
||||
}
|
||||
|
||||
# --- Configuration.xml ---
|
||||
$cfgXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Configuration uuid="$uuidCfg">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -181,21 +213,21 @@ $cfgXml = @"
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($Name))</Name>
|
||||
<Name>$(Esc-XmlText ($Name))</Name>
|
||||
<Synonym>$synonymXml</Synonym>
|
||||
<Comment/>
|
||||
<ConfigurationExtensionPurpose>$Purpose</ConfigurationExtensionPurpose>
|
||||
<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
<NamePrefix>$([System.Security.SecurityElement]::Escape($NamePrefix))</NamePrefix>
|
||||
<NamePrefix>$(Esc-XmlText ($NamePrefix))</NamePrefix>
|
||||
<ConfigurationExtensionCompatibilityMode>$CompatibilityMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles>$defaultRolesXml</DefaultRoles>
|
||||
<Vendor>$vendorXml</Vendor>
|
||||
<Version>$versionXml</Version>
|
||||
$defaultRolesEl
|
||||
$vendorEl
|
||||
$versionEl$f221Captions
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation/>
|
||||
<DetailedInformation/>
|
||||
@@ -212,7 +244,7 @@ $cfgXml = @"
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
$langXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Language uuid="$uuidLang">
|
||||
<InternalInfo/>
|
||||
<Properties>
|
||||
@@ -229,10 +261,10 @@ $langXml = @"
|
||||
# --- Role XML ---
|
||||
$roleXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$formatVersion">
|
||||
<MetaDataObject $xmlnsDecl version="$formatVersion">
|
||||
<Role uuid="$uuidRole">
|
||||
<Properties>
|
||||
<Name>$([System.Security.SecurityElement]::Escape($roleName))</Name>
|
||||
<Name>$(Esc-XmlText ($roleName))</Name>
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
</Properties>
|
||||
@@ -252,9 +284,18 @@ if (-not (Test-Path $langDir)) {
|
||||
# --- Write files with UTF-8 BOM ---
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
|
||||
[System.IO.File]::WriteAllText($cfgFile, $cfgXml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $cfgFile $cfgXml $enc
|
||||
$langFile = Join-Path $langDir "Русский.xml"
|
||||
[System.IO.File]::WriteAllText($langFile, $langXml, $enc)
|
||||
Write-XmlFile $langFile $langXml $enc
|
||||
|
||||
# --- Role ---
|
||||
if (-not $NoRole) {
|
||||
@@ -263,7 +304,7 @@ if (-not $NoRole) {
|
||||
New-Item -ItemType Directory -Path $roleDir -Force | Out-Null
|
||||
}
|
||||
$roleFile = Join-Path $roleDir "$roleName.xml"
|
||||
[System.IO.File]::WriteAllText($roleFile, $roleXml, $enc)
|
||||
Write-XmlFile $roleFile $roleXml $enc
|
||||
}
|
||||
|
||||
# --- Output ---
|
||||
|
||||
@@ -1,20 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-init v1.2 — Create 1C configuration extension scaffold (CFE)
|
||||
# cfe-init v1.11 — Create 1C configuration extension scaffold (CFE) (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C configuration extension."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -29,7 +71,7 @@ def main():
|
||||
parser.add_argument('-CompatibilityMode', dest='CompatibilityMode', default='Version8_3_24')
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default=None)
|
||||
parser.add_argument('-NoRole', dest='NoRole', action='store_true')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -126,18 +168,23 @@ def main():
|
||||
# --- Synonym XML ---
|
||||
synonym_xml = ""
|
||||
if synonym:
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
synonym_xml = f"\r\n\t\t\t\t<v8:item>\r\n\t\t\t\t\t<v8:lang>ru</v8:lang>\r\n\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>\r\n\t\t\t\t</v8:item>\r\n\t\t\t"
|
||||
|
||||
vendor_xml = esc_xml(vendor) if vendor else ""
|
||||
version_xml = esc_xml(version) if version else ""
|
||||
# Элемент целиком, а не значение внутри пары: при пустом значении Конфигуратор
|
||||
# пишет <Vendor/>, а не <Vendor></Vendor>.
|
||||
vendor_el = f"<Vendor>{esc_xml_text(vendor)}</Vendor>" if vendor else "<Vendor/>"
|
||||
version_el = f"<Version>{esc_xml_text(version)}</Version>" if version else "<Version/>"
|
||||
|
||||
# --- Role name ---
|
||||
role_name = f"{name_prefix}ОсновнаяРоль"
|
||||
|
||||
# --- DefaultRoles XML ---
|
||||
default_roles_xml = ""
|
||||
# Элемент целиком: без роли Конфигуратор пишет <DefaultRoles/>, а не пустую пару.
|
||||
default_roles_el = "<DefaultRoles/>"
|
||||
if not args.NoRole:
|
||||
default_roles_xml = f'\r\n\t\t\t\t<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>\r\n\t\t\t'
|
||||
default_roles_el = ('<DefaultRoles>\r\n\t\t\t\t'
|
||||
f'<xr:Item xsi:type="xr:MDObjectRef">Role.{role_name}</xr:Item>'
|
||||
'\r\n\t\t\t</DefaultRoles>')
|
||||
|
||||
# --- ChildObjects ---
|
||||
child_objects_xml = f"\r\n\t\t\t<Language>Русский</Language>"
|
||||
@@ -156,6 +203,40 @@ def main():
|
||||
]
|
||||
|
||||
contained_objects = ""
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
# Caption/ShortCaption — свойства корня из того же формата 2.21, между Version и
|
||||
# DefaultLanguage (позиция снята с выгрузки расширения из базы 8.5).
|
||||
f221_captions = ""
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
f221_captions = "\r\n\t\t\t<Caption/>\r\n\t\t\t<ShortCaption/>"
|
||||
for i in range(7):
|
||||
contained_objects += f"""\t\t\t<xr:ContainedObject>
|
||||
\t\t\t\t<xr:ClassId>{class_ids[i]}</xr:ClassId>
|
||||
@@ -163,27 +244,27 @@ def main():
|
||||
\t\t\t</xr:ContainedObject>\n"""
|
||||
|
||||
cfg_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>
|
||||
{contained_objects}\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<ObjectBelonging>Adopted</ObjectBelonging>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>{synonym_xml}</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<ConfigurationExtensionPurpose>{purpose}</ConfigurationExtensionPurpose>
|
||||
\t\t\t<KeepMappingToExtendedConfigurationObjectsByIDs>true</KeepMappingToExtendedConfigurationObjectsByIDs>
|
||||
\t\t\t<NamePrefix>{esc_xml(name_prefix)}</NamePrefix>
|
||||
\t\t\t<NamePrefix>{esc_xml_text(name_prefix)}</NamePrefix>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
\t\t\t</UsePurposes>
|
||||
\t\t\t<ScriptVariant>Russian</ScriptVariant>
|
||||
\t\t\t<DefaultRoles>{default_roles_xml}</DefaultRoles>
|
||||
\t\t\t<Vendor>{vendor_xml}</Vendor>
|
||||
\t\t\t<Version>{version_xml}</Version>
|
||||
\t\t\t{default_roles_el}
|
||||
\t\t\t{vendor_el}
|
||||
\t\t\t{version_el}{f221_captions}
|
||||
\t\t\t<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
\t\t\t<BriefInformation/>
|
||||
\t\t\t<DetailedInformation/>
|
||||
@@ -198,7 +279,7 @@ def main():
|
||||
|
||||
# --- Languages/Русский.xml (adopted format) ---
|
||||
lang_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<InternalInfo/>
|
||||
\t\t<Properties>
|
||||
@@ -213,10 +294,10 @@ def main():
|
||||
|
||||
# --- Role XML ---
|
||||
role_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Role uuid="{uuid_role}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(role_name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(role_name)}</Name>
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t</Properties>
|
||||
@@ -229,9 +310,9 @@ def main():
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
|
||||
# --- Write files ---
|
||||
write_utf8_bom(cfg_file, cfg_xml)
|
||||
write_xml_file(cfg_file, cfg_xml)
|
||||
lang_file = os.path.join(lang_dir, "Русский.xml")
|
||||
write_utf8_bom(lang_file, lang_xml)
|
||||
write_xml_file(lang_file, lang_xml)
|
||||
|
||||
# --- Role ---
|
||||
role_file = None
|
||||
@@ -239,7 +320,7 @@ def main():
|
||||
role_dir = os.path.join(output_dir, "Roles")
|
||||
os.makedirs(role_dir, exist_ok=True)
|
||||
role_file = os.path.join(role_dir, f"{role_name}.xml")
|
||||
write_utf8_bom(role_file, role_xml)
|
||||
write_xml_file(role_file, role_xml)
|
||||
|
||||
# --- Output ---
|
||||
print(f"[OK] Создано расширение: {name}")
|
||||
|
||||
@@ -110,36 +110,36 @@ allowed-tools:
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-patch-method.ps1" -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
```
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Код перед записью
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Catalog.Контрагенты.ObjectModule" -MethodName "ПриЗаписи" -InterceptorType Before
|
||||
|
||||
# Перехват После на форме
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.Заказ.Form.ФормаДокумента" -MethodName "ПослеЗаписиНаСервере" -InterceptorType After
|
||||
|
||||
# Замена функции (ПродолжитьВызов)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "CommonModule.ОбщийМодуль" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# ИзменениеИКонтроль — копия тела для правки маркерами
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -ModulePath "Document.РеализацияТоваров.ObjectModule" -MethodName "ОбработкаПроведения" -InterceptorType ModificationAndControl
|
||||
|
||||
# ModulePath как путь к файлу модуля-источника (без -ConfigPath)
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
... -ExtensionPath src\cfe\extname -ModulePath "src\cf\CommonModules\ОбщийМодуль\Ext\Module.bsl" -MethodName "ПолучитьДанные" -InterceptorType Instead
|
||||
|
||||
# Проверить все контролируемые методы расширения на дрейф
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Check
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Check
|
||||
|
||||
# Актуализировать дрейфнувшие контролируемые методы пачкой
|
||||
... -ExtensionPath src\cfe\ИмяРасширения -ConfigPath src\cf -Actualize
|
||||
... -ExtensionPath src\cfe\extname -ConfigPath src\cf -Actualize
|
||||
```
|
||||
|
||||
## Верификация
|
||||
|
||||
```
|
||||
/cfe-validate <ExtensionPath>
|
||||
/cfe-validate <ExtensionPath> -ConfigPath <ConfigPath>
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# cfe-patch-method v2.9 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ExtensionPath,
|
||||
@@ -788,6 +789,100 @@ if (Test-Path $ExtensionPath -PathType Leaf) { $ExtensionPath = Split-Path $Exte
|
||||
$cfgFile = Join-Path $ExtensionPath "Configuration.xml"
|
||||
if (-not (Test-Path $cfgFile)) { Write-Error "Configuration.xml не найден в расширении: $ExtensionPath"; exit 1 }
|
||||
|
||||
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
|
||||
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
|
||||
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
|
||||
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
|
||||
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
function Build-PropertyStateXml {
|
||||
param([string]$propertyName, [string]$indent)
|
||||
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
$sb.AppendLine("${indent}<xr:PropertyState>") | Out-Null
|
||||
$sb.AppendLine("${indent}`t<xr:Property>${propertyName}</xr:Property>") | Out-Null
|
||||
$sb.AppendLine("${indent}`t<xr:State>Extended</xr:State>") | Out-Null
|
||||
$sb.Append("${indent}</xr:PropertyState>") | Out-Null
|
||||
return $sb.ToString()
|
||||
}
|
||||
|
||||
function Set-PropertyStateFlag {
|
||||
param([string]$objFile, [string]$propertyName, [string]$formatVersion)
|
||||
|
||||
if ((Get-FormatRank $formatVersion) -lt 219) { return }
|
||||
if (-not (Test-Path $objFile)) { return }
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
$text = [System.IO.File]::ReadAllText($objFile, $enc)
|
||||
$nl = if ($text -match "`r`n") { "`r`n" } else { "`n" }
|
||||
|
||||
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
|
||||
# но они лежат ниже, внутри <ChildObjects>.
|
||||
$empty = [regex]::Match($text, '([ \t]*)<InternalInfo\s*/>')
|
||||
$open = [regex]::Match($text, '(?s)([ \t]*)<InternalInfo>(.*?)</InternalInfo>')
|
||||
|
||||
if ($empty.Success -and (-not $open.Success -or $empty.Index -lt $open.Index)) {
|
||||
$ind = $empty.Groups[1].Value
|
||||
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
|
||||
$replacement = "${ind}<InternalInfo>${nl}${block}${nl}${ind}</InternalInfo>"
|
||||
$text = $text.Remove($empty.Index, $empty.Length).Insert($empty.Index, $replacement)
|
||||
} elseif ($open.Success) {
|
||||
if ($open.Groups[2].Value -match "<xr:Property>$([regex]::Escape($propertyName))</xr:Property>") { return }
|
||||
$ind = $open.Groups[1].Value
|
||||
$block = Build-PropertyStateXml $propertyName ($ind + "`t")
|
||||
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
|
||||
$closeAt = $open.Index + $open.Length - "</InternalInfo>".Length - $ind.Length
|
||||
$text = $text.Insert($closeAt, "${block}${nl}")
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
[System.IO.File]::WriteAllText($objFile, $text, $enc)
|
||||
}
|
||||
|
||||
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
|
||||
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
|
||||
function Get-ModuleFlagTarget {
|
||||
param([string[]]$relParts, [string]$extRoot)
|
||||
|
||||
if ($relParts.Count -ne 4 -or $relParts[2] -ne "Ext") { return $null }
|
||||
$prop = [System.IO.Path]::GetFileNameWithoutExtension($relParts[3])
|
||||
return @{
|
||||
File = (Join-Path (Join-Path $extRoot $relParts[0]) "$($relParts[1]).xml")
|
||||
Property = $prop
|
||||
}
|
||||
}
|
||||
|
||||
# --- Read NamePrefix ---
|
||||
$cfgDoc = New-Object System.Xml.XmlDocument
|
||||
$cfgDoc.PreserveWhitespace = $false
|
||||
@@ -1084,6 +1179,12 @@ if ($reuseRegionIdx -ge 0) {
|
||||
}
|
||||
}
|
||||
|
||||
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
|
||||
$flagTarget = Get-ModuleFlagTarget $relParts $ExtensionPath
|
||||
if ($flagTarget) {
|
||||
Set-PropertyStateFlag $flagTarget.File $flagTarget.Property (Detect-FormatVersion $ExtensionPath)
|
||||
}
|
||||
|
||||
Write-Host "[OK] Перехватчик &$decoratorRu(`"$MethodName`") — $placement"
|
||||
Write-Host " Файл: $extBsl"
|
||||
Write-Host " Процедура: $interceptorName($($method.ParamsText -replace '\s+', ' '))"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-patch-method v2.5 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# cfe-patch-method v2.9 — Source-aware method interceptor for 1C extension (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
TYPE_DIR_MAP = {
|
||||
"Catalog": "Catalogs", "Document": "Documents", "Enum": "Enums",
|
||||
"CommonModule": "CommonModules", "Report": "Reports", "DataProcessor": "DataProcessors",
|
||||
@@ -19,6 +41,24 @@ TYPE_DIR_MAP = {
|
||||
"BusinessProcess": "BusinessProcesses", "Task": "Tasks",
|
||||
"InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
|
||||
"AccountingRegister": "AccountingRegisters", "CalculationRegister": "CalculationRegisters",
|
||||
# Прощающий ввод: имя каталога принимается наравне с именем типа (Catalogs.X ≡ Catalog.X) —
|
||||
# PS1-порт так умел с самого начала, PY отставал.
|
||||
"Catalogs": "Catalogs",
|
||||
"Documents": "Documents",
|
||||
"Enums": "Enums",
|
||||
"CommonModules": "CommonModules",
|
||||
"Reports": "Reports",
|
||||
"DataProcessors": "DataProcessors",
|
||||
"ExchangePlans": "ExchangePlans",
|
||||
"ChartsOfAccounts": "ChartsOfAccounts",
|
||||
"ChartsOfCharacteristicTypes": "ChartsOfCharacteristicTypes",
|
||||
"ChartsOfCalculationTypes": "ChartsOfCalculationTypes",
|
||||
"BusinessProcesses": "BusinessProcesses",
|
||||
"Tasks": "Tasks",
|
||||
"InformationRegisters": "InformationRegisters",
|
||||
"AccumulationRegisters": "AccumulationRegisters",
|
||||
"AccountingRegisters": "AccountingRegisters",
|
||||
"CalculationRegisters": "CalculationRegisters",
|
||||
}
|
||||
# accept plural forms too
|
||||
for _v in list(TYPE_DIR_MAP.values()):
|
||||
@@ -35,6 +75,99 @@ CONTEXT_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# --- Пометка расширенного свойства (<xr:PropertyState>) ---
|
||||
# Свойство появилось в формате 2.19 (8.3.26): на 2.18 и ниже платформа молча выбрасывает элемент
|
||||
# при загрузке. С 2.19 Конфигуратор ставит его сам при выгрузке. Правило: флаг ставит тот, кто
|
||||
# создал файл модуля, — здесь это мы. Имя свойства = базовое имя файла модуля.
|
||||
# Копии этих функций есть в cfe-borrow (навыки автономны); держать их одинаковыми — сознательно.
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def build_property_state_xml(property_name, indent):
|
||||
return "\n".join([
|
||||
f"{indent}<xr:PropertyState>",
|
||||
f"{indent}\t<xr:Property>{property_name}</xr:Property>",
|
||||
f"{indent}\t<xr:State>Extended</xr:State>",
|
||||
f"{indent}</xr:PropertyState>",
|
||||
])
|
||||
|
||||
|
||||
def set_property_state_flag(obj_file, property_name, format_version):
|
||||
if format_rank(format_version) < 219:
|
||||
return
|
||||
if not os.path.isfile(obj_file):
|
||||
return
|
||||
|
||||
with open(obj_file, "r", encoding="utf-8-sig", newline="") as fh:
|
||||
text = fh.read()
|
||||
nl = "\r\n" if "\r\n" in text else "\n"
|
||||
|
||||
# ПЕРВЫЙ <InternalInfo> в файле — собственный у объекта: у реквизитов и подобъектов свои,
|
||||
# но они лежат ниже, внутри <ChildObjects>.
|
||||
empty = re.search(r"([ \t]*)<InternalInfo\s*/>", text)
|
||||
opened = re.search(r"([ \t]*)<InternalInfo>(.*?)</InternalInfo>", text, re.S)
|
||||
|
||||
if empty and (not opened or empty.start() < opened.start()):
|
||||
ind = empty.group(1)
|
||||
block = build_property_state_xml(property_name, ind + "\t")
|
||||
replacement = f"{ind}<InternalInfo>{nl}{block}{nl}{ind}</InternalInfo>"
|
||||
text = text[:empty.start()] + replacement + text[empty.end():]
|
||||
elif opened:
|
||||
if re.search(rf"<xr:Property>{re.escape(property_name)}</xr:Property>", opened.group(2)):
|
||||
return
|
||||
ind = opened.group(1)
|
||||
block = build_property_state_xml(property_name, ind + "\t")
|
||||
# Дописываем в КОНЕЦ InternalInfo: у Конфигуратора PropertyState идёт после GeneratedType.
|
||||
close_at = opened.end() - len("</InternalInfo>") - len(ind)
|
||||
text = text[:close_at] + block + nl + text[close_at:]
|
||||
else:
|
||||
return
|
||||
|
||||
with open(obj_file, "w", encoding="utf-8-sig", newline="") as fh:
|
||||
fh.write(text)
|
||||
|
||||
|
||||
# Модуль формы сюда не попадает: у формы флаг называется Form и ставится при заимствовании,
|
||||
# а не при появлении модуля (замер на 8.3.26 — пустой модуль формы платформа не выгружает).
|
||||
def get_module_flag_target(rel_parts, ext_root):
|
||||
if len(rel_parts) != 4 or rel_parts[2] != "Ext":
|
||||
return None
|
||||
prop = os.path.splitext(rel_parts[3])[0]
|
||||
return {
|
||||
"file": os.path.join(ext_root, rel_parts[0], f"{rel_parts[1]}.xml"),
|
||||
"property": prop,
|
||||
}
|
||||
|
||||
|
||||
def get_module_rel_path(module_path):
|
||||
parts = module_path.split(".")
|
||||
if len(parts) < 2:
|
||||
@@ -539,7 +672,7 @@ def main():
|
||||
choices=["", "Before", "After", "Instead", "ModificationAndControl"])
|
||||
parser.add_argument("-Check", action="store_true")
|
||||
parser.add_argument("-Actualize", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
config_path = args.ConfigPath
|
||||
@@ -801,6 +934,12 @@ def main():
|
||||
|
||||
place_new(ext_bsl, ext_lines, ext_exists, method["chain"], core)
|
||||
|
||||
# Модуль в расширении есть — отражаем это в метаданных объекта (формат ≥ 2.19).
|
||||
flag_target = get_module_flag_target(rel_parts, extension_path)
|
||||
if flag_target:
|
||||
set_property_state_flag(flag_target["file"], flag_target["property"],
|
||||
detect_format_version(extension_path))
|
||||
|
||||
# emit summary
|
||||
placement = place_new.placement
|
||||
print('[OK] Перехватчик &%s("%s") — %s' % (decorator_ru, method_name, placement))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: cfe-validate
|
||||
description: Валидация расширения конфигурации 1С (CFE). Используй после создания или модификации расширения для проверки корректности
|
||||
argument-hint: <ExtensionPath> [-Detailed] [-MaxErrors 30]
|
||||
argument-hint: <ExtensionPath> [-ConfigPath <ConfigDir>] [-Detailed] [-MaxErrors 30]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -10,20 +10,31 @@ allowed-tools:
|
||||
|
||||
# /cfe-validate — валидация расширения конфигурации (CFE)
|
||||
|
||||
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты. Аналог `/cf-validate`, но для расширений.
|
||||
Проверяет структурную корректность расширения: XML-формат, свойства, состав, заимствованные объекты, права ролей. Аналог `/cf-validate`, но для расширений.
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Обяз. | Умолч. | Описание |
|
||||
|---------------|:-----:|---------|-------------------------------------------------|
|
||||
| ExtensionPath | да | — | Путь к каталогу или Configuration.xml расширения |
|
||||
| ConfigPath | нет | — | Каталог конфигурации, из которой заимствованы объекты |
|
||||
| Detailed | нет | — | Подробный вывод (все проверки, включая успешные) |
|
||||
| MaxErrors | нет | 30 | Остановиться после N ошибок |
|
||||
| OutFile | нет | — | Записать результат в файл |
|
||||
|
||||
### ConfigPath
|
||||
|
||||
Указывай всегда, когда конфигурация-источник доступна: без неё часть ошибок заимствованных форм не ловится, и расширение может пройти валидацию, а потом быть отвергнутым платформой при загрузке.
|
||||
|
||||
Если пользователь не указал путь — определи сам:
|
||||
1. Прочитай `.v8-project.json` из корня проекта
|
||||
2. Разреши целевую базу (по имени, ветке или `default`)
|
||||
3. Возьми её поле `configSrc`
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src/Configuration.xml"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname\Configuration.xml"
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/cfe-validate.ps1" -ExtensionPath "src\cfe\extname" -ConfigPath "src\cf"
|
||||
```
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# cfe-validate v1.5 — Validate 1C configuration extension structure (CFE)
|
||||
# cfe-validate v1.14 — Validate 1C configuration extension structure (CFE)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ExtensionPath,
|
||||
|
||||
@@ -9,7 +10,11 @@ param(
|
||||
|
||||
[int]$MaxErrors = 30,
|
||||
|
||||
[string]$OutFile
|
||||
[string]$OutFile,
|
||||
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -89,8 +94,42 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
|
||||
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
|
||||
$moduleKindsByType = @{
|
||||
"CommonModule"=@("Module"); "HTTPService"=@("Module"); "WebService"=@("Module")
|
||||
"Catalog"=@("ObjectModule","ManagerModule"); "Document"=@("ObjectModule","ManagerModule")
|
||||
"Report"=@("ObjectModule","ManagerModule"); "DataProcessor"=@("ObjectModule","ManagerModule")
|
||||
"ExchangePlan"=@("ObjectModule","ManagerModule")
|
||||
"ChartOfCharacteristicTypes"=@("ObjectModule","ManagerModule")
|
||||
"ChartOfAccounts"=@("ObjectModule","ManagerModule")
|
||||
"ChartOfCalculationTypes"=@("ObjectModule","ManagerModule")
|
||||
"BusinessProcess"=@("ObjectModule","ManagerModule"); "Task"=@("ObjectModule","ManagerModule")
|
||||
"InformationRegister"=@("RecordSetModule","ManagerModule")
|
||||
"AccumulationRegister"=@("RecordSetModule","ManagerModule")
|
||||
"AccountingRegister"=@("RecordSetModule","ManagerModule")
|
||||
"CalculationRegister"=@("RecordSetModule","ManagerModule")
|
||||
"Sequence"=@("RecordSetModule","ManagerModule")
|
||||
"Constant"=@("ValueManagerModule","ManagerModule")
|
||||
"Enum"=@("ManagerModule"); "DocumentJournal"=@("ManagerModule")
|
||||
"FilterCriterion"=@("ManagerModule")
|
||||
}
|
||||
|
||||
$guidPattern ='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
$identPattern = '^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$'
|
||||
|
||||
# 7 fixed ClassIds for Configuration
|
||||
@@ -108,7 +147,7 @@ $validClassIds = @(
|
||||
$childObjectTypes = @(
|
||||
"Language","Subsystem","StyleItem","Style",
|
||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
||||
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||
@@ -122,7 +161,7 @@ $childObjectTypes = @(
|
||||
|
||||
# Type -> directory mapping
|
||||
$childTypeDirMap = @{
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"; "Bot"="Bots"
|
||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||
@@ -144,6 +183,46 @@ $childTypeDirMap = @{
|
||||
"IntegrationService"="IntegrationServices"
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
$generatedTypeCategories = @{
|
||||
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||
"Document" = @("Object","Ref","Selection","List","Manager")
|
||||
"Enum" = @("Ref","Manager","List")
|
||||
"Constant" = @("Manager","ValueManager","ValueKey")
|
||||
"Report" = @("Object","Manager")
|
||||
"DataProcessor" = @("Object","Manager")
|
||||
"ExchangePlan" = @("Object","Ref","Selection","List","Manager")
|
||||
"Task" = @("Object","Ref","Selection","List","Manager")
|
||||
"BusinessProcess" = @("Object","Ref","Selection","List","Manager","RoutePointRef")
|
||||
"ChartOfCharacteristicTypes" = @("Object","Ref","Selection","List","Manager","Characteristic")
|
||||
"ChartOfAccounts" = @("Object","Ref","Selection","List","Manager","ExtDimensionTypes","ExtDimensionTypesRow")
|
||||
"ChartOfCalculationTypes" = @("Object","Ref","Selection","List","Manager","DisplacingCalculationTypes","DisplacingCalculationTypesRow","BaseCalculationTypes","BaseCalculationTypesRow","LeadingCalculationTypes","LeadingCalculationTypesRow")
|
||||
"InformationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","RecordManager")
|
||||
"AccumulationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey")
|
||||
"AccountingRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","ExtDimensions")
|
||||
"CalculationRegister" = @("Record","Manager","Selection","List","RecordSet","RecordKey","Recalcs")
|
||||
"DocumentJournal" = @("Selection","List","Manager")
|
||||
"Sequence" = @("Record","Manager","RecordSet")
|
||||
"FilterCriterion" = @("Manager","List")
|
||||
"SettingsStorage" = @("Manager")
|
||||
"IntegrationService" = @("Manager")
|
||||
"WSReference" = @("Manager")
|
||||
"DefinedType" = @("DefinedType")
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
$script:standardObjectFields = @(
|
||||
"Code","Description","Ref","Parent","Owner","DeletionMark","Predefined","IsFolder","LineNumber",
|
||||
"Number","Date","Posted","PredefinedDataName","RegisterRecords","DataVersion","RowsCount",
|
||||
"Код","Наименование","Ссылка","Родитель","Владелец","ПометкаУдаления","Предопределенный",
|
||||
"ЭтоГруппа","НомерСтроки","Номер","Дата","Проведен","ИмяПредопределенныхДанных",
|
||||
"Движения","ВерсияДанных","КоличествоСтрок"
|
||||
)
|
||||
|
||||
# Valid enum values for extension properties
|
||||
$validEnumValues = @{
|
||||
"ConfigurationExtensionCompatibilityMode" = @("DontUse","Version8_1","Version8_2_13","Version8_2_16","Version8_3_1","Version8_3_2","Version8_3_3","Version8_3_4","Version8_3_5","Version8_3_6","Version8_3_7","Version8_3_8","Version8_3_9","Version8_3_10","Version8_3_11","Version8_3_12","Version8_3_13","Version8_3_14","Version8_3_15","Version8_3_16","Version8_3_17","Version8_3_18","Version8_3_19","Version8_3_20","Version8_3_21","Version8_3_22","Version8_3_23","Version8_3_24","Version8_3_25","Version8_3_26","Version8_3_27","Version8_3_28","Version8_5_1")
|
||||
@@ -195,11 +274,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -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)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Must have Configuration child
|
||||
@@ -537,6 +620,7 @@ if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Check 9: Borrowed objects validation + Check 10: Sub-items ---
|
||||
$script:enumValuesIndex = @{}
|
||||
$script:borrowedTSIndex = @{}
|
||||
$script:formList = @()
|
||||
|
||||
# Helper: check if sub-item has explicit borrowed metadata
|
||||
@@ -640,6 +724,25 @@ if ($childObjNode) {
|
||||
} else {
|
||||
$borrowedOk++
|
||||
}
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
$expectedCats = $generatedTypeCategories[$typeName]
|
||||
if ($expectedCats) {
|
||||
$objInfo = $objEl.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$foundCats = @{}
|
||||
if ($objInfo) {
|
||||
foreach ($gt in $objInfo.SelectNodes("xr:GeneratedType", $objNs)) {
|
||||
$cat = $gt.GetAttribute("category")
|
||||
if ($cat) { $foundCats[$cat] = $true }
|
||||
}
|
||||
}
|
||||
$missingCats = @($expectedCats | Where-Object { -not $foundCats.ContainsKey($_) })
|
||||
if ($missingCats.Count -gt 0) {
|
||||
Report-Error "9. Borrowed ${typeName}.${childName}: missing GeneratedType categor$(if ($missingCats.Count -eq 1) { 'y' } else { 'ies' }) $($missingCats -join ', ')"
|
||||
$check9Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
@@ -667,6 +770,12 @@ if ($childObjNode) {
|
||||
$tsInfo = $subItem.SelectSingleNode("md:InternalInfo", $objNs)
|
||||
$tsName = $subItem.SelectSingleNode("md:Properties/md:Name", $objNs)
|
||||
$tsLabel = if ($tsName) { $tsName.InnerText } else { "?" }
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ($tsName) {
|
||||
$tsKey = "${typeName}.${childName}"
|
||||
if (-not $script:borrowedTSIndex.ContainsKey($tsKey)) { $script:borrowedTSIndex[$tsKey] = @{} }
|
||||
$script:borrowedTSIndex[$tsKey][$tsName.InnerText] = $true
|
||||
}
|
||||
if (-not $tsInfo) {
|
||||
Report-Error "10. ${ctx}: TabularSection.${tsLabel} missing InternalInfo"
|
||||
$check10Ok = $false
|
||||
@@ -896,6 +1005,38 @@ foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
}
|
||||
}
|
||||
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки ниже на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> самой формы, потом в <BaseForm>.
|
||||
$rootName = ""
|
||||
$rootMatch = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if ($rootMatch.Success) { $rootName = $rootMatch.Groups[1].Value }
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
$acTables = @{}
|
||||
if ($rootName) {
|
||||
$rootPat = [regex]::Escape($rootName)
|
||||
foreach ($m in [regex]::Matches($raw, "<AdditionalColumns table=`"${rootPat}\.(\w+)`"")) {
|
||||
$acTables[$m.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
if ($acTables.Count -gt 0) {
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerTS = $script:borrowedTSIndex[$ownerKey]
|
||||
foreach ($tblName in $acTables.Keys) {
|
||||
$depCheckCount++
|
||||
if (-not $ownerTS -or -not $ownerTS.ContainsKey($tblName)) {
|
||||
Report-Error "12. ${ctx}: <AdditionalColumns table=`"${rootName}.${tblName}`"> — TabularSection.${tblName} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($mi in $missingItems) {
|
||||
Report-Warn "12. ${ctx}: references ${mi} not borrowed in extension"
|
||||
$check12Ok = $false
|
||||
@@ -931,6 +1072,232 @@ if ($script:borrowedFormsWithTree.Count -eq 0) {
|
||||
Report-OK "13. TypeLink: clean"
|
||||
}
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не реквизит объекта и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if (-not $script:stopped -and $script:borrowedFormsWithTree.Count -gt 0) {
|
||||
if (-not $ConfigPath) {
|
||||
Out-Line "[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath"
|
||||
} else {
|
||||
$cfgRoot = $ConfigPath
|
||||
if (-not [System.IO.Path]::IsPathRooted($cfgRoot)) { $cfgRoot = Join-Path (Get-Location).Path $cfgRoot }
|
||||
if ((Test-Path $cfgRoot) -and -not (Test-Path $cfgRoot -PathType Container)) { $cfgRoot = Split-Path $cfgRoot -Parent }
|
||||
|
||||
if (-not (Test-Path (Join-Path $cfgRoot "Configuration.xml"))) {
|
||||
Report-Warn "14. -ConfigPath '$ConfigPath': Configuration.xml не найден — проверка путей пропущена"
|
||||
} else {
|
||||
$check14Ok = $true
|
||||
$pathCheckCount = 0
|
||||
|
||||
foreach ($bf in $script:borrowedFormsWithTree) {
|
||||
$raw = $bf.RawText
|
||||
$ctx = $bf.Context
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
$rootMatch14 = [regex]::Match($raw, '(?s)<Attribute name="([^"]+)"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>')
|
||||
if (-not $rootMatch14.Success) { continue }
|
||||
$rootName = $rootMatch14.Groups[1].Value
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if ($rootMatch14.Value -match '>cfg:DynamicList<') { continue }
|
||||
$ownerKey = ($ctx -split '\.Form\.')[0]
|
||||
$ownerParts = $ownerKey -split '\.', 2
|
||||
if ($ownerParts.Count -lt 2) { continue }
|
||||
$ownerType = $ownerParts[0]; $ownerName = $ownerParts[1]
|
||||
$ownerDir = $childTypeDirMap[$ownerType]
|
||||
if (-not $ownerDir) { continue }
|
||||
$srcObjFile = Join-Path (Join-Path $cfgRoot $ownerDir) "${ownerName}.xml"
|
||||
if (-not (Test-Path $srcObjFile)) {
|
||||
Report-Warn "14. ${ctx}: объект-источник не найден в конфигурации ($ownerDir/${ownerName}.xml)"
|
||||
continue
|
||||
}
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
$srcNames = @{}
|
||||
$srcTSColumns = @{}
|
||||
$srcDoc = New-Object System.Xml.XmlDocument
|
||||
$srcDoc.PreserveWhitespace = $false
|
||||
$srcDoc.Load($srcObjFile)
|
||||
$srcObjEl = $null
|
||||
foreach ($c in $srcDoc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq 'Element') { $srcObjEl = $c; break }
|
||||
}
|
||||
$srcChildObjects = if ($srcObjEl) { $srcObjEl.SelectSingleNode("*[local-name()='ChildObjects']") } else { $null }
|
||||
if ($srcChildObjects) {
|
||||
foreach ($sub in $srcChildObjects.ChildNodes) {
|
||||
if ($sub.NodeType -ne 'Element') { continue }
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них замена
|
||||
# корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if ($sub.LocalName -notin @('Attribute','Dimension','Resource','TabularSection')) { continue }
|
||||
$nameNode = $sub.SelectSingleNode("*[local-name()='Properties']/*[local-name()='Name']")
|
||||
if (-not $nameNode) { continue }
|
||||
$subName = $nameNode.InnerText.Trim()
|
||||
$srcNames[$subName] = $true
|
||||
if ($sub.LocalName -ne 'TabularSection') { continue }
|
||||
$cols = @{}
|
||||
foreach ($colName in $sub.SelectNodes("*[local-name()='ChildObjects']/*[local-name()='Attribute']/*[local-name()='Properties']/*[local-name()='Name']")) {
|
||||
$cols[$colName.InnerText.Trim()] = $true
|
||||
}
|
||||
$srcTSColumns[$subName] = $cols
|
||||
}
|
||||
}
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
$rootPat14 = [regex]::Escape($rootName)
|
||||
foreach ($acm in [regex]::Matches($raw, "(?s)<AdditionalColumns table=`"${rootPat14}\.(\w+)`">(.*?)</AdditionalColumns>")) {
|
||||
$tbl = $acm.Groups[1].Value
|
||||
if (-not $srcTSColumns.ContainsKey($tbl)) { $srcTSColumns[$tbl] = @{} }
|
||||
foreach ($cm in [regex]::Matches($acm.Groups[2].Value, '<Column name="(\w+)"')) {
|
||||
$srcTSColumns[$tbl][$cm.Groups[1].Value] = $true
|
||||
}
|
||||
}
|
||||
|
||||
$badPaths = @{}
|
||||
foreach ($m in [regex]::Matches($raw, "<(?:\w+:)?\w*DataPath[^>]*>${rootPat14}\.([^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||
$segments = $m.Groups[1].Value -split '\.'
|
||||
$seg0 = $segments[0]
|
||||
$pathCheckCount++
|
||||
if ($script:standardObjectFields -contains $seg0) { continue }
|
||||
if (-not $srcNames.ContainsKey($seg0)) {
|
||||
$badPaths["${rootName}.${seg0}"] = "у ${ownerKey} нет такого реквизита или табличной части"
|
||||
continue
|
||||
}
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if ($segments.Count -lt 2 -or -not $srcTSColumns.ContainsKey($seg0)) { continue }
|
||||
$seg1 = $segments[1]
|
||||
if ($script:standardObjectFields -contains $seg1) { continue }
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if ($seg1 -like "Total*" -and $srcTSColumns[$seg0].ContainsKey($seg1.Substring(5))) { continue }
|
||||
if (-not $srcTSColumns[$seg0].ContainsKey($seg1)) {
|
||||
$badPaths["${rootName}.${seg0}.${seg1}"] = "у табличной части ${seg0} нет колонки ${seg1}, и <Columns> формы её не объявляет"
|
||||
}
|
||||
}
|
||||
foreach ($bad in ($badPaths.Keys | Sort-Object)) {
|
||||
Report-Error "14. ${ctx}: путь '${bad}' — $($badPaths[$bad])"
|
||||
$check14Ok = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($check14Ok) {
|
||||
Report-OK "14. Object paths vs source config: $pathCheckCount checked"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
|
||||
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
|
||||
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
|
||||
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
|
||||
$defaultRoleNodes = @($cfgNode.SelectNodes("md:Properties/md:DefaultRoles/xr:Item", $ns))
|
||||
if ($defaultRoleNodes.Count -gt 0) {
|
||||
$adoptedCache = @{}
|
||||
|
||||
function Test-ObjectAdopted {
|
||||
param([string]$typeName, [string]$objName)
|
||||
$key = "$typeName.$objName"
|
||||
if ($adoptedCache.ContainsKey($key)) { return $adoptedCache[$key] }
|
||||
$adoptedCache[$key] = $false
|
||||
if ($childTypeDirMap.ContainsKey($typeName)) {
|
||||
$objPath = Join-Path (Join-Path $configDir $childTypeDirMap[$typeName]) "$objName.xml"
|
||||
if (Test-Path $objPath) {
|
||||
try {
|
||||
$objDoc = New-Object System.Xml.XmlDocument
|
||||
$objDoc.Load($objPath)
|
||||
$objNs = New-Object System.Xml.XmlNamespaceManager($objDoc.NameTable)
|
||||
$objNs.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
$ob = $objDoc.SelectSingleNode("/md:MetaDataObject/md:$typeName/md:Properties/md:ObjectBelonging", $objNs)
|
||||
if ($ob -and $ob.InnerText -eq "Adopted") { $adoptedCache[$key] = $true }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return $adoptedCache[$key]
|
||||
}
|
||||
|
||||
$check15Ok = $true
|
||||
$check15Count = 0
|
||||
foreach ($rn in $defaultRoleNodes) {
|
||||
$roleRef = $rn.InnerText
|
||||
if ($roleRef -notmatch '^Role\.(.+)$') { continue }
|
||||
$defRoleName = $Matches[1]
|
||||
$rightsPath = Join-Path (Join-Path (Join-Path $configDir "Roles") $defRoleName) "Ext\Rights.xml"
|
||||
if (-not (Test-Path $rightsPath)) { continue }
|
||||
try {
|
||||
$rDoc = New-Object System.Xml.XmlDocument
|
||||
$rDoc.Load($rightsPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
$rNs = New-Object System.Xml.XmlNamespaceManager($rDoc.NameTable)
|
||||
$rNs.AddNamespace("r", "http://v8.1c.ru/8.2/roles")
|
||||
foreach ($nameNode in $rDoc.SelectNodes("/r:Rights/r:object/r:name", $rNs)) {
|
||||
$fullName = $nameNode.InnerText
|
||||
$segs = $fullName.Split(".")
|
||||
# Configuration.* — права самого расширения, не объект; заимствования там нет.
|
||||
if ($segs.Count -lt 2 -or $segs[0] -eq "Configuration") { continue }
|
||||
$check15Count++
|
||||
if (Test-ObjectAdopted $segs[0] $segs[1]) {
|
||||
Report-Error ("15. Роль '$defRoleName' входит в DefaultRoles и даёт права на заимствованный $($segs[0]).$($segs[1]) " +
|
||||
"($fullName): платформа это запрещает. Вынесите такие права в отдельную роль вне DefaultRoles.")
|
||||
$check15Ok = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($check15Ok -and $check15Count -gt 0) {
|
||||
Report-OK "15. Основные роли: прав на заимствованные объекты нет ($check15Count checked)"
|
||||
}
|
||||
}
|
||||
|
||||
if ($script:stopped) { & $finalize; exit 1 }
|
||||
|
||||
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
|
||||
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
|
||||
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
|
||||
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на стенде),
|
||||
# но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
|
||||
if ($versionRank -ge 219 -and $childObjNode) {
|
||||
$stateIssues = @()
|
||||
$stateChecked = 0
|
||||
foreach ($child in $childObjNode.ChildNodes) {
|
||||
if ($child.NodeType -ne 'Element') { continue }
|
||||
$typeName = $child.LocalName
|
||||
if (-not $moduleKindsByType.ContainsKey($typeName)) { continue }
|
||||
if (-not $childTypeDirMap.ContainsKey($typeName)) { continue }
|
||||
$stateObjName = $child.InnerText.Trim()
|
||||
if (-not $stateObjName) { continue }
|
||||
$typeDir = Join-Path $configDir $childTypeDirMap[$typeName]
|
||||
$objFile = Join-Path $typeDir "$stateObjName.xml"
|
||||
if (-not (Test-Path $objFile)) { continue }
|
||||
$objText = [System.IO.File]::ReadAllText($objFile, [System.Text.Encoding]::UTF8)
|
||||
if ($objText -notmatch '<ObjectBelonging>Adopted</ObjectBelonging>') { continue }
|
||||
|
||||
foreach ($kind in $moduleKindsByType[$typeName]) {
|
||||
$stateChecked++
|
||||
$hasFile = Test-Path (Join-Path (Join-Path (Join-Path $typeDir $stateObjName) "Ext") "$kind.bsl")
|
||||
$hasFlag = $objText -match "<xr:Property>$kind</xr:Property>"
|
||||
if ($hasFile -and -not $hasFlag) {
|
||||
$stateIssues += "$typeName.$stateObjName — есть $kind.bsl, но нет <xr:PropertyState> для $kind"
|
||||
} elseif ($hasFlag -and -not $hasFile) {
|
||||
$stateIssues += "$typeName.$stateObjName — есть <xr:PropertyState> для $kind, но нет $kind.bsl"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stateChecked -gt 0) {
|
||||
if ($stateIssues.Count -eq 0) {
|
||||
Report-OK "16. Модули заимствованных объектов: пометки расширенных свойств согласованы ($stateChecked)"
|
||||
} else {
|
||||
foreach ($issue in $stateIssues) { Report-Warn "16. $issue" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||
$extRootDir = Split-Path $resolvedPath -Parent
|
||||
$ctrlCount = 0
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# cfe-validate v1.5 — Validate 1C configuration extension XML structure (CFE)
|
||||
# cfe-validate v1.14 — 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
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS = {
|
||||
'md': 'http://v8.1c.ru/8.3/MDClasses',
|
||||
'v8': 'http://v8.1c.ru/8.1/data/core',
|
||||
@@ -37,7 +59,7 @@ VALID_CLASS_IDS = [
|
||||
CHILD_OBJECT_TYPES = [
|
||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
||||
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||
@@ -49,11 +71,33 @@ CHILD_OBJECT_TYPES = [
|
||||
'BusinessProcess', 'Task', 'IntegrationService',
|
||||
]
|
||||
|
||||
# Модули заимствованных объектов: тип → виды модулей. Имя свойства в <xr:PropertyState>
|
||||
# совпадает с базовым именем файла модуля. Копия таблицы есть в cfe-borrow (навыки автономны).
|
||||
MODULE_KINDS_BY_TYPE = {
|
||||
"CommonModule": ["Module"], "HTTPService": ["Module"], "WebService": ["Module"],
|
||||
"Catalog": ["ObjectModule", "ManagerModule"], "Document": ["ObjectModule", "ManagerModule"],
|
||||
"Report": ["ObjectModule", "ManagerModule"], "DataProcessor": ["ObjectModule", "ManagerModule"],
|
||||
"ExchangePlan": ["ObjectModule", "ManagerModule"],
|
||||
"ChartOfCharacteristicTypes": ["ObjectModule", "ManagerModule"],
|
||||
"ChartOfAccounts": ["ObjectModule", "ManagerModule"],
|
||||
"ChartOfCalculationTypes": ["ObjectModule", "ManagerModule"],
|
||||
"BusinessProcess": ["ObjectModule", "ManagerModule"], "Task": ["ObjectModule", "ManagerModule"],
|
||||
"InformationRegister": ["RecordSetModule", "ManagerModule"],
|
||||
"AccumulationRegister": ["RecordSetModule", "ManagerModule"],
|
||||
"AccountingRegister": ["RecordSetModule", "ManagerModule"],
|
||||
"CalculationRegister": ["RecordSetModule", "ManagerModule"],
|
||||
"Sequence": ["RecordSetModule", "ManagerModule"],
|
||||
"Constant": ["ValueManagerModule", "ManagerModule"],
|
||||
"Enum": ["ManagerModule"], "DocumentJournal": ["ManagerModule"],
|
||||
"FilterCriterion": ["ManagerModule"],
|
||||
}
|
||||
|
||||
# Type -> directory mapping
|
||||
CHILD_TYPE_DIR_MAP = {
|
||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||
'Bot': 'Bots',
|
||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||
@@ -73,6 +117,50 @@ CHILD_TYPE_DIR_MAP = {
|
||||
'IntegrationService': 'IntegrationServices',
|
||||
}
|
||||
|
||||
# Наборы GeneratedType по типу объекта (эталон — таблица §2.5 спецификации конфигурации).
|
||||
# Неполный набор в заимствованной оболочке платформа отвергает при загрузке: «отсутствует один
|
||||
# или более типов объекта <Тип>». Типы, у которых GeneratedType нет вовсе (общие модули,
|
||||
# подписки, регламентные задания и т.п.), в карте отсутствуют — для них проверка не выполняется.
|
||||
GENERATED_TYPE_CATEGORIES = {
|
||||
'Catalog': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Document': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Enum': ['Ref', 'Manager', 'List'],
|
||||
'Constant': ['Manager', 'ValueManager', 'ValueKey'],
|
||||
'Report': ['Object', 'Manager'],
|
||||
'DataProcessor': ['Object', 'Manager'],
|
||||
'ExchangePlan': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'Task': ['Object', 'Ref', 'Selection', 'List', 'Manager'],
|
||||
'BusinessProcess': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'RoutePointRef'],
|
||||
'ChartOfCharacteristicTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'Characteristic'],
|
||||
'ChartOfAccounts': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'ExtDimensionTypes', 'ExtDimensionTypesRow'],
|
||||
'ChartOfCalculationTypes': ['Object', 'Ref', 'Selection', 'List', 'Manager', 'DisplacingCalculationTypes', 'DisplacingCalculationTypesRow', 'BaseCalculationTypes', 'BaseCalculationTypesRow', 'LeadingCalculationTypes', 'LeadingCalculationTypesRow'],
|
||||
'InformationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'RecordManager'],
|
||||
'AccumulationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey'],
|
||||
'AccountingRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'ExtDimensions'],
|
||||
'CalculationRegister': ['Record', 'Manager', 'Selection', 'List', 'RecordSet', 'RecordKey', 'Recalcs'],
|
||||
'DocumentJournal': ['Selection', 'List', 'Manager'],
|
||||
'Sequence': ['Record', 'Manager', 'RecordSet'],
|
||||
'FilterCriterion': ['Manager', 'List'],
|
||||
'SettingsStorage': ['Manager'],
|
||||
'IntegrationService': ['Manager'],
|
||||
'WSReference': ['Manager'],
|
||||
'DefinedType': ['DefinedType'],
|
||||
}
|
||||
|
||||
# Стандартные реквизиты объектов: в ChildObjects их нет, но пути Объект.<Стандартный> законны.
|
||||
# Имена зависят от варианта встроенного языка, поэтому держим оба написания.
|
||||
# Основной реквизит формы: <Attribute name="X"> с <MainAttribute>true</MainAttribute> внутри
|
||||
MAIN_ATTR_RE = re.compile(
|
||||
r'<Attribute name=\"([^\"]+)\"[^>]*>(?:(?!</Attribute>).)*?<MainAttribute>true</MainAttribute>', re.DOTALL)
|
||||
|
||||
STANDARD_OBJECT_FIELDS = {
|
||||
'Code', 'Description', 'Ref', 'Parent', 'Owner', 'DeletionMark', 'Predefined', 'IsFolder', 'LineNumber',
|
||||
'Number', 'Date', 'Posted', 'PredefinedDataName', 'RegisterRecords', 'DataVersion', 'RowsCount',
|
||||
'Код', 'Наименование', 'Ссылка', 'Родитель', 'Владелец', 'ПометкаУдаления', 'Предопределенный',
|
||||
'ЭтоГруппа', 'НомерСтроки', 'Номер', 'Дата', 'Проведен', 'ИмяПредопределенныхДанных',
|
||||
'Движения', 'ВерсияДанных', 'КоличествоСтрок',
|
||||
}
|
||||
|
||||
# Valid enum values for extension properties
|
||||
VALID_ENUM_VALUES = {
|
||||
'ConfigurationExtensionCompatibilityMode': [
|
||||
@@ -94,6 +182,20 @@ VALID_ENUM_VALUES = {
|
||||
|
||||
EXPECTED_NS = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
class Reporter:
|
||||
def __init__(self, max_errors, detailed=False):
|
||||
@@ -154,11 +256,15 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
# Конфигурация-источник. Без неё проверки, требующие сравнения с основной конфигурацией,
|
||||
# пропускаются (о чём сказано в отчёте), остальные работают как раньше.
|
||||
parser.add_argument('-ConfigPath', dest='ConfigPath', default='')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
extension_path = args.ExtensionPath
|
||||
max_errors = args.MaxErrors
|
||||
out_file = args.OutFile
|
||||
config_path_arg = args.ConfigPath
|
||||
|
||||
# --- Resolve path ---
|
||||
if not os.path.isabs(extension_path):
|
||||
@@ -214,11 +320,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get('version', '')
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
r.warn('1. Missing version attribute on MetaDataObject')
|
||||
elif version not in ('2.17', '2.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)")
|
||||
elif version_rank == 0:
|
||||
r.error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
r.warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
r.warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Must have Configuration child
|
||||
cfg_node = None
|
||||
@@ -537,6 +649,7 @@ def main():
|
||||
MD = NS['md']
|
||||
XR = NS['xr']
|
||||
enum_values_index = {}
|
||||
borrowed_ts_index = {}
|
||||
form_list = []
|
||||
|
||||
def is_borrowed_sub_item(sub_item):
|
||||
@@ -636,6 +749,23 @@ def main():
|
||||
else:
|
||||
borrowed_ok_count += 1
|
||||
|
||||
# Полнота набора GeneratedType: платформа отвергает оболочку с неполным набором
|
||||
# («отсутствует один или более типов объекта ChartOfCharacteristicTypes»)
|
||||
expected_cats = GENERATED_TYPE_CATEGORIES.get(type_name)
|
||||
if expected_cats:
|
||||
obj_info = obj_el.find(f'{{{MD}}}InternalInfo')
|
||||
found_cats = set()
|
||||
if obj_info is not None:
|
||||
for gt in obj_info.findall(f'{{{XR}}}GeneratedType'):
|
||||
cat = gt.get('category')
|
||||
if cat:
|
||||
found_cats.add(cat)
|
||||
missing_cats = [c for c in expected_cats if c not in found_cats]
|
||||
if missing_cats:
|
||||
word = 'category' if len(missing_cats) == 1 else 'categories'
|
||||
r.error(f"9. Borrowed {type_name}.{child_name}: missing GeneratedType {word} {', '.join(missing_cats)}")
|
||||
check9_ok = False
|
||||
|
||||
# --- Check 10: Sub-items (Attribute, TabularSection, EnumValue, Form) ---
|
||||
obj_child_objects = obj_el.find(f'{{{MD}}}ChildObjects')
|
||||
if obj_child_objects is not None:
|
||||
@@ -663,6 +793,9 @@ def main():
|
||||
ts_info = sub_item.find(f'{{{MD}}}InternalInfo')
|
||||
ts_name_el = sub_item.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
ts_label = (ts_name_el.text or '?') if ts_name_el is not None else '?'
|
||||
# Индекс заимствованных ТЧ — по нему Check 12 сверяет <AdditionalColumns table="Объект.X">
|
||||
if ts_name_el is not None and ts_name_el.text:
|
||||
borrowed_ts_index.setdefault(f'{type_name}.{child_name}', {})[ts_name_el.text.strip()] = True
|
||||
if ts_info is None:
|
||||
r.error(f'10. {ctx}: TabularSection.{ts_label} missing InternalInfo')
|
||||
check10_ok = False
|
||||
@@ -855,6 +988,29 @@ def main():
|
||||
elif entry['Enum'] not in enum_values_index or entry['Value'] not in enum_values_index.get(entry['Enum'], {}):
|
||||
missing_items.append(f"Enum.{entry['Enum']}.EnumValue.{entry['Value']}")
|
||||
|
||||
# <AdditionalColumns table="Объект.X"> — доп. колонки табличной части, объявленные в самой форме.
|
||||
# Колонки есть, а самой ТЧ в расширении нет → платформа отвергает загрузку: «Неверный путь к
|
||||
# данным» плюс «Колонки не могут быть добавлены к реквизиту».
|
||||
# Соседние проверки этого блока эвристичны (имя стиля добывается регуляркой), поэтому там
|
||||
# предупреждение. Здесь сигнал точный — имя ТЧ берётся из атрибута, — а последствие жёсткое,
|
||||
# поэтому ошибка.
|
||||
# Корень путей формы — имя её основного реквизита: «Объект» только у формы объекта, у формы
|
||||
# списка «Список», у формы записи регистра «Запись». С зашитым «Объект» обе проверки на
|
||||
# таких формах молча не срабатывали. Ищем сначала в <Attributes> формы, потом в <BaseForm>.
|
||||
root_match = MAIN_ATTR_RE.search(raw)
|
||||
root_name = root_match.group(1) if root_match else ""
|
||||
ac_tables = set()
|
||||
if root_name:
|
||||
ac_tables = set(re.findall(r'<AdditionalColumns table="' + re.escape(root_name) + r'\.(\w+)"', raw))
|
||||
if ac_tables:
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_ts = borrowed_ts_index.get(owner_key, {})
|
||||
for tbl_name in sorted(ac_tables):
|
||||
dep_check_count += 1
|
||||
if tbl_name not in owner_ts:
|
||||
r.error(f'12. {ctx}: <AdditionalColumns table="{root_name}.{tbl_name}"> — TabularSection.{tbl_name} not borrowed in extension')
|
||||
check12_ok = False
|
||||
|
||||
for mi in missing_items:
|
||||
r.warn(f'12. {ctx}: references {mi} not borrowed in extension')
|
||||
check12_ok = False
|
||||
@@ -886,6 +1042,228 @@ def main():
|
||||
elif check13_ok:
|
||||
r.ok('13. TypeLink: clean')
|
||||
|
||||
# --- Check 14: пути Объект.* заимствованных форм против конфигурации-источника ---
|
||||
# Требует -ConfigPath: отличить живой путь от висячего можно только по исходному объекту.
|
||||
# «Объект.Партнер» валиден и без заимствования реквизита (наследуется от базы), а «Объект.Товары.Артикул»
|
||||
# не разрешится нигде, если Артикул — не колонка ТЧ и не колонка из <Columns> самой формы.
|
||||
# Такой путь платформа отвергает на загрузке: «Неверный путь к данным».
|
||||
if not r.stopped and borrowed_forms_with_tree:
|
||||
if not config_path_arg:
|
||||
r.out('[INFO] 14. Пути Объект.* против конфигурации-источника не проверялись: не задан -ConfigPath')
|
||||
else:
|
||||
cfg_root = config_path_arg
|
||||
if not os.path.isabs(cfg_root):
|
||||
cfg_root = os.path.join(os.getcwd(), cfg_root)
|
||||
if os.path.exists(cfg_root) and not os.path.isdir(cfg_root):
|
||||
cfg_root = os.path.dirname(cfg_root)
|
||||
|
||||
if not os.path.isfile(os.path.join(cfg_root, 'Configuration.xml')):
|
||||
r.warn(f"14. -ConfigPath '{config_path_arg}': Configuration.xml не найден — проверка путей пропущена")
|
||||
else:
|
||||
check14_ok = True
|
||||
path_check_count = 0
|
||||
|
||||
for bf in borrowed_forms_with_tree:
|
||||
raw = bf['RawText']
|
||||
ctx = bf['Context']
|
||||
# Корень путей — имя основного реквизита формы (см. проверку 12). Нет его ни в
|
||||
# <Attributes> формы, ни в <BaseForm> — путей с корнем не бывает, проверять нечего.
|
||||
root_match14 = MAIN_ATTR_RE.search(raw)
|
||||
if root_match14 is None:
|
||||
continue
|
||||
root_name14 = root_match14.group(1)
|
||||
# У динамического списка набор полей — результат его запроса, а не состав объекта:
|
||||
# туда входят и стандартные поля списка (Ref, Date, DefaultPicture), и псевдонимы
|
||||
# запроса. Сверять такие пути с ChildObjects объекта нельзя — будут ложные ошибки
|
||||
# (корпусная проверка: 3383 таких сегмента на 1094 формах списка УТ).
|
||||
if '>cfg:DynamicList<' in root_match14.group(0):
|
||||
continue
|
||||
owner_key = ctx.split('.Form.')[0]
|
||||
owner_parts = owner_key.split('.', 1)
|
||||
if len(owner_parts) < 2:
|
||||
continue
|
||||
owner_type, owner_name = owner_parts
|
||||
owner_dir = CHILD_TYPE_DIR_MAP.get(owner_type)
|
||||
if not owner_dir:
|
||||
continue
|
||||
src_obj_file = os.path.join(cfg_root, owner_dir, f'{owner_name}.xml')
|
||||
if not os.path.isfile(src_obj_file):
|
||||
r.warn(f'14. {ctx}: объект-источник не найден в конфигурации ({owner_dir}/{owner_name}.xml)')
|
||||
continue
|
||||
|
||||
# Имена, доступные первым сегментом пути: реквизиты и ТЧ объекта-источника.
|
||||
# Плюс для каждой ТЧ — её колонки: второй сегмент проверяем по ним (именно там
|
||||
# и жил дефект — Объект.Товары.Артикул при живой ТЧ Товары).
|
||||
src_names = set()
|
||||
src_ts_columns = {}
|
||||
src_tree = etree.parse(src_obj_file, etree.XMLParser(remove_blank_text=True))
|
||||
src_obj_el = None
|
||||
for c in src_tree.getroot():
|
||||
if isinstance(c.tag, str):
|
||||
src_obj_el = c
|
||||
break
|
||||
src_child_objects = src_obj_el.find(f'{{{MD}}}ChildObjects') if src_obj_el is not None else None
|
||||
if src_child_objects is not None:
|
||||
for sub in src_child_objects:
|
||||
if not isinstance(sub.tag, str):
|
||||
continue
|
||||
sub_ln = etree.QName(sub.tag).localname
|
||||
# У регистра дочерние объекты — Dimension/Resource, а не Attribute: без них
|
||||
# замена корня превратила бы тихий пропуск в ложные ошибки на форме записи.
|
||||
if sub_ln not in ('Attribute', 'Dimension', 'Resource', 'TabularSection'):
|
||||
continue
|
||||
name_el = sub.find(f'{{{MD}}}Properties/{{{MD}}}Name')
|
||||
if name_el is None or not name_el.text:
|
||||
continue
|
||||
sub_name = name_el.text.strip()
|
||||
src_names.add(sub_name)
|
||||
if sub_ln != 'TabularSection':
|
||||
continue
|
||||
cols = set()
|
||||
for col_name in sub.findall(f'{{{MD}}}ChildObjects/{{{MD}}}Attribute/{{{MD}}}Properties/{{{MD}}}Name'):
|
||||
if col_name.text:
|
||||
cols.add(col_name.text.strip())
|
||||
src_ts_columns[sub_name] = cols
|
||||
# Плюс колонки, объявленные в самой форме через <Columns>/<AdditionalColumns table="Объект.X">
|
||||
root_pat14 = re.escape(root_name14)
|
||||
for acm in re.finditer(r'<AdditionalColumns table="' + root_pat14 + r'\.(\w+)">(.*?)</AdditionalColumns>', raw, re.DOTALL):
|
||||
tbl = acm.group(1)
|
||||
cols = src_ts_columns.setdefault(tbl, set())
|
||||
for cm in re.finditer(r'<Column name="(\w+)"', acm.group(2)):
|
||||
cols.add(cm.group(1))
|
||||
|
||||
bad_paths = {}
|
||||
for m in re.finditer(r'<(?:\w+:)?\w*DataPath[^>]*>' + root_pat14 + r'\.([^<]+)</(?:\w+:)?\w*DataPath>', raw):
|
||||
segments = m.group(1).split('.')
|
||||
seg0 = segments[0]
|
||||
path_check_count += 1
|
||||
if seg0 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
if seg0 not in src_names:
|
||||
bad_paths[f'{root_name14}.{seg0}'] = f'у {owner_key} нет такого реквизита или табличной части'
|
||||
continue
|
||||
# Второй сегмент проверяем только для табличных частей: у ссылочного реквизита
|
||||
# он ведёт в чужой объект, и это уже другая проверка.
|
||||
if len(segments) < 2 or seg0 not in src_ts_columns:
|
||||
continue
|
||||
seg1 = segments[1]
|
||||
if seg1 in STANDARD_OBJECT_FIELDS:
|
||||
continue
|
||||
# Итог колонки — псевдополе платформы: Total<Колонка> при живой колонке законен
|
||||
if seg1.startswith('Total') and seg1[5:] in src_ts_columns[seg0]:
|
||||
continue
|
||||
if seg1 not in src_ts_columns[seg0]:
|
||||
bad_paths[f'{root_name14}.{seg0}.{seg1}'] = f'у табличной части {seg0} нет колонки {seg1}, и <Columns> формы её не объявляет'
|
||||
|
||||
for bad in sorted(bad_paths):
|
||||
r.error(f"14. {ctx}: путь '{bad}' — {bad_paths[bad]}")
|
||||
check14_ok = False
|
||||
|
||||
if check14_ok:
|
||||
r.ok(f'14. Object paths vs source config: {path_check_count} checked')
|
||||
|
||||
# --- Check 15: основные роли расширения не дают прав на заимствованные объекты ---
|
||||
# Платформа: «Назначение прав доступа на заимствованные объекты основными ролями в
|
||||
# расширениях недопустимо». Роль вне <DefaultRoles> так делать вправе — проверяем только
|
||||
# основные. Ловится статически, а по симптому (отказ загрузки) причина не читается.
|
||||
default_role_nodes = cfg_node.findall('md:Properties/md:DefaultRoles/xr:Item', NS)
|
||||
if default_role_nodes:
|
||||
adopted_cache = {}
|
||||
|
||||
def is_object_adopted(type_name, obj_name):
|
||||
key = f"{type_name}.{obj_name}"
|
||||
if key in adopted_cache:
|
||||
return adopted_cache[key]
|
||||
adopted_cache[key] = False
|
||||
dir_name = CHILD_TYPE_DIR_MAP.get(type_name)
|
||||
if dir_name:
|
||||
obj_path = os.path.join(config_dir, dir_name, obj_name + '.xml')
|
||||
if os.path.isfile(obj_path):
|
||||
try:
|
||||
obj_root = etree.parse(obj_path).getroot()
|
||||
ob = obj_root.find(f'md:{type_name}/md:Properties/md:ObjectBelonging', NS)
|
||||
if ob is not None and (ob.text or '') == 'Adopted':
|
||||
adopted_cache[key] = True
|
||||
except Exception:
|
||||
pass
|
||||
return adopted_cache[key]
|
||||
|
||||
check15_ok = True
|
||||
check15_count = 0
|
||||
roles_ns = {'r': 'http://v8.1c.ru/8.2/roles'}
|
||||
for rn in default_role_nodes:
|
||||
m = re.match(r'^Role\.(.+)$', rn.text or '')
|
||||
if not m:
|
||||
continue
|
||||
def_role_name = m.group(1)
|
||||
rights_path = os.path.join(config_dir, 'Roles', def_role_name, 'Ext', 'Rights.xml')
|
||||
if not os.path.isfile(rights_path):
|
||||
continue
|
||||
try:
|
||||
rights_root = etree.parse(rights_path).getroot()
|
||||
except Exception:
|
||||
continue
|
||||
for name_node in rights_root.findall('r:object/r:name', roles_ns):
|
||||
full_name = name_node.text or ''
|
||||
segs = full_name.split('.')
|
||||
# Configuration.* — права самого расширения, не объект; заимствования там нет.
|
||||
if len(segs) < 2 or segs[0] == 'Configuration':
|
||||
continue
|
||||
check15_count += 1
|
||||
if is_object_adopted(segs[0], segs[1]):
|
||||
r.error(f"15. Роль '{def_role_name}' входит в DefaultRoles и даёт права на заимствованный "
|
||||
f"{segs[0]}.{segs[1]} ({full_name}): платформа это запрещает. "
|
||||
"Вынесите такие права в отдельную роль вне DefaultRoles.")
|
||||
check15_ok = False
|
||||
if check15_ok and check15_count > 0:
|
||||
r.ok(f'15. Основные роли: прав на заимствованные объекты нет ({check15_count} checked)')
|
||||
|
||||
if r.stopped:
|
||||
r.finalize(out_file)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check 16: модуль заимствованного объекта и пометка расширенного свойства ---
|
||||
# Свойство <xr:PropertyState> появилось в формате 2.19 (8.3.26); ниже платформа его молча
|
||||
# выбрасывает, поэтому там проверять нечего. С 2.19 состояние обязано соответствовать факту:
|
||||
# есть файл модуля — есть пометка, и наоборот. Перекос платформа принимает (проверено на
|
||||
# стенде), но выгрузка Конфигуратора так не выглядит — отсюда предупреждение, а не ошибка.
|
||||
if version_rank >= 219 and child_obj_node is not None:
|
||||
state_issues = []
|
||||
state_checked = 0
|
||||
for child in child_obj_node:
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
type_name = etree.QName(child.tag).localname
|
||||
if type_name not in MODULE_KINDS_BY_TYPE or type_name not in CHILD_TYPE_DIR_MAP:
|
||||
continue
|
||||
obj_name_val = (child.text or '').strip()
|
||||
if not obj_name_val:
|
||||
continue
|
||||
type_dir = os.path.join(config_dir, CHILD_TYPE_DIR_MAP[type_name])
|
||||
obj_file = os.path.join(type_dir, f'{obj_name_val}.xml')
|
||||
if not os.path.isfile(obj_file):
|
||||
continue
|
||||
with open(obj_file, 'r', encoding='utf-8-sig') as f:
|
||||
obj_text = f.read()
|
||||
if '<ObjectBelonging>Adopted</ObjectBelonging>' not in obj_text:
|
||||
continue
|
||||
|
||||
for kind in MODULE_KINDS_BY_TYPE[type_name]:
|
||||
state_checked += 1
|
||||
has_file = os.path.isfile(os.path.join(type_dir, obj_name_val, 'Ext', f'{kind}.bsl'))
|
||||
has_flag = f'<xr:Property>{kind}</xr:Property>' in obj_text
|
||||
if has_file and not has_flag:
|
||||
state_issues.append(f'{type_name}.{obj_name_val} — есть {kind}.bsl, но нет <xr:PropertyState> для {kind}')
|
||||
elif has_flag and not has_file:
|
||||
state_issues.append(f'{type_name}.{obj_name_val} — есть <xr:PropertyState> для {kind}, но нет {kind}.bsl')
|
||||
|
||||
if state_checked > 0:
|
||||
if not state_issues:
|
||||
r.ok(f'16. Модули заимствованных объектов: пометки расширенных свойств согласованы ({state_checked})')
|
||||
else:
|
||||
for issue in state_issues:
|
||||
r.warn(f'16. {issue}')
|
||||
|
||||
# --- Breadcrumb: controlled methods (&ИзменениеИКонтроль) drift is not checked here ---
|
||||
ctrl_count = 0
|
||||
for dp, _dn, files in os.walk(config_dir):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# db-create v1.14 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -46,7 +46,7 @@
|
||||
.\db-create.ps1 -InfoBasePath "C:\Bases\NewDB" -UseTemplate "C:\Templates\config.cf" -AddToList -ListName "Новая база"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-create v1.10 — Create 1C information base
|
||||
# db-create v1.14 — Create 1C information base
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -270,7 +288,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -291,7 +309,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -310,11 +328,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -355,7 +393,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -379,15 +417,15 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate template ---
|
||||
if args.UseTemplate and not os.path.exists(args.UseTemplate):
|
||||
print(f"Error: template file not found: {args.UseTemplate}", file=sys.stderr)
|
||||
print(f"Error: template file not found: {args.UseTemplate}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
@@ -414,10 +452,9 @@ def main():
|
||||
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(f"Error creating information base (code: {exit_code})")
|
||||
print_platform_output(result)
|
||||
sys.exit(exit_code)
|
||||
|
||||
@@ -474,10 +511,9 @@ def main():
|
||||
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(f"Error creating information base (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.16 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -49,7 +49,7 @@
|
||||
.\db-dump-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-cf v1.12 — Dump 1C configuration to CF file
|
||||
# db-dump-cf v1.16 — Dump 1C configuration to CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -375,7 +413,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -400,10 +438,10 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
@@ -414,7 +452,7 @@ def main():
|
||||
# --- 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)
|
||||
print("Error: ibcmd config save does not support -AllExtensions (use -Extension)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "save", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
@@ -437,9 +475,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -487,9 +525,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# db-dump-dt v1.15 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -39,7 +39,7 @@
|
||||
.\db-dump-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -OutputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-dt v1.11 — Dump 1C information base to DT file
|
||||
# db-dump-dt v1.15 — Dump 1C information base to DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -373,7 +411,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -398,10 +436,10 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
@@ -430,9 +468,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping information base (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -474,9 +512,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — information base was not dumped")
|
||||
else:
|
||||
print(f"Error dumping information base (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping information base (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,7 @@ allowed-tools:
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
Если в записи базы указан `configSrc` — используй как каталог выгрузки по умолчанию.
|
||||
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
|
||||
|
||||
## Команда
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.21 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -61,7 +61,7 @@
|
||||
.\db-dump-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Objects "Справочник.Номенклатура,Документ.Заказ"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
@@ -85,8 +85,10 @@ param(
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Changes", "Partial", "UpdateInfo")]
|
||||
[string]$Mode = "Changes",
|
||||
# Пустое значение = режим не задан. Прежнее умолчание Changes подставляется ниже, после
|
||||
# того как станет видно, перечислены ли объекты.
|
||||
[ValidateSet("", "Full", "Changes", "Partial", "UpdateInfo")]
|
||||
[string]$Mode = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Objects,
|
||||
@@ -101,6 +103,18 @@ param(
|
||||
[ValidateSet("Hierarchical", "Plain")]
|
||||
[string]$Format = "Hierarchical",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$ObjectsFile,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
@@ -111,6 +125,90 @@ param(
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
@@ -132,7 +230,7 @@ $script:IbcmdOwnedKeys = @(
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
@@ -415,8 +513,33 @@ if ($engine -eq "ibcmd") {
|
||||
}
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||
if ($ObjectsFile) {
|
||||
if (-not (Test-Path $ObjectsFile)) {
|
||||
Write-Host "Error: -ObjectsFile not found: $ObjectsFile" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$fromFile = @([System.IO.File]::ReadAllLines($ObjectsFile, [System.Text.Encoding]::UTF8) |
|
||||
ForEach-Object { $_.Trim() } | Where-Object { $_ -and -not $_.StartsWith('#') })
|
||||
$Objects = (@(@($Objects -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $fromFile) -join ',')
|
||||
}
|
||||
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||
if ($Objects) {
|
||||
if ($Mode -eq "UpdateInfo") {
|
||||
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||
Write-Host "Error: -Mode UpdateInfo does not take an object list — it only refreshes ConfigDumpInfo.xml" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($Mode -eq "Full" -or $Mode -eq "Changes") {
|
||||
Write-Host "[note] перечислены объекты — выгружаются только они; -Mode $Mode не применён" -ForegroundColor Yellow
|
||||
}
|
||||
$Mode = "Partial"
|
||||
}
|
||||
if (-not $Mode) { $Mode = "Changes" }
|
||||
if ($Mode -eq "Partial" -and -not $Objects) {
|
||||
Write-Host "Error: -Objects required for Partial mode" -ForegroundColor Red
|
||||
Write-Host "Error: -Objects or -ObjectsFile required for Partial mode" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -486,6 +609,11 @@ try {
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$arguments += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments += "/DumpConfigToFiles", "`"$ConfigDir`""
|
||||
$arguments += "-Format", $Format
|
||||
|
||||
@@ -530,7 +658,7 @@ try {
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-dump-xml v1.14 — Dump 1C configuration to XML files
|
||||
# db-dump-xml v1.21 — Dump 1C configuration to XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -50,10 +72,95 @@ IBCMD_OWNED_KEYS = [
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
@@ -98,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +326,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +364,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +381,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +400,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +453,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -366,14 +489,18 @@ def main():
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory for configuration dump")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Changes",
|
||||
choices=["Full", "Changes", "Partial", "UpdateInfo"],
|
||||
default="",
|
||||
choices=["", "Full", "Changes", "Partial", "UpdateInfo"],
|
||||
help="Dump mode (default: Changes)",
|
||||
)
|
||||
parser.add_argument("-Objects", default="", help="Comma-separated metadata object names (for Partial mode)")
|
||||
parser.add_argument("-ObjectsFile", default="")
|
||||
parser.add_argument("-Extension", default="", help="Extension name to dump")
|
||||
parser.add_argument("-AllExtensions", action="store_true", help="Dump all extensions")
|
||||
parser.add_argument(
|
||||
@@ -388,7 +515,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -414,15 +541,40 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Список объектов приходит либо строкой, либо файлом: файл нужен, чтобы не перепечатывать
|
||||
# то, что уже напечатал другой навык (например /db-repo update со списком полученных объектов).
|
||||
if args.ObjectsFile:
|
||||
if not os.path.exists(args.ObjectsFile):
|
||||
print("Error: -ObjectsFile not found: %s" % args.ObjectsFile)
|
||||
sys.exit(1)
|
||||
with open(args.ObjectsFile, encoding="utf-8-sig") as f:
|
||||
from_file = [s.strip() for s in f.read().splitlines()
|
||||
if s.strip() and not s.strip().startswith("#")]
|
||||
inline = [s.strip() for s in args.Objects.split(",") if s.strip()]
|
||||
args.Objects = ",".join(inline + from_file)
|
||||
# Перечислены объекты — операция частичная. Иначе список молча игнорировался бы: умолчание
|
||||
# Changes выгружает «изменённое с прошлой выгрузки», а не то, что просили.
|
||||
if args.Objects:
|
||||
if args.Mode == "UpdateInfo":
|
||||
# Не «шире/уже», а другая операция: обновление ConfigDumpInfo без выгрузки файлов.
|
||||
print("Error: -Mode UpdateInfo does not take an object list — it only refreshes "
|
||||
"ConfigDumpInfo.xml")
|
||||
sys.exit(1)
|
||||
if args.Mode in ("Full", "Changes"):
|
||||
print("[note] перечислены объекты — выгружаются только они; -Mode %s не применён"
|
||||
% args.Mode)
|
||||
args.Mode = "Partial"
|
||||
if not args.Mode:
|
||||
args.Mode = "Changes"
|
||||
if args.Mode == "Partial" and not args.Objects:
|
||||
print("Error: -Objects required for Partial mode", file=sys.stderr)
|
||||
print("Error: -Objects or -ObjectsFile required for Partial mode")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Create output dir if needed ---
|
||||
@@ -433,12 +585,12 @@ def main():
|
||||
# --- 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)
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "export", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "UpdateInfo":
|
||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8", file=sys.stderr)
|
||||
print("Error: ibcmd config export does not support Mode UpdateInfo; use 1cv8")
|
||||
sys.exit(1)
|
||||
elif args.Mode == "Partial":
|
||||
obj_list = [o.strip() for o in args.Objects.split(",") if o.strip()]
|
||||
@@ -468,9 +620,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not exported")
|
||||
else:
|
||||
print(f"Error exporting configuration (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error exporting configuration (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -491,6 +643,11 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
arguments.extend(repository_args(repo))
|
||||
|
||||
arguments += ["/DumpConfigToFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
|
||||
@@ -529,7 +686,7 @@ def main():
|
||||
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)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
@@ -542,9 +699,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no files under {args.ConfigDir} — configuration was not dumped")
|
||||
else:
|
||||
print(f"Error dumping configuration (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping configuration (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -40,7 +40,19 @@ allowed-tools:
|
||||
"password": "",
|
||||
"aliases": ["dev", "разработка"],
|
||||
"branches": ["dev", "develop", "feature/*"],
|
||||
"configSrc": "C:\\WS\\myapp\\cfsrc"
|
||||
"configSrc": "C:\\WS\\myapp\\cfsrc",
|
||||
"repository": {
|
||||
"path": "\\\\srv01\\repo\\MyApp",
|
||||
"user": "Ivanov",
|
||||
"password": ""
|
||||
},
|
||||
"extensions": [
|
||||
{
|
||||
"name": "МоёРасширение",
|
||||
"src": "src\\cfe\\МоёРасширение",
|
||||
"repository": { "path": "\\\\srv01\\repo\\MyApp_Ext", "user": "Ivanov", "password": "" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
@@ -82,6 +94,35 @@ allowed-tools:
|
||||
| `aliases` | string[] | нет | Альтернативные имена для быстрого доступа |
|
||||
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`), привязанные к этой базе |
|
||||
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации |
|
||||
| `repository` | object | нет | Хранилище конфигурации: `path`, `user`, `password` (см. ниже) |
|
||||
| `extensions` | array | нет | Расширения: `name`, `src`, необязательное `repository` (см. ниже) |
|
||||
|
||||
### Хранилище конфигурации
|
||||
|
||||
База, подключённая к хранилищу конфигурации 1С, **не принимает ни одной операции конфигуратора**
|
||||
без реквизитов доступа к хранилищу — это касается не только `/db-repo`, но и `/db-load-xml`,
|
||||
`/db-dump-xml`, `/db-update`, `/db-load-git`. Реквизиты берутся из `repository` записи базы,
|
||||
передавать их в каждом вызове не нужно.
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
|------|-----|:------------:|----------|
|
||||
| `repository.path` | string | да | Каталог хранилища или `tcp://<хост>[:<порт>]/<имя>` |
|
||||
| `repository.user` | string | нет | Пользователь **хранилища**. Не наследуется от `user` базы |
|
||||
| `repository.password` | string | нет | Пароль пользователя хранилища |
|
||||
|
||||
У расширения **своё хранилище** со своим путём, поэтому одного `repository` мало:
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
|------|-----|:------------:|----------|
|
||||
| `extensions[].name` | string | да | Имя расширения, как в конфигурации |
|
||||
| `extensions[].src` | string | нет | Каталог XML-исходников расширения |
|
||||
| `extensions[].repository` | object | нет | Хранилище расширения. Расширение без хранилища — обычный случай |
|
||||
|
||||
Пароль хранилища — такой же секрет, как `password` базы; `.v8-project.json` в `.gitignore`.
|
||||
|
||||
> **Сетевое хранилище.** Адрес — `tcp://<хост>[:<порт>]/<имя>`, порт по умолчанию 1542.
|
||||
> Обслуживается сервером хранилища. Если он недоступен, платформа отвечает «Соединение с
|
||||
> хранилищем конфигурации не установлено» — тем же сообщением, что и при отсутствии реквизитов.
|
||||
|
||||
## Алгоритм разрешения базы данных
|
||||
|
||||
@@ -128,6 +169,7 @@ test Тестовая server srv01/MyApp_Test
|
||||
- path (для file) или server + ref (для server)
|
||||
- user, password (необязательно)
|
||||
- aliases, branches (необязательно)
|
||||
- если база под хранилищем конфигурации — `repository`: путь, пользователь, пароль
|
||||
|
||||
Добавь в массив `databases`. Если это первая база — установи как `default`.
|
||||
|
||||
@@ -159,3 +201,10 @@ test Тестовая server srv01/MyApp_Test
|
||||
```
|
||||
|
||||
> **Важно**: между `/N` и именем пробела нет. Между `/P` и паролем пробела нет. Если пароль пустой — опусти `/P` целиком.
|
||||
|
||||
**Хранилище конфигурации** (если у базы задан `repository`) — скрипты навыков подставляют
|
||||
сами, сопоставляя параметры соединения с записью реестра:
|
||||
```
|
||||
/ConfigurationRepositoryF"<path>" /ConfigurationRepositoryN"<user>" /ConfigurationRepositoryP"<password>"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.17 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -49,7 +49,7 @@
|
||||
.\db-load-cf.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "ext.cfe" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-cf v1.13 — Load 1C configuration from CF file
|
||||
# db-load-cf v1.17 — Load 1C configuration from CF file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -393,7 +431,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -418,21 +456,21 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
print("Error: ibcmd config load does not support -AllExtensions (use -Extension)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "load", f"--db-path={args.InfoBasePath}"]
|
||||
if args.Extension:
|
||||
@@ -451,7 +489,7 @@ def main():
|
||||
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)
|
||||
print(f"Error loading configuration (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -495,7 +533,7 @@ def main():
|
||||
if exit_code == 0:
|
||||
print(f"Configuration loaded successfully from: {args.InputFile}")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# db-load-dt v1.16 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -46,7 +46,7 @@
|
||||
.\db-load-dt.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "backup.dt"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-dt v1.12 — Load 1C information base from DT file
|
||||
# db-load-dt v1.16 — Load 1C information base from DT file
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -393,7 +431,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -418,15 +456,15 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
@@ -448,7 +486,7 @@ def main():
|
||||
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)
|
||||
print(f"Error restoring information base (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -490,7 +528,7 @@ def main():
|
||||
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)
|
||||
print(f"Error restoring information base (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# db-load-git v1.26 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -64,7 +64,7 @@
|
||||
.\db-load-git.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -DryRun
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
@@ -110,6 +110,21 @@ param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$UpdateDB,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
@@ -120,6 +135,115 @@ param(
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
function Write-RepositoryHints {
|
||||
param([string]$LogText)
|
||||
if (-not $LogText) { return }
|
||||
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||
}
|
||||
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||
$obj = $m.Groups[1].Value
|
||||
if ($obj -eq 'Configuration') {
|
||||
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
@@ -141,7 +265,7 @@ $script:IbcmdOwnedKeys = @(
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
@@ -394,6 +518,41 @@ function Write-PlatformOutput {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
if ($engine -eq "ibcmd") {
|
||||
@@ -627,6 +786,11 @@ try {
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$arguments += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
$arguments += "-listFile", "`"$listFile`""
|
||||
$arguments += "-Format", $Format
|
||||
@@ -654,7 +818,7 @@ try {
|
||||
# --- Execute ---
|
||||
Write-Host ""
|
||||
Write-Host "Executing partial configuration load..."
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
@@ -667,6 +831,7 @@ try {
|
||||
Write-Host "Error loading configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
@@ -676,6 +841,17 @@ try {
|
||||
}
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
Write-RepositoryHints $logContent
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-git v1.18 — Load Git changes into 1C database
|
||||
# db-load-git v1.26 — Load Git changes into 1C database
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
def write_repository_hints(log_text):
|
||||
if not log_text:
|
||||
return
|
||||
if "текущая конфигурация помещена в хранилище" in log_text:
|
||||
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
|
||||
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
|
||||
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
|
||||
obj = m.group(1)
|
||||
if obj == "Configuration":
|
||||
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
|
||||
print(' /db-repo lock <база> -Objects "Конфигурация"')
|
||||
else:
|
||||
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
|
||||
if "Соединение с хранилищем конфигурации не установлено" in log_text:
|
||||
print("[hint] соединение с хранилищем не установлено. Две причины:")
|
||||
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
|
||||
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -322,6 +466,38 @@ def print_platform_output(result):
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -406,6 +582,9 @@ def main():
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration (git repo)")
|
||||
parser.add_argument(
|
||||
"-Source",
|
||||
@@ -424,13 +603,17 @@ 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")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
|
||||
help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
|
||||
parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
|
||||
help="Extra ibcmd arguments in --key=value form")
|
||||
known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
|
||||
argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
|
||||
args = parser.parse_args(argv)
|
||||
args = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -448,10 +631,10 @@ def main():
|
||||
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)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Resolve additional arguments for the selected engine ---
|
||||
@@ -468,19 +651,19 @@ def main():
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
print(f"Error: config directory not found: {args.ConfigDir}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Commit mode ---
|
||||
if args.Source == "Commit" and not args.CommitRange:
|
||||
print("Error: -CommitRange required for Source=Commit", file=sys.stderr)
|
||||
print("Error: -CommitRange required for Source=Commit")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Check git ---
|
||||
try:
|
||||
subprocess.run(["git", "--version"], capture_output=True, text=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: git not found in PATH", file=sys.stderr)
|
||||
print("Error: git not found in PATH")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Get changed files from Git ---
|
||||
@@ -559,10 +742,10 @@ def main():
|
||||
config_files.append(rel_path)
|
||||
|
||||
if support_skipped:
|
||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):", file=sys.stderr)
|
||||
print("[ВНИМАНИЕ] Состояние поддержки изменено в коммите, но частично не загружается (исключено):")
|
||||
for sf in support_skipped:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).", file=sys.stderr)
|
||||
print(f" - {sf}")
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой (db-load-xml -Mode Full).")
|
||||
|
||||
if len(config_files) == 0:
|
||||
print("No configuration files found in changes")
|
||||
@@ -586,10 +769,10 @@ def main():
|
||||
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)
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)", file=sys.stderr)
|
||||
print("Error: ibcmd config import does not support -AllExtensions (use -Extension or 1cv8)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + config_files
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
@@ -606,7 +789,7 @@ def main():
|
||||
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)
|
||||
print(f"Error loading changes (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
print(f"Changes loaded successfully ({len(config_files)} files)")
|
||||
exit_code = 0
|
||||
@@ -624,7 +807,7 @@ def main():
|
||||
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(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
@@ -646,6 +829,11 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
arguments.extend(repository_args(repo))
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
arguments += ["-listFile", f'"{list_file}"']
|
||||
arguments += ["-Format", args.Format]
|
||||
@@ -671,7 +859,7 @@ def main():
|
||||
# --- Execute ---
|
||||
print("")
|
||||
print("Executing partial configuration load...")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
@@ -681,8 +869,9 @@ def main():
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
@@ -695,6 +884,22 @@ def main():
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
write_repository_hints(log_content)
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for line in silent_failures:
|
||||
print(f" {line}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -34,6 +34,7 @@ allowed-tools:
|
||||
Если файла нет — предложи `/db-list add`.
|
||||
Если использованная база не зарегистрирована — после выполнения предложи добавить через `/db-list add`.
|
||||
Если в записи базы указан `configSrc` — используй как каталог загрузки по умолчанию.
|
||||
Для `-Extension` каталог берётся из `extensions[].src` записи базы, если он там указан.
|
||||
|
||||
## Команда
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.28 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -61,7 +61,7 @@
|
||||
.\db-load-xml.ps1 -InfoBasePath "C:\Bases\MyDB" -ConfigDir "C:\src" -Mode Partial -Files "Catalogs/Номенклатура.xml,Catalogs/Номенклатура/Ext/ObjectModule.bsl"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
@@ -85,8 +85,10 @@ param(
|
||||
[string]$ConfigDir,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[ValidateSet("Full", "Partial")]
|
||||
[string]$Mode = "Full",
|
||||
# Пустое значение = режим не задан. Прежнее умолчание Full подставляется ниже, после того
|
||||
# как станет видно, перечислены ли файлы.
|
||||
[ValidateSet("", "Full", "Partial")]
|
||||
[string]$Mode = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Files,
|
||||
@@ -110,6 +112,15 @@ param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
@@ -120,6 +131,115 @@ param(
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
function Write-RepositoryHints {
|
||||
param([string]$LogText)
|
||||
if (-not $LogText) { return }
|
||||
if ($LogText -match 'текущая конфигурация помещена в хранилище') {
|
||||
Write-Host "[hint] полная загрузка в базу, подключённую к хранилищу, невозможна." -ForegroundColor Yellow
|
||||
Write-Host " Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock" -ForegroundColor Yellow
|
||||
}
|
||||
foreach ($m in [regex]::Matches($LogText, 'объект метаданных ([^\s]+) не захвачен в хранилище')) {
|
||||
$obj = $m.Groups[1].Value
|
||||
if ($obj -eq 'Configuration') {
|
||||
Write-Host "[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:" -ForegroundColor Yellow
|
||||
Write-Host " /db-repo lock <база> -Objects `"Конфигурация`"" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects `"$obj`"" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
if ($LogText -match 'Соединение с хранилищем конфигурации не установлено') {
|
||||
Write-Host "[hint] соединение с хранилищем не установлено. Две причины:" -ForegroundColor Yellow
|
||||
Write-Host " реквизиты неизвестны — добавьте `"repository`" в запись базы в .v8-project.json (см. /db-list);" -ForegroundColor Yellow
|
||||
Write-Host " либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
@@ -158,7 +278,7 @@ $script:IbcmdOwnedKeys = @(
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
@@ -416,6 +536,41 @@ function Write-PlatformOutput {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
@@ -440,6 +595,16 @@ if (-not (Test-Path $ConfigDir)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание Full
|
||||
# заменило бы всю конфигурацию базы.
|
||||
if ($Files -or $ListFile) {
|
||||
if ($Mode -eq "Full") {
|
||||
Write-Host "[note] перечислены файлы — загружаются только они; -Mode Full не применён" -ForegroundColor Yellow
|
||||
}
|
||||
$Mode = "Partial"
|
||||
}
|
||||
if (-not $Mode) { $Mode = "Full" }
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
if ($Mode -eq "Partial" -and -not $Files -and -not $ListFile) {
|
||||
Write-Host "Error: -Files or -ListFile required for Partial mode" -ForegroundColor Red
|
||||
@@ -459,7 +624,7 @@ try {
|
||||
}
|
||||
if ($AllExtensions) {
|
||||
$arguments = @("infobase", "config", "import", "all-extensions", "$ConfigDir", "--db-path=$InfoBasePath")
|
||||
} elseif ($Mode -eq "Partial" -or $Files -or $ListFile) {
|
||||
} elseif ($Mode -eq "Partial") {
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
$fileList = @()
|
||||
if ($ListFile) {
|
||||
@@ -532,6 +697,11 @@ try {
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$arguments += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments += "/LoadConfigFromFiles", "`"$ConfigDir`""
|
||||
|
||||
if ($Mode -eq "Full") {
|
||||
@@ -596,7 +766,7 @@ try {
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
@@ -607,28 +777,7 @@ try {
|
||||
}
|
||||
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
$fatalLogPatterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу'
|
||||
)
|
||||
$silentFailures = @()
|
||||
if ($logContent) {
|
||||
foreach ($line in ($logContent -split "`r?`n")) {
|
||||
foreach ($pat in $fatalLogPatterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$silentFailures += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
@@ -646,11 +795,13 @@ try {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
Write-RepositoryHints $logContent
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
$msg = "[warning] log contains $($silentFailures.Count) rejection(s) — platform loaded config but dropped properties/refs"
|
||||
if (-not $StrictLog) { $msg += " (pass -StrictLog to treat as error)" }
|
||||
Write-Host $msg -ForegroundColor Yellow
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-load-xml v1.19 — Load 1C configuration from XML files
|
||||
# db-load-xml v1.28 — Load 1C configuration from XML files
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -50,10 +72,116 @@ IBCMD_OWNED_KEYS = [
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
|
||||
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
|
||||
def write_repository_hints(log_text):
|
||||
if not log_text:
|
||||
return
|
||||
if "текущая конфигурация помещена в хранилище" in log_text:
|
||||
print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
|
||||
print(" Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
|
||||
for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
|
||||
obj = m.group(1)
|
||||
if obj == "Configuration":
|
||||
print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
|
||||
print(' /db-repo lock <база> -Objects "Конфигурация"')
|
||||
else:
|
||||
print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
|
||||
if "Соединение с хранилищем конфигурации не установлено" in log_text:
|
||||
print("[hint] соединение с хранилищем не установлено. Две причины:")
|
||||
print(' реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
|
||||
print(" либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
@@ -98,7 +226,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +233,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +300,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +347,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +385,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +402,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +421,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -322,6 +466,38 @@ def print_platform_output(result):
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -330,7 +506,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -384,11 +560,14 @@ def main():
|
||||
parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
|
||||
parser.add_argument("-UserName", default="", help="1C user name")
|
||||
parser.add_argument("-Password", default="", help="1C user password")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
|
||||
parser.add_argument(
|
||||
"-Mode",
|
||||
default="Full",
|
||||
choices=["Full", "Partial"],
|
||||
default="",
|
||||
choices=["", "Full", "Partial"],
|
||||
help="Load mode (default: Full)",
|
||||
)
|
||||
parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
|
||||
@@ -413,7 +592,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -441,34 +620,42 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate config dir ---
|
||||
if not os.path.exists(args.ConfigDir):
|
||||
print(f"Error: config directory not found: {args.ConfigDir}", file=sys.stderr)
|
||||
print(f"Error: config directory not found: {args.ConfigDir}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate Partial mode ---
|
||||
# Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
|
||||
# Full заменило бы всю конфигурацию базы.
|
||||
if args.Files or args.ListFile:
|
||||
if args.Mode == "Full":
|
||||
print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
|
||||
args.Mode = "Partial"
|
||||
if not args.Mode:
|
||||
args.Mode = "Full"
|
||||
if args.Mode == "Partial" and not args.Files and not args.ListFile:
|
||||
print("Error: -Files or -ListFile required for Partial mode", file=sys.stderr)
|
||||
print("Error: -Files or -ListFile required for Partial mode")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
|
||||
if engine == "ibcmd":
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||
sys.exit(1)
|
||||
if args.AllExtensions:
|
||||
arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
|
||||
elif args.Mode == "Partial" or args.Files or args.ListFile:
|
||||
elif args.Mode == "Partial":
|
||||
# partial: import specific files (relative to ConfigDir)
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
print(f"Error: list file not found: {args.ListFile}")
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
file_list = [ln.strip() for ln in f if ln.strip()]
|
||||
@@ -477,7 +664,7 @@ def main():
|
||||
else:
|
||||
file_list = []
|
||||
if not file_list:
|
||||
print("Error: -Files or -ListFile required for partial import", file=sys.stderr)
|
||||
print("Error: -Files or -ListFile required for partial import")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "import", "files"] + file_list
|
||||
arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
|
||||
@@ -499,7 +686,7 @@ def main():
|
||||
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)
|
||||
print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
print(f"Configuration loaded successfully from: {args.ConfigDir}")
|
||||
exit_code = 0
|
||||
@@ -517,7 +704,7 @@ def main():
|
||||
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(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
print_platform_output(ar)
|
||||
sys.exit(exit_code)
|
||||
|
||||
@@ -539,6 +726,11 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
arguments.extend(repository_args(repo))
|
||||
|
||||
arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']
|
||||
|
||||
if args.Mode == "Full":
|
||||
@@ -549,7 +741,7 @@ def main():
|
||||
# Build list file
|
||||
if args.ListFile:
|
||||
if not os.path.isfile(args.ListFile):
|
||||
print(f"Error: list file not found: {args.ListFile}", file=sys.stderr)
|
||||
print(f"Error: list file not found: {args.ListFile}")
|
||||
sys.exit(1)
|
||||
with open(args.ListFile, encoding="utf-8-sig") as f:
|
||||
raw_list = [ln.strip() for ln in f if ln.strip()]
|
||||
@@ -561,12 +753,12 @@ def main():
|
||||
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)
|
||||
print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
|
||||
for sf in support_files:
|
||||
print(f" - {sf}", file=sys.stderr)
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.", file=sys.stderr)
|
||||
print(f" - {sf}")
|
||||
print(" Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
|
||||
if not file_list:
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.", file=sys.stderr)
|
||||
print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
|
||||
sys.exit(1)
|
||||
generated_list_file = os.path.join(temp_dir, "load_list.txt")
|
||||
with open(generated_list_file, "w", encoding="utf-8-sig") as f:
|
||||
@@ -598,7 +790,7 @@ def main():
|
||||
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)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
@@ -614,22 +806,7 @@ def main():
|
||||
# --- Scan log for silent rejections ---
|
||||
# Platform often writes load-time rejections into /Out but exits with code 0.
|
||||
# These patterns flag cases where metadata was dropped or rejected silently.
|
||||
fatal_log_patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
]
|
||||
silent_failures = []
|
||||
if log_content:
|
||||
for line in log_content.splitlines():
|
||||
for pat in fatal_log_patterns:
|
||||
if pat in line:
|
||||
silent_failures.append(line.strip())
|
||||
break
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
|
||||
# --- Result ---
|
||||
# Default: mirror platform's verdict via exit code. Log content (including any
|
||||
@@ -638,7 +815,7 @@ def main():
|
||||
if exit_code == 0:
|
||||
print("Load completed successfully")
|
||||
else:
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}", file=sys.stderr)
|
||||
print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
if log_content:
|
||||
print("--- Log ---")
|
||||
@@ -646,15 +823,20 @@ def main():
|
||||
print("--- End ---")
|
||||
|
||||
print_platform_output(result)
|
||||
write_repository_hints(log_content)
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
|
||||
# Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
|
||||
# отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
|
||||
# наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
|
||||
if silent_failures:
|
||||
suffix = "" if args.StrictLog else " (pass -StrictLog to treat as error)"
|
||||
print(
|
||||
f"[warning] log contains {len(silent_failures)} rejection(s) — "
|
||||
f"platform loaded config but dropped properties/refs{suffix}",
|
||||
file=sys.stderr,
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for f in silent_failures:
|
||||
print(f" {f}", file=sys.stderr)
|
||||
print(f" {f}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
name: db-repo
|
||||
description: Работа с хранилищем конфигурации 1С. Используй когда нужно захватить объекты, поместить изменения в хранилище конфигурации, получить изменения из него, подключить базу к хранилищу
|
||||
argument-hint: <lock|unlock|commit|update> [database] -Objects "<объекты>"
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
- Glob
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
# /db-repo — Хранилище конфигурации 1С
|
||||
|
||||
Захват и помещение объектов, получение изменений, подключение базы, история версий,
|
||||
администрирование хранилища.
|
||||
|
||||
> Хранилище конфигурации 1С, а не Git-репозиторий.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/db-repo lock [database] -Objects "Справочник.Номенклатура"
|
||||
/db-repo commit [database] -Objects "Справочник.Номенклатура" -Comment "Добавлен Артикул"
|
||||
/db-repo unlock [database] -Objects "Справочник.Номенклатура"
|
||||
/db-repo update [database]
|
||||
```
|
||||
|
||||
## Порядок работы
|
||||
|
||||
В базу, подключённую к хранилищу, исходники грузятся **только частично** и **только по захваченным**
|
||||
объектам. Выполняй строго по шагам:
|
||||
|
||||
```
|
||||
0. /db-repo update <база> — начать с актуального состояния
|
||||
1. /db-repo lock <база> -Objects "Справочник.Номенклатура"
|
||||
2. если шаг 0 или 1 напечатал «локальная конфигурация изменена, получено объектов из хранилища: N» —
|
||||
выгрузи названные объекты: /db-dump-xml <база> <каталог> -Mode Partial -ObjectsFile "<файл из вывода>"
|
||||
3. правки в исходниках: /meta-edit, /form-edit, /skd-edit, /meta-compile и т. д.
|
||||
4. /db-load-xml <каталог> <база> -Mode Partial -Files "Catalogs/Номенклатура.xml,…" -UpdateDB
|
||||
5. /db-repo commit <база> -Objects "Справочник.Номенклатура" -Comment "…"
|
||||
```
|
||||
|
||||
Шаг 0 стоит делать всегда, когда работа не продолжается сразу после предыдущего цикла: правки
|
||||
должны опираться на актуальное состояние — в том числе тех объектов, которые ты не меняешь, но
|
||||
используешь.
|
||||
|
||||
Шаг 2 пропускать нельзя: захват и обновление подтягивают из хранилища свежие версии, и загрузка
|
||||
исходников, снятых раньше, откатит чужие изменения — молча, без ошибки.
|
||||
|
||||
**Что вообще захватывается.** Отдельные объекты хранилища — сам объект, а также его **формы,
|
||||
макеты и команды**. Реквизиты, табличные части, измерения и ресурсы отдельными объектами **не
|
||||
являются**: они правятся в составе владельца.
|
||||
|
||||
| Что правишь | Что захватывать |
|
||||
|-------------|-----------------|
|
||||
| Реквизит, табличную часть, измерение, ресурс, модуль объекта | сам объект: `Справочник.Контрагенты` |
|
||||
| Существующую форму, макет, команду | её саму: `Справочник.Контрагенты.Форма.ФормаЭлемента` |
|
||||
| Добавляешь новую форму, макет, команду | объект-владельца; при помещении назови и новый объект |
|
||||
| Добавляешь новый объект конфигурации | только корень: `Конфигурация`. Самого объекта ещё нет — захватить его нельзя; при помещении назови и его |
|
||||
|
||||
Захватывай минимум того, что правишь: чем шире захват, тем больше конфликтов с коллегами.
|
||||
Захват объекта его формы и макеты не захватывает — для этого есть `-WithChildren`.
|
||||
|
||||
## Параметры подключения
|
||||
|
||||
Прочитай `.v8-project.json` из корня проекта. Возьми `v8path` и разреши базу:
|
||||
1. Если пользователь указал параметры подключения — используй напрямую
|
||||
2. Если указал базу по имени — ищи по id / alias / name
|
||||
3. Если не указал — сопоставь текущую ветку Git с `databases[].branches`
|
||||
4. Если ветка не совпала — используй `default`
|
||||
|
||||
Реквизиты хранилища передавать не нужно: запись базы находится по переданным параметрам
|
||||
соединения (`-InfoBasePath` либо `-InfoBaseServer` + `-InfoBaseRef`), реквизиты берутся из её
|
||||
`repository`. Задать их явно можно параметрами `-Repository*`.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command <подкоманда> <параметры>
|
||||
```
|
||||
|
||||
### Рабочий цикл
|
||||
|
||||
| Подкоманда | Что делает |
|
||||
|------------|------------|
|
||||
| `lock` | Захватить объекты |
|
||||
| `unlock` | Отменить захват |
|
||||
| `commit` | Поместить изменения в хранилище |
|
||||
| `update` | Получить изменения из хранилища |
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Обязательный | Описание |
|
||||
|----------|:------------:|----------|
|
||||
| `-InfoBasePath <путь>` | * | Файловая база |
|
||||
| `-InfoBaseServer <сервер>` | * | Сервер 1С |
|
||||
| `-InfoBaseRef <имя>` | * | Имя базы на сервере |
|
||||
| `-UserName <имя>` | нет | Пользователь базы |
|
||||
| `-Password <пароль>` | нет | Пароль пользователя базы |
|
||||
| `-Objects <список>` | усл. | Объекты через запятую. Для `lock`, `unlock`, `commit` обязателен, если не задан `-All` |
|
||||
| `-ObjectsFile <путь>` | нет | Файл со списком объектов, одно имя на строку |
|
||||
| `-All` | нет | Операция над всей конфигурацией — вместо `-Objects`, а не вместе с ним |
|
||||
| `-WithChildren` | нет | Вместе с подчинёнными объектами на полную глубину |
|
||||
| `-Comment <текст>` | нет | Комментарий к помещению (`commit`). Многострочный — как есть, с переводами строк |
|
||||
| `-KeepLocked` | нет | Оставить объекты захваченными после помещения |
|
||||
| `-Revised` | нет | Получать захваченные объекты, если потребуется |
|
||||
| `-Force` | нет | Разное по подкомандам — см. ниже |
|
||||
| `-Extension <имя>` | нет | Работать с хранилищем расширения |
|
||||
| `-RepositoryPath <путь>` | нет | Хранилище явно, вместо реестра |
|
||||
| `-RepositoryUser <имя>` | нет | Пользователь хранилища явно |
|
||||
| `-RepositoryPassword <пароль>` | нет | Пароль пользователя хранилища явно |
|
||||
| `-AdditionalV8Arguments <список>` | нет | Доп. аргументы `1cv8.exe` через запятую |
|
||||
|
||||
> `*` — нужен либо `-InfoBasePath`, либо пара `-InfoBaseServer` + `-InfoBaseRef`
|
||||
|
||||
### `-Force`
|
||||
|
||||
| Подкоманда | Что делает |
|
||||
|------------|------------|
|
||||
| `unlock` | **Теряет локальные правки**: объекты перезаписываются версией из хранилища |
|
||||
| `commit` | Пытается очистить ссылки на удалённые объекты вместо ошибки |
|
||||
| `update` | Подтверждает добавление и удаление объектов конфигурации |
|
||||
|
||||
### Имена объектов
|
||||
|
||||
Объект — `Справочник.Номенклатура`. Форма, макет, команда — полным путём:
|
||||
`Документ.ЗаказПокупателя.Форма.ФормаДокумента`, `Справочник.Номенклатура.Макет.Печать`.
|
||||
Корень конфигурации — `Конфигурация`.
|
||||
|
||||
Если объект «не найден», это не всегда опечатка: он мог появиться в хранилище позже, чем
|
||||
обновлялась база (`/db-repo update`), либо это вовсе не объект хранилища — реквизит или
|
||||
табличная часть.
|
||||
|
||||
## Результат
|
||||
|
||||
Нулевой код не означает, что что-то изменилось. Под нулём приходят «уже захвачено», «обновлять
|
||||
нечего», «помещать нечего» и частичный захват — когда часть объектов занята другими, а остальное
|
||||
захвачено и его можно править.
|
||||
|
||||
**Читай текст вывода, а не только код.** Там же приходит список полученных из хранилища объектов,
|
||||
который требует перевыгрузки перед правкой.
|
||||
|
||||
## Требуют подтверждения пользователя
|
||||
|
||||
Перед этими операциями **спроси подтверждение**:
|
||||
|
||||
| Операция | Почему |
|
||||
|----------|--------|
|
||||
| `lock -All` | Захватывает **всю конфигурацию**: на большой базе идёт долго и блокирует работу всей команде |
|
||||
| `unlock -Force` | Теряются локальные правки захваченных объектов |
|
||||
| `disconnect` | Теряется подключение базы к хранилищу, в том числе на стороне хранилища |
|
||||
| `connect -ForceReplaceCfg` | Конфигурация базы заменяется конфигурацией из хранилища |
|
||||
|
||||
`update` не выполнится, если у базы в реестре не объявлено `repository`, а реквизиты не заданы
|
||||
явно: на неподключённой к хранилищу базе эта команда заменяет всю конфигурацию его содержимым и
|
||||
рапортует успех.
|
||||
|
||||
## Расширения
|
||||
|
||||
У расширения своё хранилище со своим путём. Укажи `-Extension "<Имя>"` — реквизиты возьмутся из
|
||||
`extensions[].repository` записи базы. Подкоманды работают одинаково для основной конфигурации и
|
||||
для расширения.
|
||||
|
||||
## Остальные задачи
|
||||
|
||||
| Файл | Про что |
|
||||
|------|---------|
|
||||
| [connect.md](references/connect.md) | Подключение и отключение базы от хранилища |
|
||||
| [history.md](references/history.md) | История версий, отчёт, выгрузка версии в CF |
|
||||
| [admin.md](references/admin.md) | Создание хранилища, пользователи и права |
|
||||
| [service.md](references/service.md) | Метки версий, оптимизация, очистка кеша |
|
||||
|
||||
## Примеры
|
||||
|
||||
```powershell
|
||||
# Захватить справочник вместе с подчинёнными объектами
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -WithChildren
|
||||
|
||||
# Захватить корень — он нужен, чтобы добавить или удалить объект конфигурации
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command lock -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация"
|
||||
|
||||
# Поместить новый объект: он уже существует, поэтому называется вместе с корнем
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Конфигурация,Справочник.Склады" -Comment "Добавлен справочник Склады"
|
||||
|
||||
# Поместить с комментарием, оставив захват
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command commit -InfoBasePath "C:\Bases\MyDB" -Objects "Справочник.Номенклатура" -Comment "Добавлен реквизит Артикул" -KeepLocked
|
||||
|
||||
# Получить изменения из хранилища
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command update -InfoBasePath "C:\Bases\MyDB"
|
||||
|
||||
# Серверная база, расширение
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command lock -InfoBaseServer "srv01" -InfoBaseRef "MyApp_Dev" -UserName "Admin" -Extension "МоёРасширение" -Objects "Справочник.Номенклатура"
|
||||
```
|
||||
|
||||
## После выполнения
|
||||
|
||||
- `lock` или `update` сообщил о полученных объектах — выполни `/db-dump-xml -Mode Partial` с
|
||||
указанным в выводе файлом, и только потом правь исходники
|
||||
- после `lock` правки идут через `/db-load-xml -Mode Partial` и `/db-update`
|
||||
- изменения готовы — предложи `/db-repo commit` с комментарием
|
||||
@@ -0,0 +1,45 @@
|
||||
# Администрирование хранилища
|
||||
|
||||
## create — создать хранилище
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command create -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "C:\Repo\MyApp" -RepositoryUser "Admin" -RepositoryPassword "…"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-NoBind` | Не подключать базу к созданному хранилищу |
|
||||
| `-AllowConfigurationChanges` | Включить возможность изменения, если конфигурация на поддержке без неё |
|
||||
| `-ChangesAllowedRule <правило>` | Правило для объектов, изменения которых разрешены поставщиком |
|
||||
| `-ChangesNotRecommendedRule <правило>` | То же для «изменения не рекомендуются» |
|
||||
|
||||
Правила: `ObjectNotEditable`, `ObjectIsEditableSupportEnabled`, `ObjectNotSupported`.
|
||||
|
||||
Без `-NoBind` база сразу подключается к созданному хранилищу. Создание — это версия 1.
|
||||
|
||||
Для расширения: `-Extension "<Имя>"` и отдельный путь — у расширения своё хранилище.
|
||||
|
||||
## add-user — создать пользователя
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command add-user -InfoBasePath "C:\Bases\MyDB" -NewUser "Ivanov" -NewUserPassword "…" -Rights LockObjects
|
||||
```
|
||||
|
||||
| Право | Что даёт |
|
||||
|-------|----------|
|
||||
| `ReadOnly` | Просмотр |
|
||||
| `LockObjects` | Захват объектов |
|
||||
| `ManageConfigurationVersions` | Изменение состава версий |
|
||||
| `Administration` | Административные функции |
|
||||
|
||||
`-RestoreDeletedUser` — восстановить одноимённого удалённого. Если пользователь с таким именем
|
||||
существует, он **не** будет добавлен. Выполняющий должен иметь административные права.
|
||||
|
||||
## copy-users — скопировать пользователей из другого хранилища
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command copy-users -InfoBasePath "C:\Bases\MyDB" -SourcePath "\\srv01\repo\Other" -SourceUser "Admin" -SourcePassword "…"
|
||||
```
|
||||
|
||||
`-SourcePath`, `-SourceUser`, `-SourcePassword` описывают хранилище-**источник**. Удалённые пользователи
|
||||
не копируются; существующие не перезаписываются.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Подключение базы к хранилищу
|
||||
|
||||
## connect — подключить
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command connect -InfoBasePath "C:\Bases\MyDB" -RepositoryPath "\\srv01\repo\MyApp" -RepositoryUser "Ivanov" -RepositoryPassword "…"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-ForceReplaceCfg` | Конфигурация базы непустая — подтвердить замену её конфигурацией из хранилища. **Спроси подтверждение у пользователя** |
|
||||
| `-ForceBindAlreadyBindedUser` | Подключить, даже если у этого пользователя уже есть конфигурация, связанная с хранилищем |
|
||||
|
||||
На пустой базе `-ForceReplaceCfg` не нужен.
|
||||
|
||||
**Переподключение** базы, которая уже была подключена, требует обоих флагов: конфигурация в базе
|
||||
не пустая (`-ForceReplaceCfg`), а за пользователем хранилища всё ещё числится эта база
|
||||
(`-ForceBindAlreadyBindedUser`).
|
||||
|
||||
После подключения добавь `repository` в запись базы в `.v8-project.json` — иначе остальные
|
||||
подкоманды придётся каждый раз звать с явными реквизитами, а `update` откажется работать.
|
||||
|
||||
## disconnect — отключить
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command disconnect -InfoBasePath "C:\Bases\MyDB"
|
||||
```
|
||||
|
||||
**Спроси подтверждение у пользователя.** Отключение снимает связь и на стороне самого хранилища:
|
||||
запись о подключении удаляется. Подключить базу обратно можно, но это уже не рядовая операция —
|
||||
понадобятся оба флага `connect` из раздела выше.
|
||||
|
||||
Если в базе есть захваченные и изменённые объекты, операция не выполнится. `-Force` выполняет её
|
||||
всё равно, и эти изменения теряются.
|
||||
|
||||
## Расширения
|
||||
|
||||
У расширения своё хранилище: `-Extension "<Имя>"` указывай вместе с путём именно к нему, а не
|
||||
к хранилищу основной конфигурации.
|
||||
@@ -0,0 +1,31 @@
|
||||
# История версий хранилища
|
||||
|
||||
## report — отчёт по версиям
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command report -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\repo.txt"
|
||||
```
|
||||
|
||||
| Параметр | Описание |
|
||||
|----------|----------|
|
||||
| `-OutputFile <путь>` | Куда сохранить отчёт. Необязателен |
|
||||
| `-NBegin <номер>` | С какой версии. `-1` — только последняя |
|
||||
| `-NEnd <номер>` | По какую версию |
|
||||
| `-DateBegin` / `-DateEnd` | Границы по датам |
|
||||
| `-GroupByObject` | Группировать по объектам |
|
||||
| `-GroupByComment` | Группировать по комментарию |
|
||||
| `-ReportFormat <txt\|mxl>` | По умолчанию `txt` |
|
||||
|
||||
`txt` — с разделителем-табуляцией, разбирается построчно.
|
||||
|
||||
> На боевом хранилище полный отчёт строить не надо — тысячи версий. Нужна головная
|
||||
> версия — `-NBegin -1`. Длинный отчёт в вывод не печатается: сузьте выборку
|
||||
> параметрами ниже.
|
||||
|
||||
## dump-cfg — выгрузить версию в CF
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command dump-cfg -InfoBasePath "C:\Bases\MyDB" -OutputFile "C:\tmp\v120.cf" -Version 120
|
||||
```
|
||||
|
||||
Без `-Version` (или при `-1`) выгружается последняя версия.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Сервисные операции
|
||||
|
||||
## set-label — метка на версию
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command set-label -InfoBasePath "C:\Bases\MyDB" -Label "Релиз 1.2" -Version 120 -Comment "Передано в тест"
|
||||
```
|
||||
|
||||
Без `-Version` метка ставится на последнюю версию. Несуществующая версия — ошибка.
|
||||
|
||||
## optimize — оптимизация хранения
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command optimize -InfoBasePath "C:\Bases\MyDB"
|
||||
```
|
||||
|
||||
Оптимизирует хранение данных в хранилище. Операция долгая.
|
||||
|
||||
## clear-cache — очистка кеша
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/db-repo.ps1" -Command clear-cache -InfoBasePath "C:\Bases\MyDB" -CacheScope local
|
||||
```
|
||||
|
||||
| `-CacheScope` | Что чистит |
|
||||
|---------------|------------|
|
||||
| `local` (по умолчанию) | Локальный кеш версий конфигурации |
|
||||
| `global` | Глобальный кеш версий |
|
||||
| `db` | Локальную базу данных хранилища |
|
||||
|
||||
Пригождается, когда хранилище ведёт себя странно после сбоя сети или отката версии.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# db-run v1.10 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -52,7 +52,7 @@
|
||||
.\db-run.ps1 -InfoBasePath "C:\Bases\MyDB" -CParam "ЗапуститьОбновление"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-run v1.7 — Launch 1C:Enterprise
|
||||
# db-run v1.10 — Launch 1C:Enterprise
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,28 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -95,7 +117,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -103,7 +124,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -171,14 +191,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -203,7 +221,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -238,14 +256,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -281,7 +299,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -305,7 +323,7 @@ def main():
|
||||
|
||||
# --- Validate connection ---
|
||||
if not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Build arguments ---
|
||||
@@ -355,7 +373,7 @@ def main():
|
||||
time.sleep(0.2)
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})", file=sys.stderr)
|
||||
print(f"Error: 1C:Enterprise exited immediately (code: {rc})")
|
||||
sys.exit(rc if rc and rc > 0 else 1)
|
||||
print(f"PID: {proc.pid}")
|
||||
print("1C:Enterprise launched")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# db-update v1.19 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -55,7 +55,7 @@
|
||||
.\db-update.ps1 -InfoBasePath "C:\Bases\MyDB" -Dynamic "+" -Extension "МоёРасширение"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
@@ -91,6 +91,21 @@ param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$WarningsAsErrors,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
[switch]$StrictLog,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPath,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryUser,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$RepositoryPassword,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string[]]$AdditionalV8Arguments = @(),
|
||||
|
||||
@@ -101,6 +116,90 @@ param(
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.ps1 (сопоставление по configSrc).
|
||||
function Find-V8Project([string]$startDir) {
|
||||
$d = $startDir
|
||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||
$pj = Join-Path $d ".v8-project.json"
|
||||
if (Test-Path $pj) { return $pj }
|
||||
$parent = [System.IO.Path]::GetDirectoryName($d)
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function Test-SamePath {
|
||||
param([string]$A, [string]$B)
|
||||
if (-not $A -or -not $B) { return $false }
|
||||
try {
|
||||
$na = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
|
||||
$nb = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
|
||||
return $na.Equals($nb, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Find-ProjectDatabase {
|
||||
# Запись базы в реестре, соответствующая переданному соединению. $null, если не найдена.
|
||||
$pf = Find-V8Project (Get-Location).Path
|
||||
if (-not $pf) { return $null }
|
||||
try { $proj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null }
|
||||
if (-not $proj.databases) { return $null }
|
||||
foreach ($db in $proj.databases) {
|
||||
if ($InfoBasePath -and $db.path -and (Test-SamePath $db.path $InfoBasePath)) { return $db }
|
||||
if ($InfoBaseServer -and $InfoBaseRef -and $db.server -and $db.ref) {
|
||||
if ($db.server.Equals($InfoBaseServer, [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
$db.ref.Equals($InfoBaseRef, [System.StringComparison]::OrdinalIgnoreCase)) { return $db }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-RepositorySettings {
|
||||
# Возвращает @{ Path; User; Password; FromRegistry }. Явные -Repository* всегда сильнее реестра.
|
||||
$dbRec = Find-ProjectDatabase
|
||||
$rec = $null
|
||||
if ($dbRec) {
|
||||
if ($Extension) {
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
if ($dbRec.extensions) {
|
||||
foreach ($ext in $dbRec.extensions) {
|
||||
if ($ext.name -and $ext.name.Equals($Extension, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$rec = $ext.repository
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rec = $dbRec.repository
|
||||
}
|
||||
}
|
||||
$path = if ($RepositoryPath) { $RepositoryPath } elseif ($rec -and $rec.path) { [string]$rec.path } else { $null }
|
||||
$user = if ($RepositoryUser) { $RepositoryUser } elseif ($rec -and $rec.user) { [string]$rec.user } else { $null }
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
$pwd = if ($RepositoryPassword) { $RepositoryPassword } elseif ($rec -and $rec.password) { [string]$rec.password } else { $null }
|
||||
return @{
|
||||
Path = if ($path) { $path.Trim().Trim('"') } else { $null }
|
||||
User = $user
|
||||
Password = $pwd
|
||||
FromRegistry = [bool]($rec -and $rec.path)
|
||||
DbRecord = $dbRec
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RepositoryArgs {
|
||||
# Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P.
|
||||
param([hashtable]$Repo)
|
||||
$a = @()
|
||||
if (-not $Repo -or -not $Repo.Path) { return $a }
|
||||
$a += "/ConfigurationRepositoryF`"$($Repo.Path)`""
|
||||
if ($Repo.User) { $a += "/ConfigurationRepositoryN`"$($Repo.User)`"" }
|
||||
if ($Repo.Password) { $a += "/ConfigurationRepositoryP`"$($Repo.Password)`"" }
|
||||
return $a
|
||||
}
|
||||
|
||||
function Protect-Secrets {
|
||||
# Redact literal secret values from a display string (String.Replace is literal, not regex).
|
||||
param([string]$Text, [string[]]$Secrets)
|
||||
@@ -139,7 +238,7 @@ $script:IbcmdOwnedKeys = @(
|
||||
'--import', '--export', '--apply', '--force', '--create-database',
|
||||
'--user', '--password'
|
||||
)
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP')
|
||||
$script:V8SecretKeys = @('/P', '/UC', '/WSP', '/AWSP', '/ConfigurationRepositoryP')
|
||||
$script:IbcmdSecretKeys = @('--password', '--token', '--db-pwd')
|
||||
|
||||
function Test-ArgKeyMatch {
|
||||
@@ -395,6 +494,41 @@ function Write-PlatformOutput {
|
||||
Write-Host "--- End ---"
|
||||
}
|
||||
|
||||
# Строки лога, о которых платформа сообщает, НЕ поднимая код возврата: метаданные отброшены или
|
||||
# конфигурация нерабочая, а операция при этом «успешна». Возвращает подошедшие строки.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки автономны).
|
||||
# Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Find-SilentRejections {
|
||||
param([string]$LogText)
|
||||
$patterns = @(
|
||||
'Неверное свойство объекта метаданных',
|
||||
'не входит в состав объекта метаданных',
|
||||
'Неизвестное имя типа',
|
||||
'Неизвестный объект метаданных',
|
||||
'Ни один из документов не является регистратором для регистра',
|
||||
'Неверное значение перечисления',
|
||||
'не может быть приведен к типу',
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в рантайме.
|
||||
# Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
'Для работы с конфигурацией необходима версия платформы не меньше'
|
||||
)
|
||||
$found = @()
|
||||
if ($LogText) {
|
||||
foreach ($line in ($LogText -split "`r?`n")) {
|
||||
foreach ($pat in $patterns) {
|
||||
if ($line -match [regex]::Escape($pat)) {
|
||||
$found += $line.Trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Возвращаем массив БЕЗ запятой-обёртки: вызывающий берёт результат в @(), а `return ,$found`
|
||||
# дал бы массив из одного пустого массива — фантомное срабатывание на чистом логе.
|
||||
return $found
|
||||
}
|
||||
|
||||
|
||||
$engine = if ((Split-Path $V8Path -Leaf) -match '^ibcmd') { "ibcmd" } else { "1cv8" }
|
||||
|
||||
@@ -458,6 +592,11 @@ try {
|
||||
if ($UserName) { $arguments += "/N`"$UserName`"" }
|
||||
if ($Password) { $arguments += "/P`"$Password`"" }
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
$__repo = Resolve-RepositorySettings
|
||||
$arguments += Get-RepositoryArgs $__repo
|
||||
|
||||
$arguments += "/UpdateDBCfg"
|
||||
|
||||
# --- Options ---
|
||||
@@ -485,7 +624,7 @@ try {
|
||||
$arguments += $extraArgs
|
||||
|
||||
# --- Execute ---
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName))"
|
||||
Write-Host "Running: 1cv8.exe $(Protect-Secrets ((Format-ArgsForDisplay $arguments $engine) -join ' ') @($Password, $UserName, $__repo.Password))"
|
||||
$__v8 = Invoke-PlatformProcess $V8Path $arguments -PreQuoted
|
||||
$exitCode = $__v8.ExitCode
|
||||
|
||||
@@ -496,6 +635,7 @@ try {
|
||||
Write-Host "Error updating database configuration (code: $exitCode)$(Get-ExitAnnotation $exitCode)" -ForegroundColor Red
|
||||
}
|
||||
|
||||
$logContent = $null
|
||||
if (Test-Path $outFile) {
|
||||
$logContent = Get-Content $outFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($logContent) {
|
||||
@@ -506,6 +646,16 @@ try {
|
||||
}
|
||||
Write-PlatformOutput $__v8.Output
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
$silentFailures = @(Find-SilentRejections $logContent)
|
||||
if ($silentFailures.Count -gt 0) {
|
||||
Write-Host "[warning] platform reported success, but the log contains $($silentFailures.Count) problem(s):" -ForegroundColor Yellow
|
||||
foreach ($f in $silentFailures) { Write-Host " $f" -ForegroundColor Yellow }
|
||||
if ($StrictLog -and $exitCode -eq 0) { $exitCode = 1 }
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# db-update v1.13 — Update 1C database configuration
|
||||
# db-update v1.19 — Update 1C database configuration
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -50,10 +72,95 @@ IBCMD_OWNED_KEYS = [
|
||||
"--import", "--export", "--apply", "--force", "--create-database",
|
||||
"--user", "--password",
|
||||
]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP"]
|
||||
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
|
||||
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]
|
||||
|
||||
|
||||
# --- Реквизиты хранилища из .v8-project.json ---
|
||||
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
|
||||
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
|
||||
def _sg_find_v8project(start_dir):
|
||||
d = start_dir
|
||||
for _ in range(20):
|
||||
if not d:
|
||||
break
|
||||
pj = os.path.join(d, ".v8-project.json")
|
||||
if os.path.isfile(pj):
|
||||
return pj
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return None
|
||||
|
||||
def same_path(a, b):
|
||||
if not a or not b:
|
||||
return False
|
||||
try:
|
||||
return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def find_project_database(args):
|
||||
"""Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
|
||||
pf = _sg_find_v8project(os.getcwd())
|
||||
if not pf:
|
||||
return None
|
||||
try:
|
||||
with open(pf, encoding="utf-8-sig") as f:
|
||||
proj = json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
for db in proj.get("databases") or []:
|
||||
if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
|
||||
return db
|
||||
if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
|
||||
if (db["server"].lower() == args.InfoBaseServer.lower()
|
||||
and db["ref"].lower() == args.InfoBaseRef.lower()):
|
||||
return db
|
||||
return None
|
||||
|
||||
|
||||
def resolve_repository_settings(args):
|
||||
"""Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
|
||||
db_rec = find_project_database(args)
|
||||
rec = None
|
||||
if db_rec:
|
||||
if args.Extension:
|
||||
# У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
|
||||
# /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
|
||||
for ext in db_rec.get("extensions") or []:
|
||||
if (ext.get("name") or "").lower() == args.Extension.lower():
|
||||
rec = ext.get("repository")
|
||||
break
|
||||
else:
|
||||
rec = db_rec.get("repository")
|
||||
path = args.RepositoryPath or ((rec or {}).get("path") or None)
|
||||
user = args.RepositoryUser or ((rec or {}).get("user") or None)
|
||||
# Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
|
||||
pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
|
||||
return {
|
||||
"path": path.strip().strip('"') if path else None,
|
||||
"user": user,
|
||||
"password": pwd,
|
||||
"from_registry": bool(rec and rec.get("path")),
|
||||
}
|
||||
|
||||
|
||||
def repository_args(repo):
|
||||
"""Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
|
||||
a = []
|
||||
if not repo or not repo.get("path"):
|
||||
return a
|
||||
a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
|
||||
if repo.get("user"):
|
||||
a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
|
||||
if repo.get("password"):
|
||||
a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
|
||||
return a
|
||||
|
||||
|
||||
def arg_key_match(token, key):
|
||||
"""Token matches a key when it equals it, or starts with it and the next character
|
||||
is not a letter — catches glued /N"user" and --password=x, while keeping
|
||||
@@ -98,7 +205,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +212,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +279,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +326,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +364,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +381,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +400,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -322,6 +445,38 @@ def print_platform_output(result):
|
||||
print("--- End ---")
|
||||
|
||||
|
||||
def find_silent_rejections(log_text):
|
||||
"""Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.
|
||||
|
||||
Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
|
||||
Возвращает подошедшие строки.
|
||||
|
||||
Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
|
||||
автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
|
||||
весь смысл.
|
||||
"""
|
||||
patterns = [
|
||||
"Неверное свойство объекта метаданных",
|
||||
"не входит в состав объекта метаданных",
|
||||
"Неизвестное имя типа",
|
||||
"Неизвестный объект метаданных",
|
||||
"Ни один из документов не является регистратором для регистра",
|
||||
"Неверное значение перечисления",
|
||||
"не может быть приведен к типу",
|
||||
# Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
|
||||
# рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
|
||||
"Для работы с конфигурацией необходима версия платформы не меньше",
|
||||
]
|
||||
found = []
|
||||
if log_text:
|
||||
for line in log_text.splitlines():
|
||||
for pat in patterns:
|
||||
if pat in line:
|
||||
found.append(line.strip())
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
"""Run an ibcmd command non-interactively.
|
||||
|
||||
@@ -330,7 +485,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -384,18 +539,25 @@ def main():
|
||||
parser.add_argument("-InfoBaseRef", default="")
|
||||
parser.add_argument("-UserName", default="")
|
||||
parser.add_argument("-Password", default="")
|
||||
parser.add_argument("-RepositoryPath", default="")
|
||||
parser.add_argument("-RepositoryUser", default="")
|
||||
parser.add_argument("-RepositoryPassword", default="")
|
||||
parser.add_argument("-Extension", default="")
|
||||
parser.add_argument("-AllExtensions", action="store_true")
|
||||
parser.add_argument("-Dynamic", default="", choices=["", "+", "-"])
|
||||
parser.add_argument("-Server", action="store_true")
|
||||
parser.add_argument("-WarningsAsErrors", action="store_true")
|
||||
# Ключ для регрессов и верификации снапшотов, не для повседневного вызова: в SKILL.md
|
||||
# намеренно не выносится. Поднимает код возврата, если платформа отчиталась об успехе,
|
||||
# но в логе есть отбраковка.
|
||||
parser.add_argument("-StrictLog", action="store_true")
|
||||
parser.add_argument("-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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -420,16 +582,16 @@ def main():
|
||||
# --- Validate connection ---
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef", file=sys.stderr)
|
||||
print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
|
||||
sys.exit(1)
|
||||
|
||||
# --- ibcmd branch (file infobase only) ---
|
||||
if engine == "ibcmd":
|
||||
if args.AllExtensions:
|
||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)", file=sys.stderr)
|
||||
print("Error: ibcmd config apply does not support -AllExtensions (use -Extension)")
|
||||
sys.exit(1)
|
||||
arguments = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
|
||||
if args.Dynamic == "+":
|
||||
@@ -451,7 +613,7 @@ def main():
|
||||
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)
|
||||
print(f"Error updating database configuration (code: {result.returncode}){describe_exit(result.returncode)}")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
# --- Temp dir ---
|
||||
@@ -472,6 +634,11 @@ def main():
|
||||
if args.Password:
|
||||
arguments.append(f'/P"{args.Password}"')
|
||||
|
||||
# База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
|
||||
# базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
|
||||
repo = resolve_repository_settings(args)
|
||||
arguments.extend(repository_args(repo))
|
||||
|
||||
arguments.append("/UpdateDBCfg")
|
||||
|
||||
# --- Options ---
|
||||
@@ -495,7 +662,7 @@ def main():
|
||||
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)}")
|
||||
print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
|
||||
result = run_v8(v8path, arguments)
|
||||
exit_code = result.returncode
|
||||
|
||||
@@ -503,8 +670,9 @@ def main():
|
||||
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(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
|
||||
|
||||
log_content = ""
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
with open(out_file, "r", encoding="utf-8-sig") as f:
|
||||
@@ -517,6 +685,21 @@ def main():
|
||||
pass
|
||||
|
||||
print_platform_output(result)
|
||||
|
||||
# Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
|
||||
# разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
|
||||
# про -StrictLog не даём: операция уже выполнена, повторять её ради того же текста незачем.
|
||||
silent_failures = find_silent_rejections(log_content)
|
||||
if silent_failures:
|
||||
print(
|
||||
f"[warning] platform reported success, but the log contains "
|
||||
f"{len(silent_failures)} problem(s):"
|
||||
)
|
||||
for line in silent_failures:
|
||||
print(f" {line}")
|
||||
if args.StrictLog and exit_code == 0:
|
||||
exit_code = 1
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.16 — 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 не исполняется.
|
||||
<#
|
||||
@@ -46,7 +46,7 @@
|
||||
.\epf-build.ps1 -InfoBasePath "C:\Bases\MyDB" -SourceFile "src\МойОтчёт.xml" -OutputFile "build\МойОтчёт.erf"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-build v1.12 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# epf-build v1.16 — Build external data processor or report (EPF/ERF) from XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -374,7 +412,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -398,7 +436,7 @@ def main():
|
||||
}
|
||||
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)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath or omit for stub)")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Auto-create stub database if no connection specified ---
|
||||
@@ -419,14 +457,14 @@ def main():
|
||||
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)
|
||||
print("Error: failed to create stub database")
|
||||
sys.exit(1)
|
||||
args.InfoBasePath = auto_base_path
|
||||
auto_created_base = auto_base_path
|
||||
|
||||
# --- Validate source file ---
|
||||
if not os.path.isfile(args.SourceFile):
|
||||
print(f"Error: source file not found: {args.SourceFile}", file=sys.stderr)
|
||||
print(f"Error: source file not found: {args.SourceFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
@@ -460,9 +498,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||
else:
|
||||
print(f"Error building external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error building external data processor/report (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
@@ -499,9 +537,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no non-empty file at {args.OutputFile} — build produced no output")
|
||||
else:
|
||||
print(f"Error building (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error building (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -163,14 +163,35 @@ function Format-ArgsForDisplay {
|
||||
}
|
||||
|
||||
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 1. Scan XML files for reference types ---
|
||||
|
||||
$typeMap = @{} # MetadataType -> @(Name1, Name2, ...)
|
||||
|
||||
# Версия формата заглушечной конфигурации. Платформа грузит формат не новее себя, поэтому зашитая
|
||||
# версия ломала бы сборку исходников более старого формата на соответствующей ей платформе. Берём
|
||||
# версию из корня собираемого объекта (ExternalDataProcessor/ExternalReport); вложенные файлы —
|
||||
# запасной вариант, если корень почему-то не попался.
|
||||
$srcRootVersion = ""
|
||||
$srcAnyVersion = ""
|
||||
|
||||
$xmlFiles = Get-ChildItem -Path $SourceDir -Filter "*.xml" -Recurse -File
|
||||
foreach ($f in $xmlFiles) {
|
||||
$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8)
|
||||
|
||||
if ($content -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') {
|
||||
$ver = $Matches[1]
|
||||
if (-not $srcAnyVersion) { $srcAnyVersion = $ver }
|
||||
if (-not $srcRootVersion -and $content -match '<(ExternalDataProcessor|ExternalReport)[ >]') {
|
||||
$srcRootVersion = $ver
|
||||
}
|
||||
}
|
||||
|
||||
# Ref types: cfg:CatalogRef.XXX or d5p1:CatalogRef.XXX (and similar depth prefixes d4p1, d3p1, etc.)
|
||||
$refPattern = '(?:cfg:|d\dp1:)(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.([A-Za-z\u0400-\u04FF\d_]+)'
|
||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||
@@ -337,7 +358,24 @@ if ($hasRefTypes) {
|
||||
$cfgDir = Join-Path $TempBasePath "cfg"
|
||||
New-Item -ItemType Directory -Path $cfgDir -Force | Out-Null
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
# Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
# одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
# заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
# конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
# формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
#
|
||||
$srcVersion = if ($srcRootVersion) { $srcRootVersion } elseif ($srcAnyVersion) { $srcAnyVersion } else { "2.17" }
|
||||
$srcRank = Get-FormatRank $srcVersion
|
||||
$stubFormatVersion = if ($srcRank -gt 0 -and $srcRank -lt (Get-FormatRank "2.17")) { $srcVersion } else { "2.17" }
|
||||
# Режим совместимости заглушки — по той же логике. Платформа отказывается работать с
|
||||
# конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий
|
||||
# формата из docs/1c-configuration-spec.md.
|
||||
$compatByFormat = @{ "2.13" = "Version8_3_20"; "2.14" = "Version8_3_21"; "2.15" = "Version8_3_22"; "2.16" = "Version8_3_23" }
|
||||
$stubCompatMode = if ($compatByFormat.ContainsKey($stubFormatVersion)) { $compatByFormat[$stubFormatVersion] } else { "Version8_3_24" }
|
||||
|
||||
$ns = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="' + $stubFormatVersion + '"'
|
||||
|
||||
# GeneratedType definitions per metadata type
|
||||
$gtDefs = @{
|
||||
@@ -521,7 +559,7 @@ if ($hasRefTypes) {
|
||||
<Synonym/>
|
||||
<Comment/>
|
||||
<NamePrefix/>
|
||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
<ConfigurationExtensionCompatibilityMode>$stubCompatMode</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -572,7 +610,7 @@ if ($hasRefTypes) {
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
<CompatibilityMode>$stubCompatMode</CompatibilityMode>
|
||||
<DefaultConstantsForm/>
|
||||
</Properties>
|
||||
<ChildObjects>$childXml
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# stub-db-create v1.7 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# stub-db-create v1.8 — Create temp 1C infobase with metadata stubs for EPF/ERF build
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -339,6 +339,66 @@ def scan_ref_types(source_dir):
|
||||
return type_map
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def detect_stub_format_version(source_dir):
|
||||
"""Версия формата заглушечной конфигурации.
|
||||
|
||||
Заглушке нужна САМАЯ НИЗКАЯ работающая версия, а не версия исходников: ограничение платформы
|
||||
одностороннее — она читает формат не новее себя. Отсюда min(версия исходников, 2.17): на 2.17+
|
||||
заглушка остаётся 2.17 (как было), а под исходники 2.13-2.16 опускается до их версии, иначе
|
||||
конфигурация не загрузится платформой, которая эти исходники и выгрузила («Неизвестная версия
|
||||
формата 2.17 загружаемого файла», замерено на 8.3.20).
|
||||
|
||||
Версию исходников берём из корня собираемого объекта (ExternalDataProcessor/ExternalReport);
|
||||
вложенные файлы — запасной вариант, если корень почему-то не попался.
|
||||
"""
|
||||
root_version = ""
|
||||
any_version = ""
|
||||
ver_pattern = re.compile(r'<MetaDataObject[^>]+version="(\d+\.\d+)"')
|
||||
root_pattern = re.compile(r'<(ExternalDataProcessor|ExternalReport)[ >]')
|
||||
for dirpath, _, filenames in os.walk(source_dir):
|
||||
for fn in filenames:
|
||||
if not fn.endswith('.xml'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(dirpath, fn), 'r', encoding='utf-8-sig') as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
m = ver_pattern.search(content)
|
||||
if not m:
|
||||
continue
|
||||
if not any_version:
|
||||
any_version = m.group(1)
|
||||
if not root_version and root_pattern.search(content):
|
||||
root_version = m.group(1)
|
||||
src_version = root_version or any_version or "2.17"
|
||||
src_rank = format_rank(src_version)
|
||||
return src_version if 0 < src_rank < format_rank("2.17") else "2.17"
|
||||
|
||||
|
||||
# Режим совместимости заглушки — по той же логике, что и версия формата. Платформа отказывается
|
||||
# работать с конфигурацией, чей режим выше её самой («Для работы с конфигурацией необходима версия
|
||||
# платформы не меньше, чем 8.3.24»), и тогда объекты заглушки в базу не попадают: загрузка
|
||||
# рапортует успех, а сборка падает на «Неизвестное имя типа». Ступени — лестница версий формата
|
||||
# из docs/1c-configuration-spec.md.
|
||||
COMPAT_BY_FORMAT = {
|
||||
"2.13": "Version8_3_20",
|
||||
"2.14": "Version8_3_21",
|
||||
"2.15": "Version8_3_22",
|
||||
"2.16": "Version8_3_23",
|
||||
}
|
||||
|
||||
|
||||
def stub_compatibility_mode(format_version):
|
||||
return COMPAT_BY_FORMAT.get(format_version, "Version8_3_24")
|
||||
|
||||
|
||||
def scan_register_columns(source_dir):
|
||||
"""Scan Form.xml for register record set columns referenced via DataPath.
|
||||
Returns {"RegisterType.RegisterName": {"col1": True, "col2": True}}."""
|
||||
@@ -417,7 +477,7 @@ NS = (
|
||||
'xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" '
|
||||
'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17"'
|
||||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
CLASS_IDS = [
|
||||
@@ -1046,6 +1106,9 @@ def main():
|
||||
type_map = scan_ref_types(args.SourceDir)
|
||||
register_columns = scan_register_columns(args.SourceDir)
|
||||
has_ref_types = len(type_map) > 0
|
||||
stub_format_version = detect_stub_format_version(args.SourceDir)
|
||||
stub_compat = stub_compatibility_mode(stub_format_version)
|
||||
ns_decl = f'{NS} version="{stub_format_version}"'
|
||||
|
||||
temp_base = args.TempBasePath or os.path.join(tempfile.gettempdir(), f'epf_stub_db_{random.randint(0,999999)}')
|
||||
|
||||
@@ -1077,7 +1140,7 @@ def main():
|
||||
child_xml += f'\n\t\t\t<{tag}>{name}</{tag}>'
|
||||
|
||||
cfg_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Configuration uuid="{uuid_cfg}">
|
||||
\t\t<InternalInfo>{co_xml}
|
||||
\t\t</InternalInfo>
|
||||
@@ -1086,7 +1149,7 @@ def main():
|
||||
\t\t\t<Synonym/>
|
||||
\t\t\t<Comment/>
|
||||
\t\t\t<NamePrefix/>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<ConfigurationExtensionCompatibilityMode>{stub_compat}</ConfigurationExtensionCompatibilityMode>
|
||||
\t\t\t<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
\t\t\t<UsePurposes>
|
||||
\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
@@ -1137,7 +1200,7 @@ def main():
|
||||
\t\t\t<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
\t\t\t<InterfaceCompatibilityMode>Taxi</InterfaceCompatibilityMode>
|
||||
\t\t\t<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
\t\t\t<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
\t\t\t<CompatibilityMode>{stub_compat}</CompatibilityMode>
|
||||
\t\t\t<DefaultConstantsForm/>
|
||||
\t\t</Properties>
|
||||
\t\t<ChildObjects>{child_xml}
|
||||
@@ -1151,7 +1214,7 @@ def main():
|
||||
lang_dir = os.path.join(cfg_dir, 'Languages')
|
||||
os.makedirs(lang_dir, exist_ok=True)
|
||||
lang_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<Language uuid="{uuid_lang}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>\u0420\u0443\u0441\u0441\u043a\u0438\u0439</Name>
|
||||
@@ -1280,7 +1343,7 @@ def main():
|
||||
child_obj_xml = '\n\t\t<ChildObjects/>'
|
||||
|
||||
obj_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject {NS}>
|
||||
<MetaDataObject {ns_decl}>
|
||||
\t<{tag} uuid="{obj_uuid}">{internal_xml}
|
||||
\t\t<Properties>
|
||||
{props_xml}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# NB: *nix-раскладку платформы (/opt/1cv8/<ver>/1cv8, без .exe) знает только .py-порт — PS на *nix не исполняется.
|
||||
<#
|
||||
@@ -49,7 +49,7 @@
|
||||
.\epf-dump.ps1 -InfoBasePath "C:\Bases\MyDB" -InputFile "build\МойОтчёт.erf" -OutputDir "src"
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$V8Path,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-dump v1.11 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# epf-dump v1.15 — Dump external data processor or report (EPF/ERF) to XML sources
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -14,6 +14,28 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
def _find_project_v8path():
|
||||
"""Walk up from CWD to find .v8-project.json and read its v8path."""
|
||||
@@ -98,7 +120,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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:
|
||||
@@ -106,7 +127,6 @@ def assert_extra_args(extra, engine, hints):
|
||||
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)
|
||||
|
||||
@@ -174,14 +194,12 @@ def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
|
||||
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":
|
||||
@@ -223,14 +241,14 @@ def resolve_v8path(v8path):
|
||||
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)
|
||||
print("Error: 1C executable not found. Specify -V8Path")
|
||||
sys.exit(1)
|
||||
if os.path.isdir(v8path):
|
||||
# PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
|
||||
exe = "1cv8.exe" if os.name == "nt" else "1cv8"
|
||||
v8path = os.path.join(v8path, exe)
|
||||
if not os.path.isfile(v8path):
|
||||
print(f"Error: 1C executable not found at {v8path}", file=sys.stderr)
|
||||
print(f"Error: 1C executable not found at {v8path}")
|
||||
sys.exit(1)
|
||||
return v8path
|
||||
|
||||
@@ -261,7 +279,7 @@ def assert_infobase_exists(path):
|
||||
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)
|
||||
print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -278,7 +296,7 @@ def clean_path(value, param=""):
|
||||
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)
|
||||
print(f"Error: {param or 'path'} contains a quote character: {value}")
|
||||
sys.exit(1)
|
||||
return v
|
||||
|
||||
@@ -297,11 +315,31 @@ def run_v8(v8path, arguments):
|
||||
The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
|
||||
1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
|
||||
escape those quotes, so there the command line is handed over ready-made.
|
||||
|
||||
На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
|
||||
частью значения: путь с пробелом платформа не находит («Неопределена информационная
|
||||
база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
|
||||
обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
|
||||
File="…") не задеты: у них кавычки внутри токена, а не по краям.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
cmd = '"' + v8path + '" ' + " ".join(arguments)
|
||||
else:
|
||||
cmd = [v8path] + arguments
|
||||
def strip_framing_quotes(a):
|
||||
# Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
|
||||
# становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
|
||||
# токеном даёт «Неопределена информационная база», а склеенный
|
||||
# /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
|
||||
# без кавычек обе формы работают.
|
||||
if len(a) > 1 and a[0] == '"' and a[-1] == '"':
|
||||
return a[1:-1] # "значение" отдельным токеном
|
||||
if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
|
||||
i = a.index('"')
|
||||
return a[:i] + a[i + 1:-1] # /N"имя" -> /Nимя
|
||||
return a # File="…" не трогаем: там кавычки —
|
||||
# часть синтаксиса строки соединения,
|
||||
# и с ними на POSIX всё работает
|
||||
cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
r.stderr = decode_platform_bytes(r.stderr)
|
||||
@@ -330,7 +368,7 @@ def run_ibcmd(cmd, has_username=False, warn_no_user=True):
|
||||
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.stdout.write(IBCMD_NOUSER_HINT)
|
||||
sys.stderr.flush()
|
||||
r = subprocess.run(cmd, input=b"", capture_output=True)
|
||||
r.stdout = decode_platform_bytes(r.stdout)
|
||||
@@ -380,7 +418,7 @@ def main():
|
||||
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 = ci_parse_args(parser, argv)
|
||||
|
||||
args.V8Path = clean_path(args.V8Path, "-V8Path")
|
||||
args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
|
||||
@@ -406,20 +444,20 @@ def main():
|
||||
|
||||
# --- 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("Error: database connection required. Specify -InfoBasePath or -InfoBaseServer/-InfoBaseRef")
|
||||
print("Dump in an empty database loses reference types (CatalogRef, DocumentRef, etc.) irreversibly.")
|
||||
sys.exit(1)
|
||||
if engine == "ibcmd":
|
||||
if not args.InfoBasePath:
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)", file=sys.stderr)
|
||||
print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
|
||||
sys.exit(1)
|
||||
if args.Format == "Plain":
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)", file=sys.stderr)
|
||||
print("Error: ibcmd config export supports hierarchical format only (use -Format Hierarchical or 1cv8)")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Validate input file ---
|
||||
if not os.path.isfile(args.InputFile):
|
||||
print(f"Error: input file not found: {args.InputFile}", file=sys.stderr)
|
||||
print(f"Error: input file not found: {args.InputFile}")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Ensure output directory exists ---
|
||||
@@ -451,9 +489,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||
else:
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping external data processor/report (code: {exit_code})")
|
||||
sys.exit(exit_code)
|
||||
|
||||
# --- Build arguments ---
|
||||
@@ -491,9 +529,9 @@ def main():
|
||||
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)
|
||||
print(f"Error: exit code 0 but no files under {args.OutputDir} — dump produced no output")
|
||||
else:
|
||||
print(f"Error dumping (code: {exit_code})", file=sys.stderr)
|
||||
print(f"Error dumping (code: {exit_code})")
|
||||
|
||||
if os.path.isfile(out_file):
|
||||
try:
|
||||
|
||||
@@ -18,19 +18,26 @@ allowed-tools:
|
||||
## Usage
|
||||
|
||||
```
|
||||
/epf-init <Name> [Synonym] [SrcDir]
|
||||
/epf-init <Name> [Synonym] [SrcDir] [FormatVersion]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-----------|:------------:|--------------|-------------------------------------|
|
||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|------------------------------------------------|
|
||||
| Name | да | — | Имя обработки (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"]
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -6,21 +6,65 @@ param(
|
||||
|
||||
[string]$Synonym = $Name,
|
||||
|
||||
[string]$SrcDir = "src"
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономной обработки нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри обработки
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalDataProcessor uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -33,11 +77,11 @@ $xml = @"
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$Name</Name>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$Synonym</v8:content>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
@@ -64,7 +108,16 @@ $extDir = Join-Path $processorDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
@@ -83,6 +136,11 @@ $moduleBsl = @"
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создана обработка: $rootFile"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-init v1.1 — Init 1C external data processor scaffold
|
||||
# epf-init v1.8 — Init 1C external data processor scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external data processor."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -21,7 +67,24 @@ def main():
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
args = parser.parse_args()
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -32,8 +95,36 @@ def main():
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalDataProcessor uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
@@ -46,11 +137,11 @@ def main():
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
@@ -72,7 +163,7 @@ def main():
|
||||
ext_dir = os.path.join(processor_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
@@ -89,7 +180,10 @@ def main():
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
write_utf8_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создана обработка: {root_file}")
|
||||
print(f" Каталог: {processor_dir}")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.6 — 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
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$ObjectPath,
|
||||
|
||||
@@ -111,6 +112,19 @@ $finalize = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Reference tables ---
|
||||
|
||||
$guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
||||
@@ -183,11 +197,15 @@ if ($root.NamespaceURI -ne $expectedNs) {
|
||||
}
|
||||
|
||||
$version = $root.GetAttribute("version")
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "1. Missing version attribute on MetaDataObject"
|
||||
} elseif ($version -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)"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "1. Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "1. Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "1. Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
}
|
||||
|
||||
# Detect type: ExternalDataProcessor or ExternalReport
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# epf-validate v1.3 — Validate 1C external data processor / report structure
|
||||
# epf-validate v1.6 — Validate 1C external data processor / report structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects
|
||||
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
from io import StringIO
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
MD_NS = "http://v8.1c.ru/8.3/MDClasses"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
|
||||
@@ -38,6 +60,21 @@ CHILD_TYPE_ORDER = {
|
||||
}
|
||||
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -50,7 +87,7 @@ def main():
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||
parser.add_argument("-OutFile", default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
max_errors = args.MaxErrors
|
||||
|
||||
@@ -163,11 +200,17 @@ def main():
|
||||
check1_ok = False
|
||||
|
||||
version = root.get("version", "")
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
report_warn("1. Missing version attribute on MetaDataObject")
|
||||
elif version not in ("2.17", "2.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)")
|
||||
elif version_rank == 0:
|
||||
report_error(f"1. Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
report_warn(f"1. Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
report_warn(f"1. Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
|
||||
# Detect type
|
||||
child_elements = []
|
||||
|
||||
@@ -18,20 +18,27 @@ allowed-tools:
|
||||
## Usage
|
||||
|
||||
```
|
||||
/erf-init <Name> [Synonym] [SrcDir] [--with-skd]
|
||||
/erf-init <Name> [Synonym] [SrcDir] [FormatVersion] [--with-skd]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-----------|:------------:|--------------|---------------------------------------|
|
||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|---------------|:------------:|--------------|---------------------------------------|
|
||||
| Name | да | — | Имя отчёта (латиница/кириллица) |
|
||||
| Synonym | нет | = Name | Синоним (отображаемое имя) |
|
||||
| SrcDir | нет | `src` | Каталог исходников относительно CWD |
|
||||
| FormatVersion | нет | `2.17` | Версия формата выгрузки — см. ниже |
|
||||
| --WithSKD | нет | — | Создать пустую СКД и привязать к MainDataCompositionSchema |
|
||||
|
||||
`FormatVersion` — **не выше** версии формата платформы, на которой объект будут собирать и открывать:
|
||||
8.3.24 — `2.17`, 8.3.25 — `2.18`, 8.3.26 — `2.19`, 8.3.27 — `2.20`, 8.5 — `2.21`. Ниже брать можно:
|
||||
платформа читает свой формат и любой более старый, поэтому дефолт `2.17` подходит для всей линейки
|
||||
8.3.24 и выше. Для более старых платформ счёт идёт так же, по одной версии на релиз (8.3.23 — `2.16`),
|
||||
но на них навыки не проверялись — такое значение принимается с предупреждением.
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-WithSKD]
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/init.ps1" -Name "<Name>" [-Synonym "<Synonym>"] [-SrcDir "<SrcDir>"] [-FormatVersion "<версия>"] [-WithSKD]
|
||||
```
|
||||
|
||||
## Дальнейшие шаги
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# erf-init v1.1 — Init 1C external report scaffold
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -8,18 +8,62 @@ param(
|
||||
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$WithSKD
|
||||
[switch]$WithSKD,
|
||||
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного отчёта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри отчёта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
[string]$FormatVersion = "2.17"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница — docs/1c-configuration-spec.md,
|
||||
# «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и старше) реальны, поэтому запретом их не
|
||||
# закрываем: за пределами диапазона — ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только
|
||||
# на нечисловое значение: это опечатка, а не версия.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
$formatRank = Get-FormatRank $FormatVersion
|
||||
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# Проверка версии — ПОСЛЕ настройки кодировки консоли: иначе em-dash в сообщении уедет в вопросы.
|
||||
# Пишем прямо в stderr, а не Write-Warning: в PS 5.1 предупреждение уходит в stdout, получает
|
||||
# локализованный префикс и переносится по 80 символов — подстрока в тесте перестаёт находиться.
|
||||
if ($formatRank -eq 0) {
|
||||
[Console]::Error.WriteLine("Malformed -FormatVersion '$FormatVersion' (expected N.N, e.g. 2.17)")
|
||||
exit 1
|
||||
}
|
||||
if ($formatRank -lt (Get-FormatRank $formatVerifiedMin) -or $formatRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
[Console]::Error.WriteLine("WARNING: Format version '$FormatVersion' is outside the tested range $formatVerifiedMin-$formatVerifiedMax — the scaffold is emitted as requested but was not verified on that platform")
|
||||
}
|
||||
|
||||
$uuid1 = [guid]::NewGuid().ToString()
|
||||
$uuid2 = [guid]::NewGuid().ToString()
|
||||
$uuid3 = [guid]::NewGuid().ToString()
|
||||
$uuid4 = [guid]::NewGuid().ToString()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только интерполируют.
|
||||
$xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед style):
|
||||
# платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if ($formatRank -ge 221) {
|
||||
$xmlnsDecl = $xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
# --- Формируем Properties ---
|
||||
|
||||
$mainDCSValue = ""
|
||||
@@ -48,7 +92,7 @@ $childObjectsXml = if ($childObjectsContent) {
|
||||
|
||||
$xml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<ExternalReport uuid="$uuid1">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
@@ -61,11 +105,11 @@ $xml = @"
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>$Name</Name>
|
||||
<Name>$(Esc-XmlText $Name)</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>$Synonym</v8:content>
|
||||
<v8:content>$(Esc-XmlText $Synonym)</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
@@ -98,7 +142,16 @@ $extDir = Join-Path $reportDir "Ext"
|
||||
New-Item -ItemType Directory -Path $extDir -Force | Out-Null
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText((Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml"), $xml, $enc)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile (Resolve-Path $SrcDir | Join-Path -ChildPath "$Name.xml") $xml $enc
|
||||
|
||||
# --- Модуль объекта ---
|
||||
|
||||
@@ -117,6 +170,11 @@ $moduleBsl = @"
|
||||
"@
|
||||
|
||||
$modulePath = Join-Path $extDir "ObjectModule.bsl"
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $enc)
|
||||
|
||||
Write-Host "[OK] Создан отчёт: $rootFile"
|
||||
@@ -136,7 +194,7 @@ if ($WithSKD) {
|
||||
|
||||
$skdMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject $xmlnsDecl version="$FormatVersion">
|
||||
<Template uuid="$skdUuid">
|
||||
<Properties>
|
||||
<Name>$skdName</Name>
|
||||
@@ -153,7 +211,7 @@ if ($WithSKD) {
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($skdMetaPath, $skdMetaXml, $enc)
|
||||
Write-XmlFile $skdMetaPath $skdMetaXml $enc
|
||||
|
||||
$skdContent = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -173,7 +231,7 @@ if ($WithSKD) {
|
||||
"@
|
||||
|
||||
$skdFilePath = Join-Path $skdExtDir "Template.xml"
|
||||
[System.IO.File]::WriteAllText($skdFilePath, $skdContent, $enc)
|
||||
Write-XmlFile $skdFilePath $skdContent $enc
|
||||
|
||||
Write-Host " СКД: $skdMetaPath"
|
||||
Write-Host " Тело: $skdFilePath"
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# erf-init v1.1 — Init 1C external report scaffold
|
||||
# erf-init v1.8 — Init 1C external report scaffold (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Generates minimal XML source files for a 1C external report."""
|
||||
import sys, os, argparse, uuid
|
||||
import sys, os, re, argparse, uuid
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace('&','&').replace('<','<').replace('>','>').replace('"','"')
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -21,8 +67,25 @@ def main():
|
||||
parser.add_argument('-Name', dest='Name', required=True)
|
||||
parser.add_argument('-Synonym', dest='Synonym', default=None)
|
||||
parser.add_argument('-SrcDir', dest='SrcDir', default='src')
|
||||
# Версия формата выгрузки. Своей конфигурации у автономного объекта нет, наследовать
|
||||
# версию неоткуда — поэтому её задают явно. Формы, макеты и справку внутри объекта
|
||||
# навыки берут уже отсюда: их детектор читает version из корня этого файла.
|
||||
parser.add_argument('-FormatVersion', dest='FormatVersion', default='2.17')
|
||||
parser.add_argument('-WithSKD', dest='WithSKD', action='store_true')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# Проверенный диапазон: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версии ниже 2.17 (платформы 8.3.23 и
|
||||
# старше) реальны, поэтому запретом их не закрываем: за пределами диапазона —
|
||||
# ПРЕДУПРЕЖДЕНИЕ, скаффолд всё равно выпускается. Ошибка — только на нечисловое значение.
|
||||
format_rank_value = format_rank(args.FormatVersion)
|
||||
if format_rank_value == 0:
|
||||
print(f"Malformed -FormatVersion '{args.FormatVersion}' (expected N.N, e.g. 2.17)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not (format_rank(FORMAT_VERIFIED_MIN) <= format_rank_value <= format_rank(FORMAT_VERIFIED_MAX)):
|
||||
print(f"WARNING: Format version '{args.FormatVersion}' is outside the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — the scaffold is emitted as requested "
|
||||
f"but was not verified on that platform", file=sys.stderr)
|
||||
|
||||
name = args.Name
|
||||
synonym = args.Synonym if args.Synonym else name
|
||||
@@ -33,6 +96,34 @@ def main():
|
||||
uuid3 = new_uuid()
|
||||
uuid4 = new_uuid()
|
||||
|
||||
# Объявления пространств имён — одной переменной: места эмиссии её только подставляют.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
format_version = args.FormatVersion
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры. Вставляем НА МЕСТО (после lf, перед
|
||||
# style): платформа держит объявления по алфавиту, дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
|
||||
# --- Properties ---
|
||||
main_dcs_value = ""
|
||||
child_objects_content = ""
|
||||
@@ -45,7 +136,7 @@ def main():
|
||||
child_objects_xml = f"<ChildObjects>{child_objects_content}\t\t</ChildObjects>" if child_objects_content else "<ChildObjects/>"
|
||||
|
||||
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<ExternalReport uuid="{uuid1}">
|
||||
\t\t<InternalInfo>
|
||||
\t\t\t<xr:ContainedObject>
|
||||
@@ -58,11 +149,11 @@ def main():
|
||||
\t\t\t</xr:GeneratedType>
|
||||
\t\t</InternalInfo>
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{esc_xml(name)}</Name>
|
||||
\t\t\t<Name>{esc_xml_text(name)}</Name>
|
||||
\t\t\t<Synonym>
|
||||
\t\t\t\t<v8:item>
|
||||
\t\t\t\t\t<v8:lang>ru</v8:lang>
|
||||
\t\t\t\t\t<v8:content>{esc_xml(synonym)}</v8:content>
|
||||
\t\t\t\t\t<v8:content>{esc_xml_text(synonym)}</v8:content>
|
||||
\t\t\t\t</v8:item>
|
||||
\t\t\t</Synonym>
|
||||
\t\t\t<Comment/>
|
||||
@@ -90,7 +181,7 @@ def main():
|
||||
ext_dir = os.path.join(report_dir, "Ext")
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
|
||||
write_utf8_bom(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
write_xml_file(os.path.join(os.path.abspath(src_dir), f"{name}.xml"), xml)
|
||||
|
||||
# --- Модуль объекта ---
|
||||
module_bsl = """\
|
||||
@@ -107,7 +198,10 @@ def main():
|
||||
#КонецОбласти"""
|
||||
|
||||
module_path = os.path.join(ext_dir, "ObjectModule.bsl")
|
||||
write_utf8_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
print(f"[OK] Создан отчёт: {root_file}")
|
||||
print(f" Каталог: {report_dir}")
|
||||
@@ -124,7 +218,7 @@ def main():
|
||||
skd_uuid = new_uuid()
|
||||
|
||||
skd_meta_xml = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<MetaDataObject {xmlns_decl} version="{format_version}">
|
||||
\t<Template uuid="{skd_uuid}">
|
||||
\t\t<Properties>
|
||||
\t\t\t<Name>{skd_name}</Name>
|
||||
@@ -140,7 +234,7 @@ def main():
|
||||
\t</Template>
|
||||
</MetaDataObject>'''
|
||||
|
||||
write_utf8_bom(skd_meta_path, skd_meta_xml)
|
||||
write_xml_file(skd_meta_path, skd_meta_xml)
|
||||
|
||||
skd_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema"
|
||||
@@ -158,7 +252,7 @@ def main():
|
||||
</DataCompositionSchema>'''
|
||||
|
||||
skd_file_path = os.path.join(skd_ext_dir, "Template.xml")
|
||||
write_utf8_bom(skd_file_path, skd_content)
|
||||
write_xml_file(skd_file_path, skd_content)
|
||||
|
||||
print(f" СКД: {skd_meta_path}")
|
||||
print(f" Тело: {skd_file_path}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: form-add
|
||||
description: Добавить пустую управляемую форму к объекту 1С. Используй когда нужно создать у объекта новую форму
|
||||
argument-hint: <ObjectPath> <FormName> [Purpose] [--set-default]
|
||||
argument-hint: <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
@@ -18,16 +18,16 @@ allowed-tools:
|
||||
## Usage
|
||||
|
||||
```
|
||||
/form-add <ObjectPath> <FormName> [Purpose] [Synonym] [--set-default]
|
||||
/form-add <ObjectPath> <FormName> [-Purpose <Purpose>] [-Synonym <Synonym>] [-SetDefault]
|
||||
```
|
||||
|
||||
| Параметр | Обязательный | По умолчанию | Описание |
|
||||
|-------------|:------------:|--------------|----------------------------------------------|
|
||||
| ObjectPath | да | — | Путь к XML-файлу объекта (Documents/Док.xml) |
|
||||
| FormName | да | — | Имя формы (ФормаДокумента) |
|
||||
| Purpose | нет | Object | Назначение: Object, List, Choice, Record |
|
||||
| Purpose | нет | основная форма вида | Назначение формы — см. таблицу ниже: у справочника это форма объекта, у регистра сведений — форма записи, у журнала — форма списка |
|
||||
| Synonym | нет | = FormName | Синоним формы |
|
||||
| --set-default | нет | авто | Установить как форму по умолчанию |
|
||||
| -SetDefault | нет | авто | Сделать основной. Без флага основной становится первая форма каждого назначения |
|
||||
|
||||
## Команда
|
||||
|
||||
@@ -37,30 +37,52 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-add.ps1" -Obje
|
||||
|
||||
## Purpose — назначение формы
|
||||
|
||||
| Purpose | Допустимые типы объектов | Основной реквизит | DefaultForm-свойство |
|
||||
|---------|-------------------------|-------------------|---------------------|
|
||||
| Object | Document, Catalog, DataProcessor, Report, ExternalDataProcessor, ExternalReport, ChartOf*, ExchangePlan, BusinessProcess, Task | Объект (тип: *Object.Имя) | DefaultObjectForm (DefaultForm для DataProcessor/Report/ExternalDataProcessor/ExternalReport) |
|
||||
| List | Все кроме DataProcessor | Список (DynamicList) | DefaultListForm |
|
||||
| Choice | Document, Catalog, ChartOf*, ExchangePlan, BusinessProcess, Task | Список (DynamicList) | DefaultChoiceForm |
|
||||
| Record | InformationRegister | Запись (InformationRegisterRecordManager) | DefaultRecordForm |
|
||||
| Purpose | Какая форма | Становится основной |
|
||||
|---------|-------------|---------------------|
|
||||
| Object | форма объекта (элемента, документа, обработки) | да |
|
||||
| List | форма списка | да |
|
||||
| Choice | форма выбора | да |
|
||||
| Folder | форма группы | да |
|
||||
| FolderChoice | форма выбора группы | да |
|
||||
| Record | форма записи | да |
|
||||
| RecordSet | форма набора записей | нет — в платформе нет такого свойства |
|
||||
| Save | форма сохранения настроек | да |
|
||||
| Load | форма загрузки настроек | да |
|
||||
| Custom | произвольная форма, без привязки к объекту | нет |
|
||||
|
||||
### Что доступно типу объекта
|
||||
|
||||
| Тип объекта | Назначения |
|
||||
|-------------|------------|
|
||||
| Catalog, ChartOfCharacteristicTypes | Object, Folder, List, Choice, FolderChoice, Custom |
|
||||
| Document, ChartOfAccounts, ChartOfCalculationTypes, ExchangePlan, BusinessProcess, Task | Object, List, Choice, Custom |
|
||||
| DataProcessor, Report, ExternalDataProcessor, ExternalReport | Object, Custom |
|
||||
| InformationRegister | Record, List, RecordSet, Custom |
|
||||
| AccumulationRegister, AccountingRegister, CalculationRegister | List, RecordSet, Custom |
|
||||
| DocumentJournal, FilterCriterion | List, Custom |
|
||||
| Enum | List, Choice, Custom |
|
||||
| SettingsStorage | Save, Load, Custom |
|
||||
|
||||
Недопустимое сочетание отклоняется со списком доступных для этого типа. У константы собственных
|
||||
форм нет — для неё используется общая форма (`CommonForm`).
|
||||
|
||||
## Примеры
|
||||
|
||||
```
|
||||
# Форма документа
|
||||
/form-add Documents/АвансовыйОтчет.xml ФормаДокумента --purpose Object
|
||||
/form-add Documents/АвансовыйОтчет.xml ФормаДокумента -Purpose Object
|
||||
|
||||
# Форма списка каталога
|
||||
/form-add Catalogs/Контрагенты.xml ФормаСписка --purpose List
|
||||
/form-add Catalogs/Контрагенты.xml ФормаСписка -Purpose List
|
||||
|
||||
# Форма записи регистра сведений
|
||||
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи --purpose Record
|
||||
/form-add InformationRegisters/КурсыВалют.xml ФормаЗаписи -Purpose Record
|
||||
|
||||
# Форма выбора с синонимом
|
||||
/form-add Catalogs/Номенклатура.xml ФормаВыбора --purpose Choice --synonym "Выбор номенклатуры"
|
||||
/form-add Catalogs/Номенклатура.xml ФормаВыбора -Purpose Choice -Synonym "Выбор номенклатуры"
|
||||
|
||||
# Установить как форму по умолчанию
|
||||
/form-add Documents/Заказ.xml ФормаДокументаНовая --purpose Object --set-default
|
||||
/form-add Documents/Заказ.xml ФормаДокументаНовая -Purpose Object -SetDefault
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# form-add v1.12 — Add managed form to 1C config object
|
||||
# form-add v1.28 — Add managed form to 1C config object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ObjectPath,
|
||||
@@ -9,8 +10,15 @@ param(
|
||||
|
||||
[string]$Synonym = $FormName,
|
||||
|
||||
[string]$Purpose = "Object",
|
||||
# Пусто = основная форма вида (Primary в таблице): у справочника это форма объекта,
|
||||
# у регистра сведений — форма записи, у журнала — форма списка. Жёсткое "Object"
|
||||
# по умолчанию было бы неверным для видов, у которых формы объекта не бывает.
|
||||
[string]$Purpose = "",
|
||||
|
||||
# Алиас с дефисом внутри имени: вызов вида --set-default PowerShell разбирает как имя
|
||||
# параметра "set-default" и без алиаса отвечает отказом биндинга. Написания -SetDefault,
|
||||
# --SetDefault и --setdefault совпадают с именем параметра и так.
|
||||
[Alias('set-default')]
|
||||
[switch]$SetDefault
|
||||
)
|
||||
|
||||
@@ -154,6 +162,14 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
@@ -169,6 +185,13 @@ function Detect-FormatVersion([string]$dir) {
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
|
||||
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Фаза 1: Определение типа объекта ---
|
||||
|
||||
# Resolve ObjectPath (directory → .xml)
|
||||
@@ -190,7 +213,26 @@ if (-not (Test-Path $ObjectPath)) {
|
||||
|
||||
$objectXmlFull = Resolve-Path $ObjectPath
|
||||
Assert-EditAllowed $objectXmlFull.Path 'editable'
|
||||
$script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent)
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
$script:formatVersion = $null
|
||||
$objHead = [System.IO.File]::ReadAllText($objectXmlFull.Path, [System.Text.Encoding]::UTF8)
|
||||
$objHead = $objHead.Substring(0, [Math]::Min(2000, $objHead.Length))
|
||||
if ($objHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { $script:formatVersion = $Matches[1] }
|
||||
if (-not $script:formatVersion) { $script:formatVersion = Detect-FormatVersion (Split-Path $objectXmlFull.Path -Parent) }
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# интерполируют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
$script:formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
$script:formNsDecl = $script:formNsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
}
|
||||
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
@@ -207,26 +249,166 @@ if (-not $metaDataObject) {
|
||||
$metaDataObject = $xmlDoc.DocumentElement
|
||||
}
|
||||
|
||||
$supportedTypes = @(
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal"
|
||||
)
|
||||
# --- Таблица видов: вид → допустимые назначения ---
|
||||
#
|
||||
# Одна запись на вид вместо разрозненных списков «поддерживаемые типы», «объектные типы»,
|
||||
# «обработко-подобные» и «карта типов реквизита». Раньше они расходились молча: DocumentJournal
|
||||
# был среди поддерживаемых, но не в карте типов, и в форму уходило `cfg:.Журнал` — платформа
|
||||
# такую выгрузку не принимает, а навык рапортовал успех.
|
||||
#
|
||||
# MainAttr — тип главного реквизита; `{0}` подставляется именем объекта:
|
||||
# "DynamicList" — динамический список (добавляется Settings/MainTable);
|
||||
# $null — произвольная форма, блока Attributes нет вовсе.
|
||||
# Slot — свойство объекта под «основную форму»; $null — такого свойства у вида нет.
|
||||
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
|
||||
|
||||
$formKinds = @{
|
||||
"Catalog" = @{
|
||||
"Object" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"Folder" = @{ MainAttr = "CatalogObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfCharacteristicTypes" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"Folder" = @{ MainAttr = "ChartOfCharacteristicTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultFolderForm"; SavedData = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"FolderChoice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultFolderChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Document" = @{
|
||||
"Object" = @{ MainAttr = "DocumentObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfAccounts" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfAccountsObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ChartOfCalculationTypes" = @{
|
||||
"Object" = @{ MainAttr = "ChartOfCalculationTypesObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExchangePlan" = @{
|
||||
"Object" = @{ MainAttr = "ExchangePlanObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"BusinessProcess" = @{
|
||||
"Object" = @{ MainAttr = "BusinessProcessObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Task" = @{
|
||||
"Object" = @{ MainAttr = "TaskObject.{1}"; AttrName = "Объект"; Slot = "DefaultObjectForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"DataProcessor" = @{
|
||||
"Object" = @{ MainAttr = "DataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Report" = @{
|
||||
"Object" = @{ MainAttr = "ReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExternalDataProcessor" = @{
|
||||
"Object" = @{ MainAttr = "ExternalDataProcessorObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"ExternalReport" = @{
|
||||
"Object" = @{ MainAttr = "ExternalReportObject.{1}"; AttrName = "Объект"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"InformationRegister" = @{
|
||||
"Record" = @{ MainAttr = "InformationRegisterRecordManager.{1}"; AttrName = "Запись"; Slot = "DefaultRecordForm"; SavedData = $true; Primary = $true }
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm" }
|
||||
"RecordSet" = @{ MainAttr = "InformationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"AccumulationRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "AccumulationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"AccountingRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "AccountingRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"CalculationRegister" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"RecordSet" = @{ MainAttr = "CalculationRegisterRecordSet.{1}"; AttrName = "Набор"; Slot = $null; SavedData = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"DocumentJournal" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"FilterCriterion" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultForm"; Primary = $true }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"Enum" = @{
|
||||
"List" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultListForm"; Primary = $true }
|
||||
"Choice" = @{ MainAttr = "DynamicList"; AttrName = "Список"; Slot = "DefaultChoiceForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
"SettingsStorage" = @{
|
||||
"Save" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultSaveForm"; Primary = $true }
|
||||
"Load" = @{ MainAttr = $null; AttrName = $null; Slot = "DefaultLoadForm" }
|
||||
"Custom" = @{ MainAttr = $null; AttrName = $null; Slot = $null }
|
||||
}
|
||||
}
|
||||
|
||||
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает — отказ с причиной,
|
||||
# а не «тип не поддерживается».
|
||||
$noOwnForms = @{
|
||||
"Constant" = "у константы нет собственных форм — используйте общую форму (CommonForm)"
|
||||
}
|
||||
|
||||
$supportedTypes = @($formKinds.Keys) + @($noOwnForms.Keys)
|
||||
|
||||
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в метаданных
|
||||
# формы есть <ExtendedPresentation>.
|
||||
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
|
||||
|
||||
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему документу
|
||||
# имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса есть свойство
|
||||
# <Task>, и он определялся как задача, после чего имя объекта не находилось вовсе.
|
||||
$objectType = $null
|
||||
$objectNode = $null
|
||||
foreach ($t in $supportedTypes) {
|
||||
$node = $xmlDoc.SelectSingleNode("//md:$t", $nsMgr)
|
||||
if ($node) {
|
||||
$objectType = $t
|
||||
$objectNode = $node
|
||||
foreach ($child in $metaDataObject.ChildNodes) {
|
||||
if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||
$objectType = $child.LocalName
|
||||
$objectNode = $child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($objectType -and -not ($formKinds.ContainsKey($objectType) -or $noOwnForms.ContainsKey($objectType))) {
|
||||
Write-Error "Тип объекта '$objectType' не поддерживается. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $objectType) {
|
||||
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $($supportedTypes -join ', ')"
|
||||
Write-Error "Не удалось определить тип объекта. Поддерживаемые типы: $(($formKinds.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($noOwnForms.ContainsKey($objectType)) {
|
||||
Write-Error "$objectType не поддерживается: $($noOwnForms[$objectType])"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -244,44 +426,58 @@ Write-Host "Object: $objectType.$objectName"
|
||||
|
||||
# --- Фаза 2: Валидация Purpose ---
|
||||
|
||||
$Purpose = $Purpose.Substring(0,1).ToUpper() + $Purpose.Substring(1).ToLower()
|
||||
# Нормализация
|
||||
switch ($Purpose) {
|
||||
"Object" { }
|
||||
"List" { }
|
||||
"Choice" { }
|
||||
"Record" { }
|
||||
default {
|
||||
Write-Error "Недопустимое назначение: $Purpose. Допустимые: Object, List, Choice, Record"
|
||||
exit 1
|
||||
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell (в py-порту .lower()).
|
||||
$kindPurposes = $formKinds[$objectType]
|
||||
|
||||
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||
$purposeSynonyms = @{
|
||||
"формаобъекта"="Object"; "формаэлемента"="Object"; "формадокумента"="Object"
|
||||
"объект"="Object"; "элемент"="Object"; "документ"="Object"; "objectform"="Object"
|
||||
"формасписка"="List"; "список"="List"; "listform"="List"
|
||||
"формавыбора"="Choice"; "выбор"="Choice"; "choiceform"="Choice"
|
||||
"формагруппы"="Folder"; "группа"="Folder"; "folderform"="Folder"
|
||||
"формавыборагруппы"="FolderChoice"; "выборгруппы"="FolderChoice"; "folderchoiceform"="FolderChoice"
|
||||
"формазаписи"="Record"; "запись"="Record"; "recordform"="Record"
|
||||
"форманаборазаписей"="RecordSet"; "наборзаписей"="RecordSet"; "recordsetform"="RecordSet"
|
||||
"формасохранения"="Save"; "формасохранениянастроек"="Save"; "сохранение"="Save"; "saveform"="Save"
|
||||
"формазагрузки"="Load"; "формазагрузкинастроек"="Load"; "загрузка"="Load"; "loadform"="Load"
|
||||
"произвольная"="Custom"; "произвольнаяформа"="Custom"; "customform"="Custom"
|
||||
}
|
||||
if ($Purpose) {
|
||||
$purposeProbe = ($Purpose -replace '[\s_-]', '').ToLowerInvariant()
|
||||
$isKnownPurpose = $false
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $isKnownPurpose = $true; break }
|
||||
}
|
||||
if (-not $isKnownPurpose -and $purposeSynonyms.ContainsKey($purposeProbe)) {
|
||||
$Purpose = $purposeSynonyms[$purposeProbe]
|
||||
}
|
||||
}
|
||||
if (-not $Purpose) {
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($kindPurposes[$p].Primary) { $Purpose = $p; break }
|
||||
}
|
||||
}
|
||||
$purposeKey = $null
|
||||
foreach ($p in $kindPurposes.Keys) {
|
||||
if ($p.ToLowerInvariant() -eq $Purpose.ToLowerInvariant()) { $purposeKey = $p; break }
|
||||
}
|
||||
if (-not $purposeKey) {
|
||||
Write-Error "Назначение '$Purpose' недопустимо для $objectType. Допустимые: $(($kindPurposes.Keys | Sort-Object) -join ', ')"
|
||||
exit 1
|
||||
}
|
||||
$Purpose = $purposeKey
|
||||
$purposeRule = $kindPurposes[$Purpose]
|
||||
|
||||
$objectLikeTypes = @("Document", "Catalog", "ChartOfAccounts", "ChartOfCharacteristicTypes", "ExchangePlan", "BusinessProcess", "Task")
|
||||
$processorLikeTypes = @("DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport")
|
||||
|
||||
switch ($Purpose) {
|
||||
"Object" {
|
||||
# допустимо для всех типов
|
||||
}
|
||||
"List" {
|
||||
if ($objectType -eq "DataProcessor") {
|
||||
Write-Error "Purpose=List недопустим для DataProcessor"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
"Choice" {
|
||||
if ($objectType -in $processorLikeTypes -or $objectType -eq "InformationRegister") {
|
||||
Write-Error "Purpose=Choice недопустим для $objectType"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
"Record" {
|
||||
if ($objectType -ne "InformationRegister") {
|
||||
Write-Error "Purpose=Record допустим только для InformationRegister"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой MainAttr — это
|
||||
# произвольная форма (законное состояние), а вот наполовину заполненная запись означала бы, что
|
||||
# таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||
if ($purposeRule.MainAttr -and -not $purposeRule.AttrName) {
|
||||
Write-Error "Внутренняя ошибка таблицы видов: у $objectType/$Purpose задан MainAttr без AttrName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Фаза 3: Создание файлов ---
|
||||
@@ -313,9 +509,16 @@ if ($objectType -in $processorLikeTypes) {
|
||||
$extPresentationLine = "`n`t`t`t<ExtendedPresentation/>"
|
||||
}
|
||||
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
$useInIfcLine = ""
|
||||
if ((Get-FormatRank $script:formatVersion) -ge 221) {
|
||||
$useInIfcLine = "`n`t`t`t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>"
|
||||
}
|
||||
|
||||
$formMetaXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
|
||||
<MetaDataObject $($script:xmlnsDecl) version="$($script:formatVersion)">
|
||||
<Form uuid="$formUuid">
|
||||
<Properties>
|
||||
<Name>$FormName</Name>
|
||||
@@ -331,120 +534,74 @@ $formMetaXml = @"
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||
</UsePurposes>$extPresentationLine
|
||||
</UsePurposes>$useInIfcLine$extPresentationLine
|
||||
</Properties>
|
||||
</Form>
|
||||
</MetaDataObject>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($formMetaPath, $formMetaXml, $encBom)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $formMetaPath $formMetaXml $encBom
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
$formXmlPath = Join-Path $formExtDir "Form.xml"
|
||||
|
||||
$formNsDecl = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||
$attributesBlock = ""
|
||||
if ($purposeRule.MainAttr) {
|
||||
$mainAttrType = $purposeRule.MainAttr -f $objectType, $objectName
|
||||
$mainAttrName = $purposeRule.AttrName
|
||||
|
||||
if ($Purpose -eq "List" -or $Purpose -eq "Choice") {
|
||||
# Динамический список
|
||||
# MainTable: тип.имя
|
||||
$mainTable = "$objectType.$objectName"
|
||||
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||
$tailLines = ""
|
||||
if ($mainAttrType -eq "DynamicList") {
|
||||
$mainTable = "$objectType.$objectName"
|
||||
$tailLines = "`n`t`t`t<Settings xsi:type=""DynamicList"">`n`t`t`t`t<MainTable>$mainTable</MainTable>`n`t`t`t</Settings>"
|
||||
} elseif ($purposeRule.SavedData) {
|
||||
$tailLines = "`n`t`t`t<SavedData>true</SavedData>"
|
||||
}
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
<ChildItems/>
|
||||
<Attributes>
|
||||
<Attribute name="Список" id="1">
|
||||
<Type>
|
||||
<v8:Type>cfg:DynamicList</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>
|
||||
<Settings xsi:type="DynamicList">
|
||||
<MainTable>$mainTable</MainTable>
|
||||
</Settings>
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
"@
|
||||
} elseif ($Purpose -eq "Record") {
|
||||
# Запись регистра сведений
|
||||
$mainAttrName = "Запись"
|
||||
$mainAttrType = "InformationRegisterRecordManager.$objectName"
|
||||
$attributesBlock = @"
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
<ChildItems/>
|
||||
<Attributes>
|
||||
<Attribute name="$mainAttrName" id="1">
|
||||
<Type>
|
||||
<v8:Type>cfg:$mainAttrType</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>
|
||||
<SavedData>true</SavedData>
|
||||
<MainAttribute>true</MainAttribute>$tailLines
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
"@
|
||||
} else {
|
||||
# Object — форма объекта
|
||||
$mainAttrName = "Объект"
|
||||
|
||||
# Маппинг типа объекта на тип реквизита
|
||||
$attrTypeMap = @{
|
||||
"Document" = "DocumentObject"
|
||||
"Catalog" = "CatalogObject"
|
||||
"DataProcessor" = "DataProcessorObject"
|
||||
"Report" = "ReportObject"
|
||||
"ExternalDataProcessor" = "ExternalDataProcessorObject"
|
||||
"ExternalReport" = "ExternalReportObject"
|
||||
"ChartOfAccounts" = "ChartOfAccountsObject"
|
||||
"ChartOfCharacteristicTypes" = "ChartOfCharacteristicTypesObject"
|
||||
"ExchangePlan" = "ExchangePlanObject"
|
||||
"BusinessProcess" = "BusinessProcessObject"
|
||||
"Task" = "TaskObject"
|
||||
"InformationRegister" = "InformationRegisterRecordManager"
|
||||
"AccumulationRegister" = "AccumulationRegisterRecordSet"
|
||||
}
|
||||
|
||||
$mainAttrType = "$($attrTypeMap[$objectType]).$objectName"
|
||||
|
||||
# SavedData: standard for Catalog/Document/etc, but not for processor-like (DataProcessor/Report/External*)
|
||||
$savedDataLine = ""
|
||||
if ($objectType -notin $processorLikeTypes) {
|
||||
$savedDataLine = "`n`t`t`t<SavedData>true</SavedData>"
|
||||
}
|
||||
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $formNsDecl version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
<ChildItems/>
|
||||
<Attributes>
|
||||
<Attribute name="$mainAttrName" id="1">
|
||||
<Type>
|
||||
<v8:Type>cfg:$mainAttrType</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>$savedDataLine
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
"@
|
||||
}
|
||||
|
||||
# Произвольная форма (MainAttr = $null) — без блока Attributes вовсе. В типовых это самая
|
||||
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
|
||||
$formXml = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form $($script:formNsDecl) version="$($script:formatVersion)">
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">
|
||||
<Autofill>true</Autofill>
|
||||
</AutoCommandBar>
|
||||
<ChildItems/>$attributesBlock
|
||||
</Form>
|
||||
"@
|
||||
|
||||
if (Test-Path $formXmlPath) {
|
||||
Write-Host "[SKIP] Form.xml already exists: $formXmlPath — not overwriting"
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText($formXmlPath, $formXml, $encBom)
|
||||
Write-XmlFile $formXmlPath $formXml $encBom
|
||||
}
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
@@ -476,6 +633,11 @@ $moduleBsl = @"
|
||||
if (Test-Path $modulePath) {
|
||||
Write-Host "[SKIP] Module.bsl already exists: $modulePath — not overwriting"
|
||||
} else {
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без). Шаблон берёт переводы строк из
|
||||
# самого скрипта, а он в репозитории хранится с LF.
|
||||
$moduleBsl = ($moduleBsl -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($modulePath, $moduleBsl, $encBom)
|
||||
}
|
||||
|
||||
@@ -553,24 +715,17 @@ $isFirstFormForPurpose = $false
|
||||
$defaultPropName = $null
|
||||
$defaultValue = "$objectType.$objectName.Form.$FormName"
|
||||
|
||||
# Определяем имя свойства для DefaultForm
|
||||
switch ($Purpose) {
|
||||
"Object" {
|
||||
if ($objectType -in $processorLikeTypes) {
|
||||
$defaultPropName = "DefaultForm"
|
||||
} else {
|
||||
$defaultPropName = "DefaultObjectForm"
|
||||
}
|
||||
}
|
||||
"List" { $defaultPropName = "DefaultListForm" }
|
||||
"Choice" { $defaultPropName = "DefaultChoiceForm" }
|
||||
"Record" { $defaultPropName = "DefaultRecordForm" }
|
||||
}
|
||||
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному Purpose без учёта
|
||||
# вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не находился, навык
|
||||
# молча ничего не делал.
|
||||
$defaultPropName = $purposeRule.Slot
|
||||
|
||||
# Проверяем, установлено ли уже значение
|
||||
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
|
||||
if ($defaultNode) {
|
||||
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
|
||||
$defaultNode = $null
|
||||
if ($defaultPropName) {
|
||||
$defaultNode = $xmlDoc.SelectSingleNode("//md:${objectType}/md:Properties/md:$defaultPropName", $nsMgr)
|
||||
if ($defaultNode) {
|
||||
$isFirstFormForPurpose = [string]::IsNullOrWhiteSpace($defaultNode.InnerText)
|
||||
}
|
||||
}
|
||||
|
||||
$defaultUpdated = $false
|
||||
@@ -585,12 +740,27 @@ if ($SetDefault -or $isFirstFormForPurpose) {
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
$stream = New-Object System.IO.FileStream($objectXmlFull.Path, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $objectXmlFull.Path) -and ([System.IO.File]::ReadAllText($objectXmlFull.Path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($objectXmlFull.Path, $xmlText, $encBom)
|
||||
|
||||
# --- Фаза 5: Вывод ---
|
||||
|
||||
@@ -617,5 +787,9 @@ if ($alreadyRegistered) {
|
||||
}
|
||||
if ($defaultUpdated) {
|
||||
Write-Host "${defaultPropName}: $defaultValue"
|
||||
} elseif (-not $defaultPropName) {
|
||||
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||
# у платформы нет (форма набора записей, произвольная форма).
|
||||
Write-Host "Основной не назначена: у $objectType нет свойства для формы с назначением $Purpose"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-add v1.12 — Add managed form to 1C config object
|
||||
# form-add v1.28 — Add managed form to 1C config object (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,28 @@ import uuid
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -196,6 +218,16 @@ NSMAP = {
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
@@ -210,6 +242,12 @@ def detect_format_version(d):
|
||||
return "2.17"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def _detect_xml_style(path):
|
||||
"""Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
|
||||
финальный перенос. None → файл новый (сохранить текущее поведение)."""
|
||||
@@ -227,21 +265,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -257,10 +296,24 @@ def save_xml_with_bom(tree, path):
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
|
||||
Модуль .bsl сюда НЕ идёт — он пишется отдельно.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -270,9 +323,14 @@ def main():
|
||||
parser.add_argument("-ObjectPath", required=True)
|
||||
parser.add_argument("-FormName", required=True)
|
||||
parser.add_argument("-Synonym", default=None)
|
||||
parser.add_argument("-Purpose", default="Object")
|
||||
parser.add_argument("-SetDefault", action="store_true")
|
||||
args = parser.parse_args()
|
||||
# Пусто = основная форма вида (primary в таблице): у справочника это форма объекта,
|
||||
# у регистра сведений — форма записи, у журнала — форма списка.
|
||||
parser.add_argument("-Purpose", default="")
|
||||
# Написания с дефисом внутри имени и с двойным дефисом: в PS-порте их принимает алиас
|
||||
# set-default, здесь — перечисление опций, чтобы порты принимали ровно одно и то же.
|
||||
parser.add_argument("-SetDefault", "--SetDefault", "--set-default", "-set-default",
|
||||
dest="SetDefault", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_path = args.ObjectPath
|
||||
form_name = args.FormName
|
||||
@@ -299,30 +357,244 @@ def main():
|
||||
|
||||
object_xml_full = os.path.abspath(object_path)
|
||||
assert_edit_allowed(object_xml_full, "editable")
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
# Версию берём прежде всего из корня самого объекта — он её несёт всегда, а у автономной
|
||||
# внешней обработки/отчёта подниматься к Configuration.xml просто некуда.
|
||||
format_version = None
|
||||
with open(object_xml_full, "r", encoding="utf-8-sig") as f:
|
||||
obj_head = f.read(2000)
|
||||
m_ver = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', obj_head)
|
||||
if m_ver:
|
||||
format_version = m_ver.group(1)
|
||||
if not format_version:
|
||||
format_version = detect_format_version(os.path.dirname(object_xml_full))
|
||||
|
||||
# Объявления пространств имён — одной переменной на корень: места эмиссии их только
|
||||
# подставляют. Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
|
||||
xmlns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
)
|
||||
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
|
||||
# дописать в конец нельзя.
|
||||
if format_rank(format_version) >= 221:
|
||||
pal = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
|
||||
xmlns_decl = xmlns_decl.replace(' xmlns:style=', pal)
|
||||
form_ns_decl = form_ns_decl.replace(' xmlns:style=', pal)
|
||||
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(object_xml_full, parser_xml)
|
||||
root = tree.getroot()
|
||||
|
||||
supported_types = [
|
||||
"Document", "Catalog", "DataProcessor", "Report",
|
||||
"ExternalDataProcessor", "ExternalReport",
|
||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal",
|
||||
]
|
||||
# --- Таблица видов: вид -> допустимые назначения ---
|
||||
#
|
||||
# Зеркало $formKinds из PS-порта. Одна запись на вид вместо разрозненных списков
|
||||
# «поддерживаемые типы», «объектные типы», «обработко-подобные» и «карта типов реквизита»:
|
||||
# раньше они расходились молча, и для DocumentJournal в форму уходило `cfg:.Журнал`.
|
||||
#
|
||||
# main_attr — тип главного реквизита, {0} = вид, {1} = имя объекта;
|
||||
# "DynamicList" — динамический список (добавляется Settings/MainTable);
|
||||
# None — произвольная форма, блока Attributes нет вовсе.
|
||||
# slot — свойство объекта под «основную форму»; None — такого свойства у вида нет.
|
||||
# Эталон таблицы — docs/1c-form-spec.md, сверяется гардом check-form-purposes.mjs.
|
||||
|
||||
form_kinds = {
|
||||
"Catalog": {
|
||||
"Object": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"Folder": {"main_attr": "CatalogObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultFolderForm", "saved_data": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfCharacteristicTypes": {
|
||||
"Object": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"Folder": {"main_attr": "ChartOfCharacteristicTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultFolderForm", "saved_data": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"FolderChoice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultFolderChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Document": {
|
||||
"Object": {"main_attr": "DocumentObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfAccounts": {
|
||||
"Object": {"main_attr": "ChartOfAccountsObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ChartOfCalculationTypes": {
|
||||
"Object": {"main_attr": "ChartOfCalculationTypesObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExchangePlan": {
|
||||
"Object": {"main_attr": "ExchangePlanObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"BusinessProcess": {
|
||||
"Object": {"main_attr": "BusinessProcessObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Task": {
|
||||
"Object": {"main_attr": "TaskObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultObjectForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"DataProcessor": {
|
||||
"Object": {"main_attr": "DataProcessorObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Report": {
|
||||
"Object": {"main_attr": "ReportObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExternalDataProcessor": {
|
||||
"Object": {"main_attr": "ExternalDataProcessorObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"ExternalReport": {
|
||||
"Object": {"main_attr": "ExternalReportObject.{1}", "attr_name": "Объект",
|
||||
"slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"InformationRegister": {
|
||||
"Record": {"main_attr": "InformationRegisterRecordManager.{1}", "attr_name": "Запись",
|
||||
"slot": "DefaultRecordForm", "saved_data": True, "primary": True},
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm"},
|
||||
"RecordSet": {"main_attr": "InformationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"AccumulationRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "AccumulationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"AccountingRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "AccountingRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"CalculationRegister": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"RecordSet": {"main_attr": "CalculationRegisterRecordSet.{1}", "attr_name": "Набор",
|
||||
"slot": None, "saved_data": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"DocumentJournal": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"FilterCriterion": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultForm", "primary": True},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"Enum": {
|
||||
"List": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultListForm", "primary": True},
|
||||
"Choice": {"main_attr": "DynamicList", "attr_name": "Список", "slot": "DefaultChoiceForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
"SettingsStorage": {
|
||||
"Save": {"main_attr": None, "attr_name": None, "slot": "DefaultSaveForm", "primary": True},
|
||||
"Load": {"main_attr": None, "attr_name": None, "slot": "DefaultLoadForm"},
|
||||
"Custom": {"main_attr": None, "attr_name": None, "slot": None},
|
||||
},
|
||||
}
|
||||
|
||||
# Виды, у которых свойство DefaultForm есть, но собственных форм не бывает.
|
||||
no_own_forms = {
|
||||
"Constant": "у константы нет собственных форм — используйте общую форму (CommonForm)",
|
||||
}
|
||||
|
||||
supported_types = list(form_kinds) + list(no_own_forms)
|
||||
|
||||
# Отдельный факт, не выводимый из таблицы назначений: у форм обработок и отчётов в
|
||||
# метаданных формы есть <ExtendedPresentation>.
|
||||
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
|
||||
|
||||
# Вид объекта — первый элемент-потомок MetaDataObject, а не первое совпавшее по всему
|
||||
# документу имя. Поиск по документу зависел от порядка перебора видов: у бизнес-процесса
|
||||
# есть свойство <Task>, и он определялся как задача, после чего имя объекта не находилось.
|
||||
object_type = None
|
||||
object_node = None
|
||||
for t in supported_types:
|
||||
node = root.find(f".//md:{t}", NSMAP)
|
||||
if node is not None:
|
||||
object_type = t
|
||||
object_node = node
|
||||
for child in root:
|
||||
if isinstance(child.tag, str):
|
||||
object_type = etree.QName(child).localname
|
||||
object_node = child
|
||||
break
|
||||
|
||||
if object_type is not None and object_type not in form_kinds and object_type not in no_own_forms:
|
||||
print(f"Тип объекта '{object_type}' не поддерживается. "
|
||||
f"Поддерживаемые типы: {', '.join(sorted(form_kinds))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if object_type is None:
|
||||
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(supported_types)}", file=sys.stderr)
|
||||
print(f"Не удалось определить тип объекта. Поддерживаемые типы: {', '.join(sorted(form_kinds))}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if object_type in no_own_forms:
|
||||
print(f"{object_type} не поддерживается: {no_own_forms[object_type]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Object name from Properties/Name
|
||||
@@ -339,32 +611,59 @@ def main():
|
||||
|
||||
# --- Phase 2: Validate Purpose ---
|
||||
|
||||
# Normalize: capitalize first letter, lowercase rest
|
||||
purpose = purpose[0].upper() + purpose[1:].lower()
|
||||
# Назначение ищем в таблице регистронезависимо — как принимает PowerShell.
|
||||
kind_purposes = form_kinds[object_type]
|
||||
|
||||
valid_purposes = ["Object", "List", "Choice", "Record"]
|
||||
if purpose not in valid_purposes:
|
||||
print(f"Недопустимое назначение: {purpose}. Допустимые: Object, List, Choice, Record", file=sys.stderr)
|
||||
# Обиходные написания назначения приводим к канону молча: русское название вида формы и
|
||||
# английское с суффиксом Form. Ключ нормализуем — регистр, пробелы и разделители не значимы.
|
||||
# Канон в документации один; здесь только приём ошибочного ввода, чтобы вызов не падал на форме
|
||||
# записи вместо назначения. Применимость назначения к виду объекта проверяется ниже как обычно.
|
||||
purpose_synonyms = {
|
||||
"формаобъекта": "Object", "формаэлемента": "Object", "формадокумента": "Object",
|
||||
"объект": "Object", "элемент": "Object", "документ": "Object", "objectform": "Object",
|
||||
"формасписка": "List", "список": "List", "listform": "List",
|
||||
"формавыбора": "Choice", "выбор": "Choice", "choiceform": "Choice",
|
||||
"формагруппы": "Folder", "группа": "Folder", "folderform": "Folder",
|
||||
"формавыборагруппы": "FolderChoice", "выборгруппы": "FolderChoice",
|
||||
"folderchoiceform": "FolderChoice",
|
||||
"формазаписи": "Record", "запись": "Record", "recordform": "Record",
|
||||
"форманаборазаписей": "RecordSet", "наборзаписей": "RecordSet", "recordsetform": "RecordSet",
|
||||
"формасохранения": "Save", "формасохранениянастроек": "Save", "сохранение": "Save",
|
||||
"saveform": "Save",
|
||||
"формазагрузки": "Load", "формазагрузкинастроек": "Load", "загрузка": "Load",
|
||||
"loadform": "Load",
|
||||
"произвольная": "Custom", "произвольнаяформа": "Custom", "customform": "Custom",
|
||||
}
|
||||
if purpose:
|
||||
purpose_probe = re.sub(r"[\s_-]", "", purpose).lower()
|
||||
is_known_purpose = any(k.lower() == purpose.lower() for k in kind_purposes)
|
||||
if not is_known_purpose and purpose_probe in purpose_synonyms:
|
||||
purpose = purpose_synonyms[purpose_probe]
|
||||
|
||||
if not purpose:
|
||||
for k, rule in kind_purposes.items():
|
||||
if rule.get("primary"):
|
||||
purpose = k
|
||||
break
|
||||
purpose_key = None
|
||||
for k in kind_purposes:
|
||||
if k.lower() == purpose.lower():
|
||||
purpose_key = k
|
||||
break
|
||||
if purpose_key is None:
|
||||
print(f"Назначение '{purpose}' недопустимо для {object_type}. "
|
||||
f"Допустимые: {', '.join(sorted(kind_purposes))}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
purpose = purpose_key
|
||||
purpose_rule = kind_purposes[purpose]
|
||||
|
||||
object_like_types = ["Document", "Catalog", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ExchangePlan", "BusinessProcess", "Task"]
|
||||
processor_like_types = ["DataProcessor", "Report", "ExternalDataProcessor", "ExternalReport"]
|
||||
|
||||
if purpose == "List":
|
||||
if object_type == "DataProcessor":
|
||||
print("Purpose=List недопустим для DataProcessor", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif purpose == "Choice":
|
||||
if object_type in processor_like_types or object_type == "InformationRegister":
|
||||
print(f"Purpose=Choice недопустим для {object_type}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
elif purpose == "Record":
|
||||
if object_type != "InformationRegister":
|
||||
print("Purpose=Record допустим только для InformationRegister", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# Гард от повторения дефекта: запись таблицы обязана быть заполненной. Пустой main_attr —
|
||||
# это произвольная форма (законное состояние), а наполовину заполненная запись означала бы,
|
||||
# что таблицу правили невнимательно, и в XML уйдёт мусор вроде `cfg:.Журнал`.
|
||||
if purpose_rule.get("main_attr") and not purpose_rule.get("attr_name"):
|
||||
print(f"Внутренняя ошибка таблицы видов: у {object_type}/{purpose} задан main_attr без attr_name",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Phase 3: Create files ---
|
||||
|
||||
@@ -388,24 +687,7 @@ def main():
|
||||
|
||||
form_meta_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xen="http://v8.1c.ru/8.3/xcf/enums"'
|
||||
' xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
f' version="{format_version}">\n'
|
||||
f'<MetaDataObject {xmlns_decl} version="{format_version}">\n'
|
||||
f'\t<Form uuid="{form_uuid}">\n'
|
||||
'\t\t<Properties>\n'
|
||||
f'\t\t\t<Name>{form_name}</Name>\n'
|
||||
@@ -422,136 +704,68 @@ def main():
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>\n'
|
||||
'\t\t\t\t<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>\n'
|
||||
'\t\t\t</UsePurposes>\n'
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# сразу после UsePurposes (проверено по выгрузке 8.5, до ExtendedPresentation).
|
||||
+ ('\t\t\t<UseInInterfaceCompatibilityMode>Any</UseInInterfaceCompatibilityMode>\n'
|
||||
if format_rank(format_version) >= 221 else '')
|
||||
+ ('\t\t\t<ExtendedPresentation/>\n' if object_type in processor_like_types else '')
|
||||
+ '\t\t</Properties>\n'
|
||||
'\t</Form>\n'
|
||||
'</MetaDataObject>'
|
||||
)
|
||||
|
||||
write_text_with_bom(form_meta_path, form_meta_xml)
|
||||
write_xml_file(form_meta_path, form_meta_xml)
|
||||
|
||||
# --- 3b. Form.xml ---
|
||||
|
||||
form_xml_path = os.path.join(form_ext_dir, "Form.xml")
|
||||
|
||||
form_ns_decl = (
|
||||
'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
|
||||
' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||
' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
|
||||
' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
|
||||
' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
|
||||
' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||
' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
|
||||
' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
|
||||
' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
|
||||
' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
|
||||
' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
|
||||
' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
|
||||
' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
|
||||
' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
|
||||
' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
|
||||
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
# Одна ветка вместо трёх: что писать, решает запись таблицы видов. Раньше тип главного
|
||||
# реквизита брался из отдельной карты, и отсутствие вида в ней давало `cfg:.Имя` — молча.
|
||||
attributes_block = ''
|
||||
if purpose_rule.get("main_attr"):
|
||||
main_attr_type = purpose_rule["main_attr"].format(object_type, object_name)
|
||||
main_attr_name = purpose_rule["attr_name"]
|
||||
|
||||
# Динамический список несёт MainTable, остальные типы — SavedData по записи таблицы.
|
||||
tail_lines = ''
|
||||
if main_attr_type == "DynamicList":
|
||||
main_table = f"{object_type}.{object_name}"
|
||||
tail_lines = ('\t\t\t<Settings xsi:type="DynamicList">\n'
|
||||
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
|
||||
'\t\t\t</Settings>\n')
|
||||
elif purpose_rule.get("saved_data"):
|
||||
tail_lines = '\t\t\t<SavedData>true</SavedData>\n'
|
||||
|
||||
attributes_block = (
|
||||
'\t<Attributes>\n'
|
||||
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
|
||||
'\t\t\t<Type>\n'
|
||||
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
|
||||
'\t\t\t</Type>\n'
|
||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
||||
f'{tail_lines}'
|
||||
'\t\t</Attribute>\n'
|
||||
'\t</Attributes>\n'
|
||||
)
|
||||
|
||||
# Произвольная форма (main_attr=None) — без блока Attributes вовсе. В типовых это самая
|
||||
# частая форма после объектной: 907 у справочников, 941 у документов, 3482 у отчётов.
|
||||
form_xml = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
|
||||
'\t\t<Autofill>true</Autofill>\n'
|
||||
'\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n'
|
||||
f'{attributes_block}'
|
||||
'</Form>'
|
||||
)
|
||||
|
||||
if purpose in ("List", "Choice"):
|
||||
# Dynamic list
|
||||
main_table = f"{object_type}.{object_name}"
|
||||
|
||||
form_xml = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
|
||||
'\t\t<Autofill>true</Autofill>\n'
|
||||
'\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n'
|
||||
'\t<Attributes>\n'
|
||||
'\t\t<Attribute name="\u0421\u043f\u0438\u0441\u043e\u043a" id="1">\n'
|
||||
'\t\t\t<Type>\n'
|
||||
'\t\t\t\t<v8:Type>cfg:DynamicList</v8:Type>\n'
|
||||
'\t\t\t</Type>\n'
|
||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
||||
'\t\t\t<Settings xsi:type="DynamicList">\n'
|
||||
f'\t\t\t\t<MainTable>{main_table}</MainTable>\n'
|
||||
'\t\t\t</Settings>\n'
|
||||
'\t\t</Attribute>\n'
|
||||
'\t</Attributes>\n'
|
||||
'</Form>'
|
||||
)
|
||||
|
||||
elif purpose == "Record":
|
||||
# Information register record
|
||||
main_attr_name = "\u0417\u0430\u043f\u0438\u0441\u044c"
|
||||
main_attr_type = f"InformationRegisterRecordManager.{object_name}"
|
||||
|
||||
form_xml = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
|
||||
'\t\t<Autofill>true</Autofill>\n'
|
||||
'\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n'
|
||||
'\t<Attributes>\n'
|
||||
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
|
||||
'\t\t\t<Type>\n'
|
||||
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
|
||||
'\t\t\t</Type>\n'
|
||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
||||
'\t\t\t<SavedData>true</SavedData>\n'
|
||||
'\t\t</Attribute>\n'
|
||||
'\t</Attributes>\n'
|
||||
'</Form>'
|
||||
)
|
||||
|
||||
else:
|
||||
# Object — object form
|
||||
main_attr_name = "\u041e\u0431\u044a\u0435\u043a\u0442"
|
||||
|
||||
attr_type_map = {
|
||||
"Document": "DocumentObject",
|
||||
"Catalog": "CatalogObject",
|
||||
"DataProcessor": "DataProcessorObject",
|
||||
"Report": "ReportObject",
|
||||
"ExternalDataProcessor": "ExternalDataProcessorObject",
|
||||
"ExternalReport": "ExternalReportObject",
|
||||
"ChartOfAccounts": "ChartOfAccountsObject",
|
||||
"ChartOfCharacteristicTypes": "ChartOfCharacteristicTypesObject",
|
||||
"ExchangePlan": "ExchangePlanObject",
|
||||
"BusinessProcess": "BusinessProcessObject",
|
||||
"Task": "TaskObject",
|
||||
"InformationRegister": "InformationRegisterRecordManager",
|
||||
"AccumulationRegister": "AccumulationRegisterRecordSet",
|
||||
}
|
||||
|
||||
main_attr_type = f"{attr_type_map[object_type]}.{object_name}"
|
||||
|
||||
# SavedData: standard for Catalog/Document/etc, but not for processor-like (DataProcessor/Report/External*)
|
||||
saved_data_line = ''
|
||||
if object_type not in processor_like_types:
|
||||
saved_data_line = '\t\t\t<SavedData>true</SavedData>\n'
|
||||
|
||||
form_xml = (
|
||||
f'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
f'<Form {form_ns_decl} version="{format_version}">\n'
|
||||
'\t<AutoCommandBar name="\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c" id="-1">\n'
|
||||
'\t\t<Autofill>true</Autofill>\n'
|
||||
'\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n'
|
||||
'\t<Attributes>\n'
|
||||
f'\t\t<Attribute name="{main_attr_name}" id="1">\n'
|
||||
'\t\t\t<Type>\n'
|
||||
f'\t\t\t\t<v8:Type>cfg:{main_attr_type}</v8:Type>\n'
|
||||
'\t\t\t</Type>\n'
|
||||
'\t\t\t<MainAttribute>true</MainAttribute>\n'
|
||||
f'{saved_data_line}'
|
||||
'\t\t</Attribute>\n'
|
||||
'\t</Attributes>\n'
|
||||
'</Form>'
|
||||
)
|
||||
|
||||
if os.path.exists(form_xml_path):
|
||||
print(f"[SKIP] Form.xml already exists: {form_xml_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(form_xml_path, form_xml)
|
||||
write_xml_file(form_xml_path, form_xml)
|
||||
|
||||
# --- 3c. Module.bsl ---
|
||||
|
||||
@@ -582,7 +796,10 @@ def main():
|
||||
if os.path.exists(module_path):
|
||||
print(f"[SKIP] Module.bsl already exists: {module_path} — not overwriting")
|
||||
else:
|
||||
write_text_with_bom(module_path, module_bsl)
|
||||
# Модуль пишем в каноне платформы: CRLF в разделителях строк (корпус: 2643 CRLF,
|
||||
# чисто-LF 0 из 3001). Хвостовой перевод НЕ навязываем — у платформы он
|
||||
# неканоничен (1235 модулей с ним, 766 без).
|
||||
write_utf8_bom(module_path, module_bsl.replace('\r\n', '\n').replace('\n', '\r\n'))
|
||||
|
||||
# --- Phase 4: Register in parent object ---
|
||||
|
||||
@@ -640,26 +857,18 @@ def main():
|
||||
# --- SetDefault ---
|
||||
|
||||
is_first_form_for_purpose = False
|
||||
default_prop_name = None
|
||||
default_value = f"{object_type}.{object_name}.Form.{form_name}"
|
||||
|
||||
# Determine property name for DefaultForm
|
||||
if purpose == "Object":
|
||||
if object_type in processor_like_types:
|
||||
default_prop_name = "DefaultForm"
|
||||
else:
|
||||
default_prop_name = "DefaultObjectForm"
|
||||
elif purpose == "List":
|
||||
default_prop_name = "DefaultListForm"
|
||||
elif purpose == "Choice":
|
||||
default_prop_name = "DefaultChoiceForm"
|
||||
elif purpose == "Record":
|
||||
default_prop_name = "DefaultRecordForm"
|
||||
# Свойство «основная форма» — из записи таблицы. Раньше выбиралось по одному purpose без
|
||||
# учёта вида, и для журнала писалось DefaultListForm, которого у журнала нет: слот не
|
||||
# находился, навык молча ничего не делал.
|
||||
default_prop_name = purpose_rule.get("slot")
|
||||
|
||||
# Check if value is already set
|
||||
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
|
||||
if default_node is not None:
|
||||
is_first_form_for_purpose = default_node.text is None or default_node.text.strip() == ""
|
||||
default_node = None
|
||||
if default_prop_name:
|
||||
default_node = root.find(f".//md:{object_type}/md:Properties/md:{default_prop_name}", NSMAP)
|
||||
if default_node is not None:
|
||||
is_first_form_for_purpose = not (default_node.text or "").strip()
|
||||
|
||||
default_updated = False
|
||||
if set_default or is_first_form_for_purpose:
|
||||
@@ -686,6 +895,10 @@ def main():
|
||||
print(f"Registered: <Form>{form_name}</Form> in ChildObjects")
|
||||
if default_updated:
|
||||
print(f"{default_prop_name}: {default_value}")
|
||||
elif not default_prop_name:
|
||||
# Молчать здесь нельзя: пользователь ждёт, что форма станет основной, а свойства под неё
|
||||
# у платформы нет (форма набора записей, произвольная форма).
|
||||
print(f"Основной не назначена: у {object_type} нет свойства для формы с назначением {purpose}")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# form-decompile v0.150 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias('Path')]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-decompile v0.147 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# form-decompile v0.150 — Decompile 1C managed Form.xml to JSON DSL (draft)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
|
||||
#
|
||||
@@ -13,6 +13,28 @@ import xml.etree.ElementTree as ET
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- 1. Namespaces ---
|
||||
NS_LF = "http://v8.1c.ru/8.3/xcf/logform"
|
||||
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
||||
@@ -81,29 +103,29 @@ def _attr(node, name, ns_uri=None):
|
||||
def convert_string_to_json_literal(s):
|
||||
if s is None:
|
||||
return 'null'
|
||||
sb = ['"']
|
||||
out = ['"']
|
||||
for ch in s:
|
||||
code = ord(ch)
|
||||
if code == 0x22:
|
||||
sb.append('\\"')
|
||||
out.append('\\"')
|
||||
elif code == 0x5C:
|
||||
sb.append('\\\\')
|
||||
out.append('\\\\')
|
||||
elif code == 0x08:
|
||||
sb.append('\\b')
|
||||
out.append('\\b')
|
||||
elif code == 0x09:
|
||||
sb.append('\\t')
|
||||
out.append('\\t')
|
||||
elif code == 0x0A:
|
||||
sb.append('\\n')
|
||||
out.append('\\n')
|
||||
elif code == 0x0C:
|
||||
sb.append('\\f')
|
||||
out.append('\\f')
|
||||
elif code == 0x0D:
|
||||
sb.append('\\r')
|
||||
out.append('\\r')
|
||||
elif code < 0x20:
|
||||
sb.append('\\u%04x' % code)
|
||||
out.append('\\u%04x' % code)
|
||||
else:
|
||||
sb.append(ch)
|
||||
sb.append('"')
|
||||
return ''.join(sb)
|
||||
out.append(ch)
|
||||
out.append('"')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def _num_to_str(obj):
|
||||
@@ -3115,7 +3137,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description='Decompile 1C managed Form.xml to JSON DSL', allow_abbrev=False)
|
||||
parser.add_argument('-FormPath', '-Path', dest='FormPath', type=str, required=True)
|
||||
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
output_path = args.OutputPath
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# form-edit v1.6 — Edit 1C managed form elements
|
||||
# form-edit v1.18 — Edit 1C managed form elements (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias('Path')]
|
||||
@@ -10,6 +11,70 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Разбор пользовательского JSON ---
|
||||
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||
# Возврат через -NoEnumerate: без него одноэлементный
|
||||
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||
try {
|
||||
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||
$parsed = $text | ConvertFrom-Json
|
||||
} catch {
|
||||
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||
if ($Inline) {
|
||||
$got = ($text -replace '\s+', ' ').Trim()
|
||||
$label = 'got'
|
||||
if (-not $got) { $got = '(empty)' }
|
||||
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||
$what = "${what}, ${label}: ${got}"
|
||||
}
|
||||
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||
exit 1
|
||||
}
|
||||
Write-Output -NoEnumerate $parsed
|
||||
}
|
||||
|
||||
# --- Чтение входного JSON-файла ---
|
||||
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||
# угаданное имя уйдёт в метаданные так же молча.
|
||||
function Read-JsonInputFile([string]$path) {
|
||||
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||
exit 1
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
try {
|
||||
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||
} catch {
|
||||
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
@@ -175,7 +240,7 @@ $root = $xmlDoc.DocumentElement
|
||||
|
||||
# === 2. Load JSON ===
|
||||
|
||||
$def = Get-Content -Raw -Encoding UTF8 $JsonPath | ConvertFrom-Json
|
||||
$def = ConvertFrom-JsonInput (Read-JsonInputFile $JsonPath) $JsonPath
|
||||
|
||||
# === 3. Form name + header ===
|
||||
|
||||
@@ -270,6 +335,12 @@ function X {
|
||||
}
|
||||
|
||||
function Esc-Xml {
|
||||
param([string]$s)
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>').Replace('"','"')
|
||||
}
|
||||
|
||||
function Esc-XmlText {
|
||||
# Экранирование ТЕКСТА элемента: только & < > . Кавычки в тексте платформа НЕ экранирует —
|
||||
# пишет литерально (проверено: 92142 сырых кавычки на корпус, ни одной "). " платформа
|
||||
# принимает, но при выгрузке нормализует обратно в кавычку → лишний шум в роундтрипе.
|
||||
@@ -282,7 +353,7 @@ function Emit-MLText {
|
||||
X "$indent<$tag>"
|
||||
X "$indent`t<v8:item>"
|
||||
X "$indent`t`t<v8:lang>ru</v8:lang>"
|
||||
X "$indent`t`t<v8:content>$(Esc-Xml $text)</v8:content>"
|
||||
X "$indent`t`t<v8:content>$(Esc-XmlText $text)</v8:content>"
|
||||
X "$indent`t</v8:item>"
|
||||
X "$indent</$tag>"
|
||||
}
|
||||
@@ -311,24 +382,49 @@ $script:formTypeSynonyms["бизнеспроцессссылка"] = "
|
||||
$script:formTypeSynonyms["задачассылка"] = "TaskRef"
|
||||
$script:formTypeSynonyms["определяемыйтип"] = "DefinedType"
|
||||
|
||||
# Алиас на локальный словарь: тело Resolve-TypeStr ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
$script:typeSynonyms = $script:formTypeSynonyms
|
||||
|
||||
function Resolve-TypeStr {
|
||||
param([string]$typeStr)
|
||||
if (-not $typeStr) { return $typeStr }
|
||||
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if ($typeStr.StartsWith('cfg:')) {
|
||||
$typeStr = $typeStr.Substring(4)
|
||||
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||
}
|
||||
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||
$base = $Matches[1].Trim(); $params = $Matches[2]
|
||||
$r = $script:formTypeSynonyms[$base.ToLower()]
|
||||
if ($r) { return "$r($params)" }
|
||||
$baseName = $Matches[1].Trim()
|
||||
$params = $Matches[2]
|
||||
$resolved = $script:typeSynonyms[$baseName.ToLower()]
|
||||
if ($resolved) { return "$resolved($params)" }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
if ($typeStr.Contains('.')) {
|
||||
$i = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $i); $suffix = $typeStr.Substring($i)
|
||||
$r = $script:formTypeSynonyms[$prefix.ToLower()]
|
||||
if ($r) { return "$r$suffix" }
|
||||
$dotIdx = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||
if ($resolved) { return "$resolved$suffix" }
|
||||
return $typeStr
|
||||
}
|
||||
$r = $script:formTypeSynonyms[$typeStr.ToLower()]
|
||||
if ($r) { return $r }
|
||||
|
||||
# Простое имя
|
||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||
if ($resolved) { return $resolved }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
@@ -592,7 +688,7 @@ function Emit-Label {
|
||||
X "$inner<Title formatted=`"$formatted`">"
|
||||
X "$inner`t<v8:item>"
|
||||
X "$inner`t`t<v8:lang>ru</v8:lang>"
|
||||
X "$inner`t`t<v8:content>$(Esc-Xml "$($el.title)")</v8:content>"
|
||||
X "$inner`t`t<v8:content>$(Esc-XmlText "$($el.title)")</v8:content>"
|
||||
X "$inner`t</v8:item>"
|
||||
X "$inner</Title>"
|
||||
}
|
||||
@@ -1387,8 +1483,16 @@ if ($def.elementEvents -and $def.elementEvents.Count -gt 0) {
|
||||
$content = $xmlDoc.OuterXml
|
||||
# Ensure encoding declaration is uppercase UTF-8
|
||||
$content = $content -replace '^<\?xml version="1.0" encoding="utf-8"\?>', '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$content = [regex]::Replace($content, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedFormPath) -and ([System.IO.File]::ReadAllText($resolvedFormPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$content = ($content -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($resolvedFormPath, $content, $enc)
|
||||
|
||||
# === 14. Summary ===
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-edit v1.6 — Edit 1C managed form elements (Python port)
|
||||
# form-edit v1.18 — Edit 1C managed form elements (Python port) (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
@@ -11,6 +11,127 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
@@ -192,7 +313,7 @@ def assert_edit_allowed(target_path, require):
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument("-FormPath", "-Path", required=True)
|
||||
parser.add_argument("-JsonPath", required=True)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
json_path = args.JsonPath
|
||||
@@ -226,6 +347,11 @@ def local_name(node):
|
||||
# ── helpers ──────────────────────────────────────────────────
|
||||
|
||||
def esc_xml(s):
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
|
||||
|
||||
def esc_xml_text(s):
|
||||
"""Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
|
||||
(92142 сырых кавычки на корпус, ни одной "); " она принимает, но нормализует обратно."""
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
@@ -253,8 +379,7 @@ root = tree.getroot()
|
||||
|
||||
# ── 2. Load JSON ────────────────────────────────────────────
|
||||
|
||||
with open(json_path, "r", encoding="utf-8-sig") as f:
|
||||
defn = json.load(f)
|
||||
defn = ci_json(parse_json_input(read_json_file(json_path), json_path))
|
||||
|
||||
# ── 3. Form name + header ───────────────────────────────────
|
||||
|
||||
@@ -386,23 +511,48 @@ _FORM_TYPE_SYNONYMS = {
|
||||
}
|
||||
|
||||
|
||||
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
TYPE_SYNONYMS = _FORM_TYPE_SYNONYMS
|
||||
|
||||
|
||||
def resolve_type_str(type_str):
|
||||
if not type_str:
|
||||
return type_str
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if type_str.startswith('cfg:'):
|
||||
type_str = type_str[4:]
|
||||
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||
type_str = type_str[type_str.index(':') + 1:]
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||
if m:
|
||||
base, params = m.group(1).strip(), m.group(2)
|
||||
r = _FORM_TYPE_SYNONYMS.get(base.lower())
|
||||
return f"{r}({params})" if r else type_str
|
||||
base_name = m.group(1).strip()
|
||||
params = m.group(2)
|
||||
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||
if resolved:
|
||||
return f'{resolved}({params})'
|
||||
return type_str
|
||||
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||
if '.' in type_str:
|
||||
i = type_str.index('.')
|
||||
prefix, suffix = type_str[:i], type_str[i:]
|
||||
r = _FORM_TYPE_SYNONYMS.get(prefix.lower())
|
||||
return f"{r}{suffix}" if r else type_str
|
||||
r = _FORM_TYPE_SYNONYMS.get(type_str.lower())
|
||||
return r if r else type_str
|
||||
|
||||
|
||||
dot_idx = type_str.index('.')
|
||||
prefix = type_str[:dot_idx]
|
||||
suffix = type_str[dot_idx:] # includes the dot
|
||||
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||
if resolved:
|
||||
return f'{resolved}{suffix}'
|
||||
return type_str
|
||||
# Простое имя
|
||||
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||
if resolved:
|
||||
return resolved
|
||||
return type_str
|
||||
def emit_type(type_str, indent):
|
||||
if not type_str:
|
||||
X(f"{indent}<Type/>")
|
||||
@@ -496,7 +646,7 @@ def emit_mltext(tag, text, indent):
|
||||
X(f"{indent}<{tag}>")
|
||||
X(f"{indent}\t<v8:item>")
|
||||
X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
|
||||
X(f"{indent}\t\t<v8:content>{esc_xml(text)}</v8:content>")
|
||||
X(f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>")
|
||||
X(f"{indent}\t</v8:item>")
|
||||
X(f"{indent}</{tag}>")
|
||||
|
||||
@@ -724,7 +874,7 @@ def emit_label(el, name, _id, indent):
|
||||
X(f'{inner}<Title formatted="{formatted}">')
|
||||
X(f"{inner}\t<v8:item>")
|
||||
X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
|
||||
X(f"{inner}\t\t<v8:content>{esc_xml(str(el['title']))}</v8:content>")
|
||||
X(f"{inner}\t\t<v8:content>{esc_xml_text(str(el['title']))}</v8:content>")
|
||||
X(f"{inner}\t</v8:item>")
|
||||
X(f"{inner}</Title>")
|
||||
emit_common_flags(el, inner)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# form-info v1.8 — Analyze 1C managed form structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[Parameter(Mandatory=$true, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$FormPath,
|
||||
[int]$Limit = 150,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-info v1.5 — Analyze 1C managed form structure
|
||||
# form-info v1.8 — Analyze 1C managed form structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ import re
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Namespace map ---
|
||||
|
||||
NSMAP = {
|
||||
@@ -353,7 +375,7 @@ def get_support_status_for_path(target_path):
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
def is_external_root(xml_path):
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
@@ -367,14 +389,14 @@ def get_support_status_for_path(target_path):
|
||||
rp = os.path.abspath(target_path)
|
||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||
elem_uuid = root_uuid(rp)
|
||||
if is_external_root(rp):
|
||||
if _sg_is_external_root(rp):
|
||||
return None
|
||||
bin_path = None
|
||||
d = os.path.dirname(rp)
|
||||
for _ in range(12):
|
||||
if not d:
|
||||
break
|
||||
if is_external_root(d + ".xml"):
|
||||
if _sg_is_external_root(d + ".xml"):
|
||||
return None
|
||||
if not elem_uuid:
|
||||
elem_uuid = root_uuid(d + ".xml")
|
||||
@@ -433,7 +455,7 @@ def main():
|
||||
parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
|
||||
parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
|
||||
parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
limit = args.Limit
|
||||
|
||||
@@ -27,11 +27,12 @@ allowed-tools:
|
||||
| ObjectName | да | — | Имя объекта |
|
||||
| FormName | да | — | Имя формы для удаления |
|
||||
| SrcDir | нет | `src` | Каталог исходников |
|
||||
| Force | нет | — | Удалить, даже если на форму ссылаются, и очистить ссылки |
|
||||
|
||||
## Команда
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "<ObjectName>" -FormName "<FormName>" [-SrcDir "<SrcDir>"]
|
||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -ObjectName "<ObjectName>" -FormName "<FormName>" [-SrcDir "<SrcDir>"] [-Force]
|
||||
```
|
||||
|
||||
## Что удаляется
|
||||
@@ -44,4 +45,4 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/remove-form.ps1" -O
|
||||
## Что модифицируется
|
||||
|
||||
- `<SrcDir>/<ObjectName>.xml` — убирается `<Form>` из `ChildObjects`
|
||||
- Если удаляемая форма была DefaultForm — очищается значение DefaultForm
|
||||
- Свойства объекта, указывавшие на удалённую форму — очищаются
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-remove v1.4 — Remove form from 1C object
|
||||
# form-remove v1.10 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -8,7 +8,9 @@ param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$FormName,
|
||||
|
||||
[string]$SrcDir = "src"
|
||||
[string]$SrcDir = "src",
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -33,6 +35,180 @@ if (-not (Test-Path $formMetaPath)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Загрузка корневого XML: вид и имя объекта ---
|
||||
|
||||
$rootXmlFull = Resolve-Path $rootXmlPath
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.Load($rootXmlFull.Path)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
$typeNode = $null
|
||||
foreach ($c in $xmlDoc.DocumentElement.ChildNodes) {
|
||||
if ($c.NodeType -eq [System.Xml.XmlNodeType]::Element) { $typeNode = $c; break }
|
||||
}
|
||||
if (-not $typeNode) {
|
||||
Write-Error "Не удалось определить вид объекта в $rootXmlPath"
|
||||
exit 1
|
||||
}
|
||||
$mdType = $typeNode.LocalName
|
||||
$nameNode = $typeNode.SelectSingleNode("md:Properties/md:Name", $nsMgr)
|
||||
$objMetaName = if ($nameNode -and $nameNode.InnerText.Trim()) { $nameNode.InnerText.Trim() } else { [System.IO.Path]::GetFileNameWithoutExtension($rootXmlPath) }
|
||||
|
||||
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||
$formRef = "$mdType.$objMetaName.Form.$FormName"
|
||||
|
||||
# --- Чистка ссылок и сохранение в стиле файла-источника ---
|
||||
|
||||
# Каноничное «не задано» зависит от файла: в корневом XML объекта и в Configuration.xml
|
||||
# пустой слот штатен (164 508 пустых на корпус), а внутри Ext/Form.xml пустых <ChoiceForm/>
|
||||
# и <SettingsStorage/> нет ни одного — там свойство просто отсутствует.
|
||||
function Clear-FormRefs {
|
||||
param([System.Xml.XmlDocument]$doc, [string]$ref)
|
||||
|
||||
$isFormFile = $doc.DocumentElement -and $doc.DocumentElement.LocalName -eq "Form"
|
||||
$touched = @()
|
||||
foreach ($node in @($doc.SelectNodes("//*"))) {
|
||||
if ($node.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }
|
||||
if ($node.SelectNodes("*").Count -gt 0) { continue } # только листья
|
||||
# Сравнение регистронезависимое — как у платформы (в py-порту .lower()).
|
||||
if ($node.InnerText.Trim() -ne $ref) { continue }
|
||||
|
||||
$ln = $node.LocalName
|
||||
$parent = $node.ParentNode
|
||||
if ($ln -eq "Form" -and $parent -and $parent.LocalName -eq "Item") {
|
||||
$touched += "$($parent.LocalName)/$ln"
|
||||
Remove-NodeWithIndent $parent
|
||||
} elseif ($isFormFile) {
|
||||
$touched += $ln
|
||||
Remove-NodeWithIndent $node
|
||||
} else {
|
||||
# IsEmpty, а не InnerText="": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
$touched += $ln
|
||||
$node.IsEmpty = $true
|
||||
}
|
||||
}
|
||||
return $touched
|
||||
}
|
||||
|
||||
function Remove-NodeWithIndent {
|
||||
param([System.Xml.XmlNode]$node)
|
||||
$parent = $node.ParentNode
|
||||
if (-not $parent) { return }
|
||||
$prev = $node.PreviousSibling
|
||||
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$parent.RemoveChild($prev) | Out-Null
|
||||
}
|
||||
$parent.RemoveChild($node) | Out-Null
|
||||
# Опустевший контейнер: остаётся отступ-whitespace, и XmlWriter пишет пару
|
||||
# <ChildObjects>\n\t\t</ChildObjects>. Платформа пишет только <ChildObjects/>
|
||||
# (1394 самозакрывающихся на acc+erp, пустых пар ни в одной форме — 0).
|
||||
if ($parent.SelectNodes("*").Count -eq 0) { $parent.IsEmpty = $true }
|
||||
}
|
||||
|
||||
function Save-XmlPreservingStyle {
|
||||
param([System.Xml.XmlDocument]$doc, [string]$path)
|
||||
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$doc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $path) -and ([System.IO.File]::ReadAllText($path) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($path, $xmlText, $encBom)
|
||||
}
|
||||
|
||||
# --- Поиск ссылок на форму по всей конфигурации ---
|
||||
|
||||
# Get-Item, а не Resolve-Path: последний оставляет короткое имя 8.3 (NSHIRO~1), а
|
||||
# Get-ChildItem отдаёт длинное (nshirokov) — сравнение путей молча не совпадало.
|
||||
function Get-LongPath {
|
||||
param([string]$path)
|
||||
if (-not (Test-Path -LiteralPath $path)) { return "" }
|
||||
return (Get-Item -LiteralPath $path -Force).FullName
|
||||
}
|
||||
|
||||
# Корень конфигурации: обычно это сам SrcDir, но объект могут передать и из глубины.
|
||||
$configDir = $null
|
||||
$probe = Get-LongPath $SrcDir
|
||||
for ($depth = 0; $depth -lt 4; $depth++) {
|
||||
if (-not $probe) { break }
|
||||
if (Test-Path (Join-Path $probe "Configuration.xml")) { $configDir = $probe; break }
|
||||
$probe = Split-Path $probe
|
||||
}
|
||||
|
||||
$rootXmlLong = Get-LongPath $rootXmlFull.Path
|
||||
$formMetaFull = Get-LongPath $formMetaPath
|
||||
$formDirFull = Get-LongPath $formDir
|
||||
|
||||
$references = @()
|
||||
if ($configDir) {
|
||||
# Полный обход, как в meta-remove: ссылки лежат и внутри Ext/Form.xml (ChoiceForm,
|
||||
# SettingsStorage), узкий скан по корневым XML их не видит.
|
||||
# EnumerateFiles, а не Get-ChildItem -Recurse: на ERP (73 904 XML) обход обёртками
|
||||
# занимает 180 с против 47 с — чтение файлов не узкое место, узкое место перечисление.
|
||||
$refPattern = '<([A-Za-z0-9_.]+)>' + [regex]::Escape($formRef) + '</'
|
||||
$scanSw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$scanned = 0
|
||||
foreach ($fp in [System.IO.Directory]::EnumerateFiles($configDir, "*.xml", [System.IO.SearchOption]::AllDirectories)) {
|
||||
if ($fp -eq $rootXmlLong) { continue } # свой файл чистится всегда
|
||||
if ($fp -eq $formMetaFull) { continue } # файлы удаляемой формы
|
||||
if ($formDirFull -and $fp.StartsWith($formDirFull)) { continue }
|
||||
$scanned++
|
||||
$content = [System.IO.File]::ReadAllText($fp, [System.Text.Encoding]::UTF8)
|
||||
if (-not $content.Contains($formRef)) { continue }
|
||||
foreach ($m in [regex]::Matches($content, $refPattern)) {
|
||||
$references += @{ Path = $fp; Rel = $fp.Substring($configDir.Length + 1); Tag = $m.Groups[1].Value }
|
||||
}
|
||||
}
|
||||
$scanSw.Stop()
|
||||
if ($scanSw.Elapsed.TotalSeconds -ge 5) {
|
||||
Write-Host "[INFO] Проверено ссылок в $scanned файлах за $([math]::Round($scanSw.Elapsed.TotalSeconds, 1)) c"
|
||||
}
|
||||
}
|
||||
|
||||
if ($references.Count -gt 0) {
|
||||
Write-Host "[WARN] На форму $formRef ссылаются $($references.Count) раз(а):"
|
||||
foreach ($grp in ($references | Group-Object { "$($_.Rel)|$($_.Tag)" } | Sort-Object Name)) {
|
||||
$parts = $grp.Name.Split("|")
|
||||
$suffix = if ($grp.Count -gt 1) { " x$($grp.Count)" } else { "" }
|
||||
Write-Host " $($parts[0]) — <$($parts[1])>$suffix"
|
||||
}
|
||||
Write-Host ""
|
||||
if (-not $Force) {
|
||||
Write-Host "[ERROR] Удаление остановлено: форма используется."
|
||||
Write-Host " Решает пользователь: убрать ссылки, отказаться от удаления или"
|
||||
Write-Host " повторить с -Force — тогда ссылки будут очищены."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[WARN] -Force: ссылки будут очищены"
|
||||
Write-Host ""
|
||||
} elseif (-not $configDir) {
|
||||
Write-Host "[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены"
|
||||
}
|
||||
|
||||
# --- Удаление файлов ---
|
||||
|
||||
if (Test-Path $formDir) {
|
||||
@@ -45,49 +221,35 @@ Write-Host "[OK] Удалён файл: $formMetaPath"
|
||||
|
||||
# --- Модификация корневого XML ---
|
||||
|
||||
$rootXmlFull = Resolve-Path $rootXmlPath
|
||||
$xmlDoc = New-Object System.Xml.XmlDocument
|
||||
$xmlDoc.PreserveWhitespace = $true
|
||||
$xmlDoc.Load($rootXmlFull.Path)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
# Удалить <Form>FormName</Form> из ChildObjects
|
||||
$formNodes = $xmlDoc.SelectNodes("//md:ChildObjects/md:Form", $nsMgr)
|
||||
foreach ($node in $formNodes) {
|
||||
if ($node.InnerText -eq $FormName) {
|
||||
$parent = $node.ParentNode
|
||||
# Удалить предшествующий whitespace
|
||||
$prev = $node.PreviousSibling
|
||||
if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$parent.RemoveChild($prev) | Out-Null
|
||||
}
|
||||
$parent.RemoveChild($node) | Out-Null
|
||||
Remove-NodeWithIndent $node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Очистить любые 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 = ""
|
||||
}
|
||||
}
|
||||
# Очистить слоты своего объекта, указывавшие на удалённую форму: Default*/Auxiliary*Form
|
||||
# (form-add пишет свойство по назначению) и ChoiceForm у реквизитов.
|
||||
Clear-FormRefs $xmlDoc $formRef | Out-Null
|
||||
|
||||
# Сохранить с BOM
|
||||
$encBom = New-Object System.Text.UTF8Encoding($true)
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
|
||||
$stream = New-Object System.IO.FileStream($rootXmlFull.Path, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
Save-XmlPreservingStyle $xmlDoc $rootXmlFull.Path
|
||||
|
||||
Write-Host "[OK] Форма $FormName удалена из $rootXmlPath"
|
||||
|
||||
# --- Чистка ссылок в других файлах (только с -Force) ---
|
||||
|
||||
if ($references.Count -gt 0) {
|
||||
foreach ($grp in ($references | Group-Object { $_.Path } | Sort-Object Name)) {
|
||||
$path = $grp.Name
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $true
|
||||
$doc.Load($path)
|
||||
$touched = @(Clear-FormRefs $doc $formRef)
|
||||
if ($touched.Count -eq 0) { continue }
|
||||
Save-XmlPreservingStyle $doc $path
|
||||
$rel = $path.Substring($configDir.Length + 1)
|
||||
Write-Host "[OK] Очищена ссылка в $rel — $(($touched | Sort-Object -Unique) -join ', ')"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# remove-form v1.4 — Remove form from 1C object
|
||||
# form-remove v1.10 — Remove form from 1C object
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
@@ -30,21 +52,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -60,6 +83,69 @@ def save_xml_with_bom(tree, path):
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def long_path(path):
|
||||
"""Полный путь в длинной форме. Зеркало Get-LongPath в PS: там Resolve-Path оставляет
|
||||
короткое имя 8.3 (NSHIRO~1), а перечисление отдаёт длинное — сравнение молча не совпадало."""
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
return os.path.realpath(path)
|
||||
|
||||
|
||||
def remove_node_with_indent(node):
|
||||
"""Удалить элемент вместе с предшествующим whitespace; опустевший контейнер сделать
|
||||
самозакрывающимся. Зеркало Remove-NodeWithIndent в PS."""
|
||||
parent = node.getparent()
|
||||
if parent is None:
|
||||
return
|
||||
# В DOM (PS) whitespace — отдельные узлы: удаляются предшествующий и сам элемент, а
|
||||
# whitespace ПОСЛЕ элемента остаётся. В lxml он лежит в node.tail и ушёл бы вместе с
|
||||
# узлом, поэтому его надо передать предшественнику — иначе `</Attributes></Form>`.
|
||||
prev = node.getprevious()
|
||||
if prev is not None:
|
||||
prev.tail = node.tail
|
||||
else:
|
||||
parent.text = node.tail
|
||||
parent.remove(node)
|
||||
# Опустевший контейнер: text="" сериализуется парой <ChildObjects></ChildObjects>,
|
||||
# а нужен <ChildObjects/> — PS-порт через DOM даёт именно его.
|
||||
if len(parent) == 0 and not (parent.text or "").strip():
|
||||
parent.text = None
|
||||
|
||||
|
||||
def clear_form_refs(tree, ref):
|
||||
"""Очистить ссылки на форму. Каноничное «не задано» зависит от файла: в корневом XML
|
||||
объекта и в Configuration.xml пустой слот штатен (164 508 пустых на корпус), а внутри
|
||||
Ext/Form.xml пустых <ChoiceForm/> и <SettingsStorage/> нет ни одного — там свойство
|
||||
просто отсутствует. Зеркало Clear-FormRefs в PS."""
|
||||
root = tree.getroot()
|
||||
is_form_file = etree.QName(root).localname == "Form"
|
||||
touched = []
|
||||
ref_lc = ref.lower()
|
||||
for el in list(root.iter()):
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if len(el) > 0: # только листья
|
||||
continue
|
||||
# Сравнение регистронезависимое — как у платформы (в PS -eq регистр не различает).
|
||||
if (el.text or "").strip().lower() != ref_lc:
|
||||
continue
|
||||
|
||||
ln = etree.QName(el).localname
|
||||
parent = el.getparent()
|
||||
if ln == "Form" and parent is not None and etree.QName(parent).localname == "Item":
|
||||
touched.append(f"{etree.QName(parent).localname}/{ln}")
|
||||
remove_node_with_indent(parent)
|
||||
elif is_form_file:
|
||||
touched.append(ln)
|
||||
remove_node_with_indent(el)
|
||||
else:
|
||||
# text=None, а не "": пустая строка сериализуется парой <Tag></Tag>, а
|
||||
# Конфигуратор пустых пар не пишет (0 на 476 942 XML корпуса) — нужен <Tag/>.
|
||||
touched.append(ln)
|
||||
el.text = None
|
||||
return touched
|
||||
|
||||
|
||||
def main():
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
@@ -67,11 +153,13 @@ def main():
|
||||
parser.add_argument("-ObjectName", "-ProcessorName", required=True)
|
||||
parser.add_argument("-FormName", required=True)
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
args = parser.parse_args()
|
||||
parser.add_argument("-Force", action="store_true")
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_name = args.ObjectName
|
||||
form_name = args.FormName
|
||||
src_dir = args.SrcDir
|
||||
force = args.Force
|
||||
|
||||
# --- Checks ---
|
||||
|
||||
@@ -89,6 +177,91 @@ def main():
|
||||
print(f"Метаданные формы не найдены: {form_meta_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# --- Load root XML: kind and object name ---
|
||||
|
||||
root_xml_full = long_path(root_xml_path) or os.path.abspath(root_xml_path)
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(root_xml_full, parser_xml)
|
||||
root = tree.getroot()
|
||||
|
||||
type_node = None
|
||||
for c in root:
|
||||
if isinstance(c.tag, str):
|
||||
type_node = c
|
||||
break
|
||||
if type_node is None:
|
||||
print(f"Не удалось определить вид объекта в {root_xml_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
md_type = etree.QName(type_node).localname
|
||||
name_node = type_node.find("md:Properties/md:Name", NSMAP)
|
||||
obj_meta_name = (name_node.text or "").strip() if name_node is not None else ""
|
||||
if not obj_meta_name:
|
||||
obj_meta_name = os.path.splitext(os.path.basename(root_xml_path))[0]
|
||||
|
||||
# Полная ссылка на форму. Матч по ней целиком, а не по хвосту "Form.<Имя>": иначе при
|
||||
# удалении своей ФормаСписка обнулялась бы и ссылка на DocumentJournal.Ж.Form.ФормаСписка.
|
||||
form_ref = f"{md_type}.{obj_meta_name}.Form.{form_name}"
|
||||
|
||||
# --- Find references across the configuration ---
|
||||
|
||||
config_dir = None
|
||||
probe = long_path(src_dir) or os.path.abspath(src_dir)
|
||||
for _ in range(4):
|
||||
if not probe:
|
||||
break
|
||||
if os.path.exists(os.path.join(probe, "Configuration.xml")):
|
||||
config_dir = probe
|
||||
break
|
||||
parent_probe = os.path.dirname(probe)
|
||||
if parent_probe == probe:
|
||||
break
|
||||
probe = parent_probe
|
||||
|
||||
form_meta_full = long_path(form_meta_path)
|
||||
form_dir_full = long_path(form_dir)
|
||||
|
||||
references = []
|
||||
if config_dir:
|
||||
ref_pattern = re.compile(r"<([A-Za-z0-9_.]+)>" + re.escape(form_ref) + r"</")
|
||||
for dirpath, _dirnames, filenames in os.walk(config_dir):
|
||||
for fn in filenames:
|
||||
if not fn.lower().endswith(".xml"):
|
||||
continue
|
||||
fp = os.path.join(dirpath, fn)
|
||||
if fp == root_xml_full or fp == form_meta_full:
|
||||
continue # свой файл и файлы удаляемой формы
|
||||
if form_dir_full and fp.startswith(form_dir_full):
|
||||
continue
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8-sig") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
if form_ref not in content:
|
||||
continue
|
||||
for m in ref_pattern.finditer(content):
|
||||
references.append({"path": fp, "rel": os.path.relpath(fp, config_dir),
|
||||
"tag": m.group(1)})
|
||||
|
||||
if references:
|
||||
print(f"[WARN] На форму {form_ref} ссылаются {len(references)} раз(а):")
|
||||
grouped = {}
|
||||
for r in references:
|
||||
grouped[(r["rel"], r["tag"])] = grouped.get((r["rel"], r["tag"]), 0) + 1
|
||||
for (rel, tag) in sorted(grouped):
|
||||
suffix = f" x{grouped[(rel, tag)]}" if grouped[(rel, tag)] > 1 else ""
|
||||
print(f" {rel} — <{tag}>{suffix}")
|
||||
print()
|
||||
if not force:
|
||||
print("[ERROR] Удаление остановлено: форма используется.")
|
||||
print(" Решает пользователь: убрать ссылки, отказаться от удаления или")
|
||||
print(" повторить с -Force — тогда ссылки будут очищены.")
|
||||
sys.exit(1)
|
||||
print("[WARN] -Force: ссылки будут очищены")
|
||||
print()
|
||||
elif not config_dir:
|
||||
print("[WARN] Корень конфигурации не найден — ссылки в других объектах не проверены")
|
||||
|
||||
# --- Delete files ---
|
||||
|
||||
if os.path.isdir(form_dir):
|
||||
@@ -100,42 +273,31 @@ def main():
|
||||
|
||||
# --- Modify root XML ---
|
||||
|
||||
root_xml_full = os.path.abspath(root_xml_path)
|
||||
parser_xml = etree.XMLParser(remove_blank_text=False)
|
||||
tree = etree.parse(root_xml_full, parser_xml)
|
||||
root = tree.getroot()
|
||||
|
||||
# Remove <Form>FormName</Form> from ChildObjects
|
||||
for node in root.findall(".//md:ChildObjects/md:Form", NSMAP):
|
||||
if node.text and node.text.strip() == form_name:
|
||||
parent = node.getparent()
|
||||
prev = node.getprevious()
|
||||
if prev is not None:
|
||||
# Whitespace is in prev.tail
|
||||
if prev.tail and prev.tail.strip() == "":
|
||||
prev.tail = ""
|
||||
else:
|
||||
# First child — whitespace is in parent.text
|
||||
if parent.text and parent.text.strip() == "":
|
||||
parent.text = ""
|
||||
parent.remove(node)
|
||||
remove_node_with_indent(node)
|
||||
break
|
||||
|
||||
# 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 = ""
|
||||
# Очистить слоты своего объекта: Default*/Auxiliary*Form и ChoiceForm у реквизитов.
|
||||
clear_form_refs(tree, form_ref)
|
||||
|
||||
# Save with BOM
|
||||
save_xml_with_bom(tree, root_xml_full)
|
||||
|
||||
print(f"[OK] Форма {form_name} удалена из {root_xml_path}")
|
||||
|
||||
# --- Clean references in other files (only with -Force) ---
|
||||
|
||||
for fp in sorted({r["path"] for r in references}):
|
||||
other_tree = etree.parse(fp, parser_xml)
|
||||
touched = clear_form_refs(other_tree, form_ref)
|
||||
if not touched:
|
||||
continue
|
||||
save_xml_with_bom(other_tree, fp)
|
||||
rel = os.path.relpath(fp, config_dir)
|
||||
print(f"[OK] Очищена ссылка в {rel} — {', '.join(sorted(set(touched)))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# form-validate v1.9 — Validate 1C managed form
|
||||
# form-validate v1.18 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter(Mandatory, Position=0)]
|
||||
[Alias('Path')]
|
||||
[string]$FormPath,
|
||||
|
||||
@@ -56,9 +57,23 @@ try {
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("f", "http://v8.1c.ru/8.3/xcf/logform")
|
||||
$nsMgr.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
|
||||
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
|
||||
$root = $xmlDoc.DocumentElement
|
||||
|
||||
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||
# support-guard: is_external_root, авторитет — cf-edit).
|
||||
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||
if (-not (Test-Path $xmlPath)) { return $false }
|
||||
try {
|
||||
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||
$el = $mx.DocumentElement.FirstChild
|
||||
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
|
||||
# --- Detect context: config vs EPF/ERF ---
|
||||
# Walk up from FormPath looking for Configuration.xml → config context
|
||||
# No Configuration.xml → external data processor / report (EPF/ERF)
|
||||
@@ -66,13 +81,56 @@ $script:isConfigContext = $false
|
||||
$walkDir = Split-Path (Resolve-Path $FormPath) -Parent
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
if (-not $walkDir -or $walkDir -eq (Split-Path $walkDir)) { break }
|
||||
# Порядок проверок тот же, что у Detect-FormatVersion: сначала корень автономной обработки,
|
||||
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||
# версию конфигурации.
|
||||
$extRoot = "$walkDir.xml"
|
||||
if (-not $script:versionAnchor) {
|
||||
if (Test-ExternalObjectRoot $extRoot) {
|
||||
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||
$script:versionAnchor = $extRoot
|
||||
break
|
||||
}
|
||||
}
|
||||
if (Test-Path (Join-Path $walkDir "Configuration.xml")) {
|
||||
$script:isConfigContext = $true
|
||||
$script:configXmlPath = Join-Path $walkDir "Configuration.xml"
|
||||
if (-not $script:versionAnchor) { $script:versionAnchor = $script:configXmlPath }
|
||||
break
|
||||
}
|
||||
$walkDir = Split-Path $walkDir
|
||||
}
|
||||
|
||||
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
if ($parent -eq $d) { break }
|
||||
$d = $parent
|
||||
}
|
||||
return "2.17"
|
||||
}
|
||||
|
||||
# --- Counters ---
|
||||
|
||||
$errors = 0
|
||||
@@ -101,6 +159,19 @@ function Report-Warn {
|
||||
Write-Host "[WARN] $msg"
|
||||
}
|
||||
|
||||
# --- Format version ---
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
$formatVerifiedMin = "2.17"
|
||||
$formatVerifiedMax = "2.21"
|
||||
# Версия формата как число: "2.20" → 220. Строковое сравнение неверно ("2.9" > "2.17").
|
||||
function Get-FormatRank([string]$ver) {
|
||||
if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- Form name from path ---
|
||||
|
||||
$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
|
||||
@@ -127,13 +198,17 @@ if ($root.LocalName -ne "Form") {
|
||||
Report-Error "Root element is '$($root.LocalName)', expected 'Form'"
|
||||
} else {
|
||||
$version = $root.GetAttribute("version")
|
||||
# Лестница версий формата: 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-2.20)"
|
||||
} else {
|
||||
$versionRank = Get-FormatRank $version
|
||||
if (-not $version) {
|
||||
Report-Warn "Form version attribute missing"
|
||||
} elseif ($versionRank -eq 0) {
|
||||
Report-Error "Malformed version '$version' (expected N.N)"
|
||||
} elseif ($versionRank -lt (Get-FormatRank $formatVerifiedMin)) {
|
||||
Report-Warn "Format version '$version' is below the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} elseif ($versionRank -gt (Get-FormatRank $formatVerifiedMax)) {
|
||||
Report-Warn "Format version '$version' is above the tested range $formatVerifiedMin-$formatVerifiedMax — skills were not verified on it"
|
||||
} else {
|
||||
Report-OK "Root element: Form version=$version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,10 +219,15 @@ if (-not $stopped) {
|
||||
if ($acb) {
|
||||
$acbName = $acb.GetAttribute("name")
|
||||
$acbId = $acb.GetAttribute("id")
|
||||
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||
# предупреждение; ошибка — только если id вовсе не число.
|
||||
if ($acbId -eq "-1") {
|
||||
Report-OK "AutoCommandBar: name='$acbName', id=$acbId"
|
||||
} elseif ($acbId -match '^-?\d+$') {
|
||||
Report-Warn "AutoCommandBar id='$acbId', usually '-1'"
|
||||
} else {
|
||||
Report-Error "AutoCommandBar id='$acbId', expected '-1'"
|
||||
Report-Error "AutoCommandBar id='$acbId' is not a number"
|
||||
}
|
||||
} else {
|
||||
Report-Error "AutoCommandBar element missing"
|
||||
@@ -427,11 +507,19 @@ if (-not $stopped) {
|
||||
$segments = $cleanPath -split '\.'
|
||||
$rootAttr = $segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if ($rootAttr -eq 'Items') {
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||
$itemsHops = 0
|
||||
$itemsBroken = $false
|
||||
while ($rootAttr -eq 'Items') {
|
||||
$itemsHops++
|
||||
if ($itemsHops -gt 10) { $itemsBroken = $true; break } # страховка от кольца ссылок
|
||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||
Report-Warn "[$tag] '$elName': $bTag='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableName = $segments[1]
|
||||
$tableEl = $null
|
||||
@@ -444,17 +532,21 @@ if (-not $stopped) {
|
||||
if (-not $tableEl) {
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — table element '$tableName' not found"
|
||||
$pathErrors++
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||
# Table without DataPath — can't resolve further, accept silently
|
||||
continue
|
||||
$itemsBroken = $true
|
||||
break
|
||||
}
|
||||
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||
$rootAttr = ($tableDp -split '\.')[0]
|
||||
$segments = $tableDp -split '\.'
|
||||
$rootAttr = $segments[0]
|
||||
}
|
||||
if ($itemsBroken) { continue }
|
||||
|
||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||
Report-Error "[$tag] '$elName': $bTag='$dataPath' — attribute '$rootAttr' not found"
|
||||
@@ -569,13 +661,18 @@ if (-not $stopped) {
|
||||
$actionErrors = 0
|
||||
$actionChecked = 0
|
||||
|
||||
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||
foreach ($cmd in $cmdNodes) {
|
||||
if ($stopped) { break }
|
||||
$cmdName = $cmd.GetAttribute("name")
|
||||
$actionNode = $cmd.SelectSingleNode("f:Action", $nsMgr)
|
||||
$actionChecked++
|
||||
if (-not $actionNode -or -not $actionNode.InnerText.Trim()) {
|
||||
Report-Error "Command '$cmdName': missing or empty Action"
|
||||
Report-Warn "Command '$cmdName': no Action — handler must be assigned at runtime, otherwise the command does nothing"
|
||||
$actionErrors++
|
||||
}
|
||||
}
|
||||
@@ -742,6 +839,41 @@ if (-not $stopped -and $isExtension) {
|
||||
Report-OK "Extension ID ranges: $extAttrCount attr(s), $extCmdCount cmd(s) — all >= 1000000"
|
||||
}
|
||||
}
|
||||
|
||||
# 11d. Пути на основной реквизит, которого форма не объявляет.
|
||||
# Check 5 такое пропускает: у заимствованной формы он не проверяет базовые элементы (id < 1000000),
|
||||
# а привязки в <xr:Link> вообще вне его списка тегов. Между тем это ровно тот случай, на котором
|
||||
# платформа отвергает загрузку: «Неверный путь к полю - Объект.X». Правило: если основной реквизит
|
||||
# не объявлен в <Attributes> формы, любой путь с его корнем не разрешится.
|
||||
# Корень берётся из основного реквизита BaseForm: «Объект» он только у формы объекта, у формы
|
||||
# списка это «Список», у формы записи регистра «Запись». С зашитым «Объект» проверка на таких
|
||||
# формах молча не срабатывала — валидатор рапортовал «чисто» на форме, которую платформа не примет.
|
||||
$mainAttrDeclared = $false
|
||||
foreach ($attr in $attrNodes) {
|
||||
$maNode = $attr.SelectSingleNode("f:MainAttribute", $nsMgr)
|
||||
if ($maNode -and $maNode.InnerText.Trim() -eq "true") { $mainAttrDeclared = $true; break }
|
||||
}
|
||||
|
||||
if (-not $mainAttrDeclared) {
|
||||
# Значения привязок ищем текстом: интересуют и обычные теги, и <xr:DataPath> внутри
|
||||
# <ChoiceParameterLinks>, а те живут в чужом пространстве имён.
|
||||
$rawForm = [System.IO.File]::ReadAllText($FormPath, [System.Text.Encoding]::UTF8)
|
||||
$mainBase = $baseFormNode.SelectSingleNode("f:Attributes/f:Attribute[f:MainAttribute='true']", $bfNs)
|
||||
$rootName = if ($mainBase -and $mainBase.GetAttribute("name")) { $mainBase.GetAttribute("name") } else { "Объект" }
|
||||
$rootPat = [regex]::Escape($rootName)
|
||||
$danglingPaths = @{}
|
||||
foreach ($m in [regex]::Matches($rawForm, "<(?:\w+:)?\w*DataPath[^>]*>(${rootPat}\.[^<]+)</(?:\w+:)?\w*DataPath>")) {
|
||||
$danglingPaths[$m.Groups[1].Value] = $true
|
||||
}
|
||||
if ($danglingPaths.Count -gt 0) {
|
||||
$shown = @($danglingPaths.Keys | Sort-Object)
|
||||
$sample = ($shown | Select-Object -First 3) -join ", "
|
||||
$suffix = if ($shown.Count -gt 3) { " (и ещё $($shown.Count - 3))" } else { "" }
|
||||
Report-Error "Path(s) rooted at '${rootName}' but the form declares no MainAttribute: $sample$suffix"
|
||||
} elseif ($mainBase) {
|
||||
Report-OK "Object paths: none dangling (MainAttribute not declared)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check callType without BaseForm (structural warning)
|
||||
@@ -844,6 +976,71 @@ if (-not $stopped) {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||
# считаем по узлу (GetNamespaceOfPrefix), а не по корню: локальная xmlns на элементе законна.
|
||||
|
||||
if (-not $stopped) {
|
||||
$prefixErrors = 0
|
||||
$prefixChecked = 0
|
||||
|
||||
$prefixPattern = '^([A-Za-z_][A-Za-z0-9_.-]*):.+$'
|
||||
# Значения, где префикс обязан резолвиться: тип реквизита/колонки и xsi:type
|
||||
# Только листовые узлы: под local-name()='Type' подходит и обёртка <Type>, и вложенный <v8:Type>,
|
||||
# а InnerText обёртки — то же значение, иначе одна ошибка сообщалась бы дважды.
|
||||
foreach ($node in $xmlDoc.SelectNodes("//*[local-name()='Type' or local-name()='TypeSet']", $nsMgr)) {
|
||||
if ($node.SelectSingleNode("*")) { continue }
|
||||
$val = $node.InnerText.Trim()
|
||||
if (-not $val) { continue }
|
||||
$m = [regex]::Match($val, $prefixPattern)
|
||||
if (-not $m.Success) { continue }
|
||||
$prefixChecked++
|
||||
$pfx = $m.Groups[1].Value
|
||||
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||
Report-Error "13. Type '$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||
$prefixErrors++
|
||||
}
|
||||
}
|
||||
foreach ($node in $xmlDoc.SelectNodes("//*[@xsi:type]", $nsMgr)) {
|
||||
$val = $node.GetAttribute("type", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
$m = [regex]::Match($val, $prefixPattern)
|
||||
if (-not $m.Success) { continue }
|
||||
$prefixChecked++
|
||||
$pfx = $m.Groups[1].Value
|
||||
if (-not $node.GetNamespaceOfPrefix($pfx)) {
|
||||
Report-Error "13. xsi:type='$val': namespace prefix '${pfx}:' is not declared — the platform cannot read the file (XDTO)"
|
||||
$prefixErrors++
|
||||
}
|
||||
}
|
||||
|
||||
if ($prefixChecked -eq 0) {
|
||||
Report-OK "13. Namespace prefixes: nothing to check"
|
||||
} elseif ($prefixErrors -eq 0) {
|
||||
Report-OK "13. Namespace prefixes: $prefixChecked values, all declared"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||
|
||||
if (-not $stopped -and $script:versionAnchor) {
|
||||
$formVer = $root.GetAttribute("version")
|
||||
$dumpVer = Detect-FormatVersion (Split-Path (Resolve-Path $FormPath) -Parent)
|
||||
|
||||
if (-not $formVer) {
|
||||
Report-OK "14. Format version: not comparable"
|
||||
} elseif ($formVer -ne $dumpVer) {
|
||||
Report-Error "14. Format version $formVer differs from the dump ($dumpVer) — a dump carries one version, the platform refuses a file it cannot read"
|
||||
} else {
|
||||
Report-OK "14. Format version: $formVer, matches the dump"
|
||||
}
|
||||
}
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
$checks = $script:okCount + $errors + $warnings
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-validate v1.9 — Validate 1C managed form
|
||||
# form-validate v1.18 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -8,6 +8,28 @@ import re
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
F_NS = "http://v8.1c.ru/8.3/xcf/logform"
|
||||
V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
|
||||
@@ -49,6 +71,64 @@ VALID_CFG_PREFIXES = {
|
||||
}
|
||||
|
||||
|
||||
# Корень автономной внешней обработки/отчёта. Копия общего эталона (семья
|
||||
# support-guard: is_external_root, авторитет — cf-edit).
|
||||
def _sg_is_external_root(xml_path):
|
||||
if not os.path.isfile(xml_path):
|
||||
return False
|
||||
try:
|
||||
mx = etree.parse(xml_path).getroot()
|
||||
for child in mx:
|
||||
if isinstance(child.tag, str):
|
||||
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# Версия формата выгрузки. Копия общего эталона (семья detect_format_version, авторитет —
|
||||
# form-compile): та же ветка для автономной EPF/ERF, где версию несёт корень обработки.
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
head = f.read(2000)
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
parent = os.path.dirname(d)
|
||||
if parent == d:
|
||||
break
|
||||
d = parent
|
||||
return "2.17"
|
||||
|
||||
|
||||
# ── Format version ───────────────────────────────────────────
|
||||
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
|
||||
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
|
||||
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
|
||||
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
|
||||
FORMAT_VERIFIED_MIN = "2.17"
|
||||
FORMAT_VERIFIED_MAX = "2.21"
|
||||
|
||||
|
||||
def format_rank(ver):
|
||||
""""2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
|
||||
m = re.match(r'^(\d+)\.(\d+)$', ver or '')
|
||||
return int(m.group(1)) * 100 + int(m.group(2)) if m else 0
|
||||
|
||||
|
||||
def localname(el):
|
||||
return etree.QName(el.tag).localname
|
||||
|
||||
@@ -60,7 +140,7 @@ def main():
|
||||
parser.add_argument("-FormPath", "-Path", required=True)
|
||||
parser.add_argument("-Detailed", action="store_true")
|
||||
parser.add_argument("-MaxErrors", type=int, default=30)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
form_path = args.FormPath
|
||||
detailed = args.Detailed
|
||||
@@ -106,13 +186,29 @@ def main():
|
||||
|
||||
# Detect context: config vs EPF/ERF
|
||||
is_config_context = False
|
||||
config_xml_path = ''
|
||||
version_anchor = ''
|
||||
walk_dir = os.path.dirname(os.path.abspath(form_path))
|
||||
for _ in range(15):
|
||||
parent = os.path.dirname(walk_dir)
|
||||
if parent == walk_dir:
|
||||
break
|
||||
# Порядок проверок тот же, что у detect_format_version: сначала корень автономной обработки,
|
||||
# потом Configuration.xml — иначе форма внутри EPF, лежащей в дереве конфигурации, взяла бы
|
||||
# версию конфигурации.
|
||||
ext_root = walk_dir + '.xml'
|
||||
if not version_anchor:
|
||||
if _sg_is_external_root(ext_root):
|
||||
# Ближайший якорь побеждает: автономная обработка остаётся автономной, даже если её
|
||||
# исходники лежат внутри дерева с Configuration.xml (типовая раскладка проекта:
|
||||
# src/cf рядом с src/epf). Иначе её собственные External*-типы считались бы ошибкой.
|
||||
version_anchor = ext_root
|
||||
break
|
||||
if os.path.isfile(os.path.join(walk_dir, 'Configuration.xml')):
|
||||
is_config_context = True
|
||||
config_xml_path = os.path.join(walk_dir, 'Configuration.xml')
|
||||
if not version_anchor:
|
||||
version_anchor = config_xml_path
|
||||
break
|
||||
walk_dir = parent
|
||||
|
||||
@@ -161,13 +257,19 @@ def main():
|
||||
report_error(f"Root element is '{localname(root)}', expected 'Form'")
|
||||
else:
|
||||
version = root.get("version", "")
|
||||
# Лестница версий формата: 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-2.20)")
|
||||
else:
|
||||
version_rank = format_rank(version)
|
||||
if not version:
|
||||
report_warn("Form version attribute missing")
|
||||
elif version_rank == 0:
|
||||
report_error(f"Malformed version '{version}' (expected N.N)")
|
||||
elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
|
||||
report_warn(f"Format version '{version}' is below the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
|
||||
report_warn(f"Format version '{version}' is above the tested range "
|
||||
f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
|
||||
else:
|
||||
report_ok(f"Root element: Form version={version}")
|
||||
|
||||
# --- Check 2: AutoCommandBar ---
|
||||
if not stopped:
|
||||
@@ -175,10 +277,15 @@ def main():
|
||||
if acb is not None:
|
||||
acb_name = acb.get("name", "")
|
||||
acb_id = acb.get("id", "")
|
||||
# id=-1 — соглашение, а не требование: в корпусе УТ/БП/ERP так у 21 094 форм из 21 097,
|
||||
# но три формы платформа выгружает с обычным id и грузит их без нареканий. Поэтому
|
||||
# предупреждение; ошибка — только если id вовсе не число.
|
||||
if acb_id == "-1":
|
||||
report_ok(f"AutoCommandBar: name='{acb_name}', id={acb_id}")
|
||||
elif re.match(r'^-?\d+$', acb_id):
|
||||
report_warn(f"AutoCommandBar id='{acb_id}', usually '-1'")
|
||||
else:
|
||||
report_error(f"AutoCommandBar id='{acb_id}', expected '-1'")
|
||||
report_error(f"AutoCommandBar id='{acb_id}' is not a number")
|
||||
else:
|
||||
report_error("AutoCommandBar element missing")
|
||||
|
||||
@@ -430,11 +537,21 @@ def main():
|
||||
segments = clean_path.split(".")
|
||||
root_attr = segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if root_attr == 'Items':
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute.
|
||||
# Разрешаем ЦЕПОЧКОЙ: таблица во вложенной таблице сама привязана через Items.*, и один
|
||||
# шаг оставлял корнем литерал «Items» — форма платформы объявлялась битой (типовые
|
||||
# НастройкаПравилОбработкиЗаявокСотрудников в БП и ERP).
|
||||
items_hops = 0
|
||||
items_broken = False
|
||||
while root_attr == 'Items':
|
||||
items_hops += 1
|
||||
if items_hops > 10: # страховка от кольца ссылок
|
||||
items_broken = True
|
||||
break
|
||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||
report_warn(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_name = segments[1]
|
||||
table_el = None
|
||||
for candidate in all_elements:
|
||||
@@ -444,14 +561,19 @@ def main():
|
||||
if table_el is None:
|
||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — table element '{table_name}' not found")
|
||||
path_errors += 1
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||
continue
|
||||
items_broken = True
|
||||
break
|
||||
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||
if table_dp.startswith('~'):
|
||||
table_dp = table_dp[1:]
|
||||
root_attr = table_dp.split(".")[0]
|
||||
segments = table_dp.split(".")
|
||||
root_attr = segments[0]
|
||||
if items_broken:
|
||||
continue
|
||||
|
||||
if root_attr not in attr_map:
|
||||
report_error(f"[{tag}] '{el_name}': {b_tag}='{data_path}' — attribute '{root_attr}' not found")
|
||||
@@ -465,6 +587,8 @@ def main():
|
||||
path_msg = f"{path_msg}, {skip_note}" if path_msg else skip_note
|
||||
if path_errors == 0 and path_msg:
|
||||
report_ok(f"Data bindings: {path_msg}")
|
||||
elif path_errors == 0:
|
||||
report_ok("Data bindings: none")
|
||||
|
||||
# --- Check 6: Button command references ---
|
||||
if not stopped:
|
||||
@@ -499,6 +623,8 @@ def main():
|
||||
|
||||
if cmd_errors == 0 and cmd_checked > 0:
|
||||
report_ok(f"Command references: {cmd_checked} buttons checked")
|
||||
elif cmd_checked == 0:
|
||||
report_ok("Command references: none")
|
||||
|
||||
# --- Check 7: Events have handler names ---
|
||||
if not stopped:
|
||||
@@ -538,12 +664,19 @@ def main():
|
||||
|
||||
if event_errors == 0 and event_checked > 0:
|
||||
report_ok(f"Event handlers: {event_checked} events checked")
|
||||
elif event_checked == 0:
|
||||
report_ok("Event handlers: none")
|
||||
|
||||
# --- Check 8: Command actions ---
|
||||
if not stopped:
|
||||
action_errors = 0
|
||||
action_checked = 0
|
||||
|
||||
# Предупреждение, а не ошибка: <Action> может назначаться в рантайме
|
||||
# (`Команда.Действие = "Подключаемый_…"` в ПриСозданииНаСервере) — приём типовых конфигураций
|
||||
# там, где обработчик существует не во всякой сборке. Назначать может и чужой модуль
|
||||
# (переопределяемый слой, подключаемые команды), так что по одному Form.xml не решить.
|
||||
# Корпус УТ/БП/ERP: 406 таких команд на 275 формах, произведённых платформой.
|
||||
for cmd in cmd_nodes:
|
||||
if stopped:
|
||||
break
|
||||
@@ -551,11 +684,13 @@ def main():
|
||||
action_node = cmd.find(f"{{{F_NS}}}Action")
|
||||
action_checked += 1
|
||||
if action_node is None or not (action_node.text or "").strip():
|
||||
report_error(f"Command '{cmd_name}': missing or empty Action")
|
||||
report_warn(f"Command '{cmd_name}': no Action — handler must be assigned at runtime, otherwise the command does nothing")
|
||||
action_errors += 1
|
||||
|
||||
if action_errors == 0 and action_checked > 0:
|
||||
report_ok(f"Command actions: {action_checked} commands checked")
|
||||
elif action_checked == 0:
|
||||
report_ok("Command actions: none")
|
||||
|
||||
# --- Check 9: MainAttribute count ---
|
||||
if not stopped:
|
||||
@@ -686,6 +821,39 @@ def main():
|
||||
if (ext_attr_count + ext_cmd_count) > 0:
|
||||
report_ok(f"Extension ID ranges: {ext_attr_count} attr(s), {ext_cmd_count} cmd(s) \u2014 all >= 1000000")
|
||||
|
||||
# 11d. \u041f\u0443\u0442\u0438 \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442, \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0444\u043e\u0440\u043c\u0430 \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u044f\u0435\u0442.
|
||||
# Check 5 \u0442\u0430\u043a\u043e\u0435 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442: \u0443 \u0437\u0430\u0438\u043c\u0441\u0442\u0432\u043e\u0432\u0430\u043d\u043d\u043e\u0439 \u0444\u043e\u0440\u043c\u044b \u043e\u043d \u043d\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b (id < 1000000),
|
||||
# \u0430 \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0438 \u0432 <xr:Link> \u0432\u043e\u043e\u0431\u0449\u0435 \u0432\u043d\u0435 \u0435\u0433\u043e \u0441\u043f\u0438\u0441\u043a\u0430 \u0442\u0435\u0433\u043e\u0432. \u041c\u0435\u0436\u0434\u0443 \u0442\u0435\u043c \u044d\u0442\u043e \u0440\u043e\u0432\u043d\u043e \u0442\u043e\u0442 \u0441\u043b\u0443\u0447\u0430\u0439, \u043d\u0430 \u043a\u043e\u0442\u043e\u0440\u043e\u043c
|
||||
# \u043f\u043b\u0430\u0442\u0444\u043e\u0440\u043c\u0430 \u043e\u0442\u0432\u0435\u0440\u0433\u0430\u0435\u0442 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0443: \u00ab\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0443\u0442\u044c \u043a \u043f\u043e\u043b\u044e - \u041e\u0431\u044a\u0435\u043a\u0442.X\u00bb. \u041f\u0440\u0430\u0432\u0438\u043b\u043e: \u0435\u0441\u043b\u0438 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442
|
||||
# \u043d\u0435 \u043e\u0431\u044a\u044f\u0432\u043b\u0435\u043d \u0432 <Attributes> \u0444\u043e\u0440\u043c\u044b, \u043b\u044e\u0431\u043e\u0439 \u043f\u0443\u0442\u044c \u0441 \u0435\u0433\u043e \u043a\u043e\u0440\u043d\u0435\u043c \u043d\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0441\u044f.
|
||||
# \u041a\u043e\u0440\u0435\u043d\u044c \u0431\u0435\u0440\u0451\u0442\u0441\u044f \u0438\u0437 \u043e\u0441\u043d\u043e\u0432\u043d\u043e\u0433\u043e \u0440\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u0430 BaseForm: \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043e\u043d \u0442\u043e\u043b\u044c\u043a\u043e \u0443 \u0444\u043e\u0440\u043c\u044b \u043e\u0431\u044a\u0435\u043a\u0442\u0430, \u0443 \u0444\u043e\u0440\u043c\u044b
|
||||
# \u0441\u043f\u0438\u0441\u043a\u0430 \u044d\u0442\u043e \u00ab\u0421\u043f\u0438\u0441\u043e\u043a\u00bb, \u0443 \u0444\u043e\u0440\u043c\u044b \u0437\u0430\u043f\u0438\u0441\u0438 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430 \u00ab\u0417\u0430\u043f\u0438\u0441\u044c\u00bb. \u0421 \u0437\u0430\u0448\u0438\u0442\u044b\u043c \u00ab\u041e\u0431\u044a\u0435\u043a\u0442\u00bb \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043d\u0430 \u0442\u0430\u043a\u0438\u0445
|
||||
# \u0444\u043e\u0440\u043c\u0430\u0445 \u043c\u043e\u043b\u0447\u0430 \u043d\u0435 \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u0430.
|
||||
main_attr_declared = False
|
||||
for attr in attr_nodes:
|
||||
ma_node = attr.find(f"{{{F_NS}}}MainAttribute")
|
||||
if ma_node is not None and (ma_node.text or "").strip() == "true":
|
||||
main_attr_declared = True
|
||||
break
|
||||
|
||||
if not main_attr_declared:
|
||||
# \u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043e\u043a \u0438\u0449\u0435\u043c \u0442\u0435\u043a\u0441\u0442\u043e\u043c: \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u044e\u0442 \u0438 \u043e\u0431\u044b\u0447\u043d\u044b\u0435 \u0442\u0435\u0433\u0438, \u0438 <xr:DataPath> \u0432\u043d\u0443\u0442\u0440\u0438
|
||||
# <ChoiceParameterLinks>, \u0430 \u0442\u0435 \u0436\u0438\u0432\u0443\u0442 \u0432 \u0447\u0443\u0436\u043e\u043c \u043f\u0440\u043e\u0441\u0442\u0440\u0430\u043d\u0441\u0442\u0432\u0435 \u0438\u043c\u0451\u043d.
|
||||
with open(form_path, "r", encoding="utf-8-sig") as fh:
|
||||
raw_form = fh.read()
|
||||
main_base = base_form_node.find(f"{{{F_NS}}}Attributes/{{{F_NS}}}Attribute[{{{F_NS}}}MainAttribute='true']")
|
||||
root_name = main_base.get("name") if main_base is not None and main_base.get("name") else "\u041e\u0431\u044a\u0435\u043a\u0442"
|
||||
root_pat = re.escape(root_name)
|
||||
dangling_paths = set(re.findall(
|
||||
r'<(?:\w+:)?\w*DataPath[^>]*>(' + root_pat + r'\.[^<]+)</(?:\w+:)?\w*DataPath>', raw_form))
|
||||
if dangling_paths:
|
||||
shown = sorted(dangling_paths)
|
||||
sample = ", ".join(shown[:3])
|
||||
suffix = f" (\u0438 \u0435\u0449\u0451 {len(shown) - 3})" if len(shown) > 3 else ""
|
||||
report_error(f"Path(s) rooted at '{root_name}' but the form declares no MainAttribute: {sample}{suffix}")
|
||||
elif main_base is not None:
|
||||
report_ok("Object paths: none dangling (MainAttribute not declared)")
|
||||
|
||||
# Check callType without BaseForm
|
||||
if not stopped and not is_extension:
|
||||
call_type_without_base = False
|
||||
@@ -748,6 +916,62 @@ def main():
|
||||
else:
|
||||
report_ok('12. Types: no type values to check')
|
||||
|
||||
# --- Check 13: префиксы в значениях объявлены в самом файле ---
|
||||
# `cfg:DataProcessorObject.X` в <v8:Type> при незадекларированном xmlns:cfg — валидный XML, который
|
||||
# платформа не читает вовсе: «Исключение XDTO произошло при чтении файла». Ошибка типична для
|
||||
# рукописного XML: префикс скопирован из чужой формы, а объявление в корне забыто. Область видимости
|
||||
# считаем по узлу (nsmap элемента), а не по корню: локальная xmlns на элементе законна.
|
||||
if not stopped:
|
||||
prefix_errors = 0
|
||||
prefix_checked = 0
|
||||
prefix_re = re.compile(r'^([A-Za-z_][A-Za-z0-9_.-]*):.+$')
|
||||
|
||||
for node in root.iter():
|
||||
if not isinstance(node.tag, str):
|
||||
continue
|
||||
ln = localname(node)
|
||||
values = []
|
||||
if ln in ('Type', 'TypeSet'):
|
||||
values.append((node.text or '').strip())
|
||||
xsi_type = node.get(f'{{{"http://www.w3.org/2001/XMLSchema-instance"}}}type')
|
||||
if xsi_type:
|
||||
values.append(xsi_type.strip())
|
||||
for val in values:
|
||||
if not val:
|
||||
continue
|
||||
m = prefix_re.match(val)
|
||||
if not m:
|
||||
continue
|
||||
prefix_checked += 1
|
||||
pfx = m.group(1)
|
||||
if pfx not in node.nsmap:
|
||||
kind = "xsi:type" if val == xsi_type else "Type"
|
||||
report_error(f"13. {kind} '{val}': namespace prefix '{pfx}:' is not declared "
|
||||
"— the platform cannot read the file (XDTO)")
|
||||
prefix_errors += 1
|
||||
|
||||
if prefix_checked == 0:
|
||||
report_ok('13. Namespace prefixes: nothing to check')
|
||||
elif prefix_errors == 0:
|
||||
report_ok(f'13. Namespace prefixes: {prefix_checked} values, all declared')
|
||||
|
||||
# --- Check 14: версия формата формы совпадает с версией выгрузки ---
|
||||
# Версию задаёт платформа, которой выгружали, и в пределах одной выгрузки она едина. Форма из
|
||||
# другой версии — «Неизвестная версия формата N загружаемого файла»: платформа не читает файл,
|
||||
# который новее её самой. Источник версии ищем общим helper-ом: он же покрывает автономную
|
||||
# внешнюю обработку/отчёт, где Configuration.xml нет и версию несёт корень самой обработки.
|
||||
if not stopped and version_anchor:
|
||||
form_ver = root.get('version', '')
|
||||
dump_ver = detect_format_version(os.path.dirname(os.path.abspath(form_path)))
|
||||
|
||||
if not form_ver:
|
||||
report_ok('14. Format version: not comparable')
|
||||
elif form_ver != dump_ver:
|
||||
report_error(f'14. Format version {form_ver} differs from the dump ({dump_ver}) '
|
||||
'— a dump carries one version, the platform refuses a file it cannot read')
|
||||
else:
|
||||
report_ok(f'14. Format version: {form_ver}, matches the dump')
|
||||
|
||||
# --- Finalize ---
|
||||
checks = ok_count + errors + warnings
|
||||
if errors == 0 and warnings == 0 and not detailed:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# help-add v1.9 — Add built-in help to 1C object
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -149,10 +149,20 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$content = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
$head = $content.Substring(0, [Math]::Min(2000, $content.Length))
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
# Длину среза берём по СТРОКЕ, а не по размеру файла: размер в БАЙТАХ, Substring считает
|
||||
# СИМВОЛЫ, и на кириллице байт больше — короткий Configuration.xml ронял навык исключением.
|
||||
$head = $cfgText.Substring(0, [Math]::Min(2000, $cfgText.Length))
|
||||
if ($head -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$parent = Split-Path $d -Parent
|
||||
@@ -195,7 +205,18 @@ $helpXml = @"
|
||||
</Help>
|
||||
"@
|
||||
|
||||
[System.IO.File]::WriteAllText($helpXmlPath, $helpXml, $encBom)
|
||||
# XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
#
|
||||
# Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
# копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
#
|
||||
# HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||
function Write-XmlFile([string]$path, [string]$text, $encoding) {
|
||||
$t = ($text -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $t.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFile $helpXmlPath $helpXml $encBom
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
@@ -255,11 +276,26 @@ if (Test-Path $formsDir) {
|
||||
$settings = New-Object System.Xml.XmlWriterSettings
|
||||
$settings.Encoding = $encBom
|
||||
$settings.Indent = $false
|
||||
$stream = New-Object System.IO.FileStream($formMeta.FullName, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $settings)
|
||||
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
# Через MemoryStream, а не прямо в файл: нужен шаг пост-обработки строки.
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
|
||||
$xmlDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$xmlText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($xmlText.Length -gt 0 -and $xmlText[0] -eq [char]0xFEFF) { $xmlText = $xmlText.Substring(1) }
|
||||
$xmlText = $xmlText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$xmlText = [regex]::Replace($xmlText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $formMeta.FullName) -and ([System.IO.File]::ReadAllText($formMeta.FullName) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$xmlText = ($xmlText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($formMeta.FullName, $xmlText, $encBom)
|
||||
|
||||
Write-Host " IncludeHelpInContents добавлен: $($formMeta.Name)"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# add-help v1.9 — Add built-in help to 1C object
|
||||
# help-add v1.19 — Add built-in help to 1C object (+write_xml_file/write_utf8_bom: общий эталон записи)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,28 @@ import sys
|
||||
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NSMAP = {"md": "http://v8.1c.ru/8.3/MDClasses"}
|
||||
|
||||
|
||||
@@ -191,6 +213,16 @@ def assert_edit_allowed(target_path, require):
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
@@ -222,21 +254,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -252,10 +285,24 @@ def save_xml_with_bom(tree, path):
|
||||
f.write(xml_bytes)
|
||||
|
||||
|
||||
def write_text_with_bom(path, text):
|
||||
"""Write text to file with UTF-8 BOM."""
|
||||
with open(path, "w", encoding="utf-8-sig") as f:
|
||||
f.write(text)
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
|
||||
def write_xml_file(path, content):
|
||||
"""XML в каноне выгрузки Конфигуратора: CRLF в разделителях, без перевода в конце.
|
||||
|
||||
Копия этой функции есть в каждом навыке-эмиттере (навыки автономны). Держать
|
||||
копии одинаковыми — сознательно: разошедшиеся копии сводят на нет весь смысл.
|
||||
|
||||
HTML-страница сюда НЕ идёт — платформа хранит её с LF.
|
||||
"""
|
||||
text = content.replace('\r\n', '\n').replace('\n', '\r\n').rstrip('\r\n')
|
||||
write_utf8_bom(path, text)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -265,7 +312,7 @@ def main():
|
||||
parser.add_argument("-ObjectName", required=True)
|
||||
parser.add_argument("-Lang", default="ru")
|
||||
parser.add_argument("-SrcDir", default="src")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_name = args.ObjectName
|
||||
lang = args.Lang
|
||||
@@ -301,7 +348,7 @@ def main():
|
||||
'</Help>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_xml_path, help_xml)
|
||||
write_xml_file(help_xml_path, help_xml)
|
||||
|
||||
# --- 2. Help/<lang>.html ---
|
||||
|
||||
@@ -324,7 +371,7 @@ def main():
|
||||
'</html>'
|
||||
)
|
||||
|
||||
write_text_with_bom(help_html_path, help_html)
|
||||
write_utf8_bom(help_html_path, help_html)
|
||||
|
||||
# --- 3. Check IncludeHelpInContents in form metadata ---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# img-grid v1.1 — Overlay numbered grid on image
|
||||
# img-grid v1.2 — Overlay numbered grid on image
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Overlay a numbered grid on an image to help determine column/row proportions.
|
||||
|
||||
@@ -16,6 +16,28 @@ import os
|
||||
import sys
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
MARGIN_TOP = 20
|
||||
MARGIN_LEFT = 24
|
||||
|
||||
@@ -30,7 +52,7 @@ def main():
|
||||
parser.add_argument("-r", "--rows", type=int, default=0,
|
||||
help="Number of horizontal divisions (0 = auto, match cell aspect ratio)")
|
||||
parser.add_argument("-o", "--output", help="Output path (default: <name>-grid.<ext>)")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
if args.cols <= 0:
|
||||
parser.error("--cols must be greater than 0")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.22 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||
[string]$DefinitionFile,
|
||||
@@ -17,6 +18,70 @@ $ErrorActionPreference = "Stop"
|
||||
if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 }
|
||||
if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 }
|
||||
|
||||
# --- Разбор пользовательского JSON ---
|
||||
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||
# Возврат через -NoEnumerate: без него одноэлементный
|
||||
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||
try {
|
||||
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||
$parsed = $text | ConvertFrom-Json
|
||||
} catch {
|
||||
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||
if ($Inline) {
|
||||
$got = ($text -replace '\s+', ' ').Trim()
|
||||
$label = 'got'
|
||||
if (-not $got) { $got = '(empty)' }
|
||||
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||
$what = "${what}, ${label}: ${got}"
|
||||
}
|
||||
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||
exit 1
|
||||
}
|
||||
Write-Output -NoEnumerate $parsed
|
||||
}
|
||||
|
||||
# --- Чтение входного JSON-файла ---
|
||||
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||
# угаданное имя уйдёт в метаданные так же молча.
|
||||
function Read-JsonInputFile([string]$path) {
|
||||
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||
exit 1
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
try {
|
||||
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||
} catch {
|
||||
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Resolve path ---
|
||||
if (-not [System.IO.Path]::IsPathRooted($CIPath)) {
|
||||
$CIPath = Join-Path (Get-Location).Path $CIPath
|
||||
@@ -162,6 +227,14 @@ Assert-EditAllowed $CIPath 'editable'
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
@@ -202,7 +275,12 @@ if (-not (Test-Path $CIPath)) {
|
||||
</CommandInterface>
|
||||
"@
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI, $utf8Bom)
|
||||
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF, без перевода строки в конце.
|
||||
# (Правка существующего файла, наоборот, наследует его стиль — это делает
|
||||
# основной путь сохранения ниже.) Нормализация нужна потому, что here-string
|
||||
# берёт переводы строк из самого .ps1, а он в репозитории хранится с LF.
|
||||
$emptyCI = ($emptyCI -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
[System.IO.File]::WriteAllText($CIPath, $emptyCI.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
Write-Host "[INFO] Created new CommandInterface.xml: $CIPath"
|
||||
} else {
|
||||
Write-Error "File not found: $CIPath (use -CreateIfMissing to create)"
|
||||
@@ -338,10 +416,10 @@ function Ensure-Section([string]$sectionName) {
|
||||
}
|
||||
|
||||
# --- Parse value: string or JSON array ---
|
||||
function Parse-ValueList([string]$val) {
|
||||
function Parse-ValueList([string]$val, [string]$opName) {
|
||||
$val = $val.Trim()
|
||||
if ($val.StartsWith("[")) {
|
||||
$arr = $val | ConvertFrom-Json
|
||||
$arr = ConvertFrom-JsonInput $val "-Value for operation '$opName'" "a JSON array of command names" -Inline
|
||||
$result = @(); foreach ($item in $arr) { $result += "$item" }
|
||||
return ,$result
|
||||
}
|
||||
@@ -383,6 +461,10 @@ $script:typeNormMap = @{
|
||||
"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
|
||||
"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
|
||||
"ОбщаяФорма"="CommonForm"; "Подсистема"="Subsystem"
|
||||
"РегистрРасчёта"="CalculationRegister"; "РегистрРасчета"="CalculationRegister"
|
||||
"ПланВидовРасчёта"="ChartOfCalculationTypes"; "ПланВидовРасчета"="ChartOfCalculationTypes"
|
||||
"Роль"="Role"; "ОбщийМакет"="CommonTemplate"; "ЭлементСтиля"="StyleItem"
|
||||
"ОбщийРеквизит"="CommonAttribute"; "ГруппаКоманд"="CommandGroup"
|
||||
# Russian plural
|
||||
"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
|
||||
"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"; "Обработки"="DataProcessor"
|
||||
@@ -392,6 +474,10 @@ $script:typeNormMap = @{
|
||||
"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
|
||||
"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
|
||||
"Подсистемы"="Subsystem"
|
||||
"РегистрыРасчёта"="CalculationRegister"; "РегистрыРасчета"="CalculationRegister"
|
||||
"ПланыВидовРасчёта"="ChartOfCalculationTypes"; "ПланыВидовРасчета"="ChartOfCalculationTypes"
|
||||
"Роли"="Role"; "ОбщиеМакеты"="CommonTemplate"; "ЭлементыСтиля"="StyleItem"
|
||||
"ОбщиеРеквизиты"="CommonAttribute"; "ГруппыКоманд"="CommandGroup"
|
||||
}
|
||||
|
||||
function Normalize-CmdName([string]$name) {
|
||||
@@ -498,7 +584,7 @@ function Do-Show([string[]]$commands) {
|
||||
}
|
||||
|
||||
function Do-Place([string]$jsonVal) {
|
||||
$def = $jsonVal | ConvertFrom-Json
|
||||
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'place'" "a JSON object {command, group}" -Inline
|
||||
$cmdName = Normalize-CmdName "$($def.command)"
|
||||
$groupName = "$($def.group)"
|
||||
if (-not $cmdName -or -not $groupName) { Write-Error "place requires {command, group}"; exit 1 }
|
||||
@@ -531,7 +617,7 @@ function Do-Place([string]$jsonVal) {
|
||||
}
|
||||
|
||||
function Do-Order([string]$jsonVal) {
|
||||
$def = $jsonVal | ConvertFrom-Json
|
||||
$def = ConvertFrom-JsonInput $jsonVal "-Value for operation 'order'" "a JSON object {group, commands:[...]}" -Inline
|
||||
$groupName = "$($def.group)"
|
||||
$commands = @($def.commands | ForEach-Object { Normalize-CmdName "$_" })
|
||||
if (-not $groupName -or $commands.Count -eq 0) { Write-Error "order requires {group, commands:[...]}"; exit 1 }
|
||||
@@ -569,7 +655,7 @@ function Do-Order([string]$jsonVal) {
|
||||
}
|
||||
|
||||
function Do-SubsystemOrder([string]$jsonVal) {
|
||||
$parsed = $jsonVal | ConvertFrom-Json
|
||||
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'subsystem-order'" "a JSON array of subsystem paths" -Inline
|
||||
$subsystems = @(); foreach ($s in $parsed) { $subsystems += "$s" }
|
||||
if ($subsystems.Count -eq 0) { Write-Error "subsystem-order requires array of subsystem paths"; exit 1 }
|
||||
|
||||
@@ -597,7 +683,7 @@ function Do-SubsystemOrder([string]$jsonVal) {
|
||||
}
|
||||
|
||||
function Do-GroupOrder([string]$jsonVal) {
|
||||
$parsed = $jsonVal | ConvertFrom-Json
|
||||
$parsed = ConvertFrom-JsonInput $jsonVal "-Value for operation 'group-order'" "a JSON array of group names" -Inline
|
||||
$groups = @(); foreach ($g in $parsed) { $groups += "$g" }
|
||||
if ($groups.Count -eq 0) { Write-Error "group-order requires array of group names"; exit 1 }
|
||||
|
||||
@@ -630,8 +716,8 @@ if ($DefinitionFile) {
|
||||
if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
|
||||
$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
|
||||
}
|
||||
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile
|
||||
$ops = $jsonText | ConvertFrom-Json
|
||||
$jsonText = Read-JsonInputFile $DefinitionFile
|
||||
$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
|
||||
if ($ops -is [System.Array]) {
|
||||
foreach ($op in $ops) { $operations += $op }
|
||||
} else {
|
||||
@@ -648,8 +734,8 @@ foreach ($op in $operations) {
|
||||
$opValue = if ($opValueRaw -is [string]) { $opValueRaw } else { $opValueRaw | ConvertTo-Json -Compress }
|
||||
|
||||
switch ($opName) {
|
||||
"hide" { Do-Hide (Parse-ValueList $opValue) }
|
||||
"show" { Do-Show (Parse-ValueList $opValue) }
|
||||
"hide" { Do-Hide (Parse-ValueList $opValue $opName) }
|
||||
"show" { Do-Show (Parse-ValueList $opValue $opName) }
|
||||
"place" { Do-Place $opValue }
|
||||
"order" { Do-Order $opValue }
|
||||
"subsystem-order" { Do-SubsystemOrder $opValue }
|
||||
@@ -674,8 +760,16 @@ $memStream.Close()
|
||||
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
Info "Saved: $resolvedPath"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-edit v1.9 — Edit 1C CommandInterface.xml
|
||||
# interface-edit v1.22 — Edit 1C CommandInterface.xml (+русские алиасы типов: формы с ё и без)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -10,6 +10,65 @@ import subprocess
|
||||
import sys
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -189,6 +248,16 @@ def assert_edit_allowed(target_path, require):
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
@@ -279,10 +348,71 @@ def import_ci_fragment(xml_string):
|
||||
return nodes
|
||||
|
||||
|
||||
def parse_value_list(val):
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def parse_value_list(val, op_name):
|
||||
val = val.strip()
|
||||
if val.startswith("["):
|
||||
arr = json.loads(val)
|
||||
arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of command names", inline=True))
|
||||
return [str(item) for item in arr]
|
||||
return [val]
|
||||
|
||||
@@ -304,21 +434,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -357,6 +488,10 @@ TYPE_NORM_MAP = {
|
||||
'ПланОбмена': 'ExchangePlan', 'ЖурналДокументов': 'DocumentJournal',
|
||||
'ОбщийМодуль': 'CommonModule', 'ОбщаяКоманда': 'CommonCommand',
|
||||
'ОбщаяФорма': 'CommonForm', 'Подсистема': 'Subsystem',
|
||||
'РегистрРасчёта': 'CalculationRegister', 'РегистрРасчета': 'CalculationRegister',
|
||||
'ПланВидовРасчёта': 'ChartOfCalculationTypes', 'ПланВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роль': 'Role', 'ОбщийМакет': 'CommonTemplate', 'ЭлементСтиля': 'StyleItem',
|
||||
'ОбщийРеквизит': 'CommonAttribute', 'ГруппаКоманд': 'CommandGroup',
|
||||
# Russian plural
|
||||
'Справочники': 'Catalog', 'Документы': 'Document', 'Перечисления': 'Enum',
|
||||
'Константы': 'Constant', 'Отчёты': 'Report', 'Отчеты': 'Report', 'Обработки': 'DataProcessor',
|
||||
@@ -366,6 +501,10 @@ TYPE_NORM_MAP = {
|
||||
'БизнесПроцессы': 'BusinessProcess', 'Задачи': 'Task',
|
||||
'ПланыОбмена': 'ExchangePlan', 'ЖурналыДокументов': 'DocumentJournal',
|
||||
'Подсистемы': 'Subsystem',
|
||||
'РегистрыРасчёта': 'CalculationRegister', 'РегистрыРасчета': 'CalculationRegister',
|
||||
'ПланыВидовРасчёта': 'ChartOfCalculationTypes', 'ПланыВидовРасчета': 'ChartOfCalculationTypes',
|
||||
'Роли': 'Role', 'ОбщиеМакеты': 'CommonTemplate', 'ЭлементыСтиля': 'StyleItem',
|
||||
'ОбщиеРеквизиты': 'CommonAttribute', 'ГруппыКоманд': 'CommandGroup',
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +540,7 @@ def main():
|
||||
parser.add_argument("-Value", default=None)
|
||||
parser.add_argument("-CreateIfMissing", action="store_true")
|
||||
parser.add_argument("-NoValidate", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Mode validation ---
|
||||
if args.DefinitionFile and args.Operation:
|
||||
@@ -438,7 +577,12 @@ def main():
|
||||
f'\tversion="{format_version}">\n'
|
||||
f'</CommandInterface>'
|
||||
)
|
||||
with open(ci_path, "w", encoding="utf-8-sig") as fh:
|
||||
# Файл СОЗДАЁМ — пишем канон выгрузки: CRLF в разделителях. (Правка
|
||||
# существующего файла, наоборот, наследует его стиль — это делает
|
||||
# save_xml_bom через _detect_xml_style.) newline="" обязателен: без него
|
||||
# текстовый режим дал бы CRLF на Windows и LF на macOS.
|
||||
empty_ci = empty_ci.replace("\r\n", "\n").replace("\n", "\r\n").rstrip("\r\n")
|
||||
with open(ci_path, "w", encoding="utf-8-sig", newline="") as fh:
|
||||
fh.write(empty_ci)
|
||||
print(f"[INFO] Created new CommandInterface.xml: {ci_path}")
|
||||
else:
|
||||
@@ -564,7 +708,8 @@ def main():
|
||||
|
||||
def do_place(json_val):
|
||||
nonlocal add_count, modify_count
|
||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||
json_val, "-Value for operation 'place'", "a JSON object {command, group}", inline=True))
|
||||
cmd_name = normalize_cmd_name(str(defn["command"]))
|
||||
group_name = str(defn["group"])
|
||||
if not cmd_name or not group_name:
|
||||
@@ -592,7 +737,8 @@ def main():
|
||||
|
||||
def do_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
defn = json_val if isinstance(json_val, dict) else json.loads(json_val)
|
||||
defn = ci_json(json_val if isinstance(json_val, dict) else parse_json_input(
|
||||
json_val, "-Value for operation 'order'", "a JSON object {group, commands:[...]}", inline=True))
|
||||
group_name = str(defn["group"])
|
||||
commands = [normalize_cmd_name(str(c)) for c in defn["commands"]]
|
||||
if not group_name or not commands:
|
||||
@@ -626,7 +772,8 @@ def main():
|
||||
|
||||
def do_subsystem_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||
json_val, "-Value for operation 'subsystem-order'", "a JSON array of subsystem paths", inline=True))
|
||||
subsystems = [str(s) for s in parsed]
|
||||
if not subsystems:
|
||||
print("subsystem-order requires array of subsystem paths", file=sys.stderr)
|
||||
@@ -651,7 +798,8 @@ def main():
|
||||
|
||||
def do_group_order(json_val):
|
||||
nonlocal add_count, remove_count
|
||||
parsed = json_val if isinstance(json_val, list) else json.loads(json_val)
|
||||
parsed = ci_json(json_val if isinstance(json_val, list) else parse_json_input(
|
||||
json_val, "-Value for operation 'group-order'", "a JSON array of group names", inline=True))
|
||||
groups = [str(g) for g in parsed]
|
||||
if not groups:
|
||||
print("group-order requires array of group names", file=sys.stderr)
|
||||
@@ -680,8 +828,7 @@ def main():
|
||||
def_file = args.DefinitionFile
|
||||
if not os.path.isabs(def_file):
|
||||
def_file = os.path.join(os.getcwd(), def_file)
|
||||
with open(def_file, "r", encoding="utf-8-sig") as fh:
|
||||
ops = json.loads(fh.read())
|
||||
ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
|
||||
if isinstance(ops, list):
|
||||
operations = ops
|
||||
else:
|
||||
@@ -691,19 +838,21 @@ def main():
|
||||
|
||||
for op in operations:
|
||||
op_name = op.get("operation", args.Operation or "")
|
||||
# PS сравнивает имя операции через switch, а он регистронезависим.
|
||||
op_key = str(op_name).lower()
|
||||
op_value = op.get("value", args.Value or "")
|
||||
|
||||
if op_name == "hide":
|
||||
do_hide(parse_value_list(op_value))
|
||||
elif op_name == "show":
|
||||
do_show(parse_value_list(op_value))
|
||||
elif op_name == "place":
|
||||
if op_key == "hide":
|
||||
do_hide(parse_value_list(op_value, op_name))
|
||||
elif op_key == "show":
|
||||
do_show(parse_value_list(op_value, op_name))
|
||||
elif op_key == "place":
|
||||
do_place(op_value)
|
||||
elif op_name == "order":
|
||||
elif op_key == "order":
|
||||
do_order(op_value)
|
||||
elif op_name == "subsystem-order":
|
||||
elif op_key == "subsystem-order":
|
||||
do_subsystem_order(op_value)
|
||||
elif op_name == "group-order":
|
||||
elif op_key == "group-order":
|
||||
do_group_order(op_value)
|
||||
else:
|
||||
print(f"Unknown operation: {op_name}", file=sys.stderr)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
||||
# interface-validate v1.4 — Validate 1C CommandInterface.xml structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||
[Parameter(Mandatory, Position=0)][Alias('Path')][string]$CIPath,
|
||||
[switch]$Detailed,
|
||||
[int]$MaxErrors = 30,
|
||||
[string]$OutFile
|
||||
@@ -51,16 +52,21 @@ $script:output = New-Object System.Text.StringBuilder 8192
|
||||
$script:allCommandNames = @()
|
||||
|
||||
function Out-Line([string]$msg) { $script:output.AppendLine($msg) | Out-Null }
|
||||
function Report-OK([string]$msg) {
|
||||
function Report-OK {
|
||||
param([string]$msg)
|
||||
$script:okCount++
|
||||
if ($Detailed) { Out-Line "[OK] $msg" }
|
||||
}
|
||||
function Report-Error([string]$msg) {
|
||||
function Report-Error {
|
||||
param([string]$msg)
|
||||
$script:errors++
|
||||
Out-Line "[ERROR] $msg"
|
||||
if ($script:errors -ge $MaxErrors) { $script:stopped = $true }
|
||||
if ($script:errors -ge $MaxErrors) {
|
||||
$script:stopped = $true
|
||||
}
|
||||
}
|
||||
function Report-Warn([string]$msg) {
|
||||
function Report-Warn {
|
||||
param([string]$msg)
|
||||
$script:warnings++
|
||||
Out-Line "[WARN] $msg"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
# interface-validate v1.1 — Validate 1C CommandInterface.xml structure
|
||||
# interface-validate v1.4 — Validate 1C CommandInterface.xml structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
"""Validates CommandInterface.xml sections, command references, order, duplicates."""
|
||||
import sys, os, argparse, re
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
NS_CI = 'http://v8.1c.ru/8.3/xcf/extrnprops'
|
||||
NS_XR = 'http://v8.1c.ru/8.3/xcf/readable'
|
||||
NS_XSI = 'http://www.w3.org/2001/XMLSchema-instance'
|
||||
@@ -83,7 +105,7 @@ def main():
|
||||
parser.add_argument('-Detailed', action='store_true')
|
||||
parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
|
||||
parser.add_argument('-OutFile', dest='OutFile', default='')
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
ci_path = args.CIPath
|
||||
detailed = args.Detailed
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# meta-compile v1.76 — Compile 1C metadata object from JSON
|
||||
# meta-compile v1.99 — Compile 1C metadata object from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$JsonPath,
|
||||
@@ -9,6 +10,70 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Разбор пользовательского JSON ---
|
||||
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||
# Возврат через -NoEnumerate: без него одноэлементный
|
||||
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||
try {
|
||||
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||
$parsed = $text | ConvertFrom-Json
|
||||
} catch {
|
||||
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||
if ($Inline) {
|
||||
$got = ($text -replace '\s+', ' ').Trim()
|
||||
$label = 'got'
|
||||
if (-not $got) { $got = '(empty)' }
|
||||
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||
$what = "${what}, ${label}: ${got}"
|
||||
}
|
||||
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||
exit 1
|
||||
}
|
||||
Write-Output -NoEnumerate $parsed
|
||||
}
|
||||
|
||||
# --- Чтение входного JSON-файла ---
|
||||
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||
# угаданное имя уйдёт в метаданные так же молча.
|
||||
function Read-JsonInputFile([string]$path) {
|
||||
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||
exit 1
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
try {
|
||||
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||
} catch {
|
||||
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# --- 1. Load and validate JSON ---
|
||||
@@ -18,8 +83,8 @@ if (-not (Test-Path $JsonPath)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$json = Get-Content -Raw -Encoding UTF8 $JsonPath
|
||||
$def = $json | ConvertFrom-Json
|
||||
$json = Read-JsonInputFile $JsonPath
|
||||
$def = ConvertFrom-JsonInput $json $JsonPath
|
||||
|
||||
# --- Support guard (Ext/ParentConfigurations.bin) ---
|
||||
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
|
||||
@@ -161,7 +226,10 @@ if ($def -is [array] -or ($null -ne $def -and $def.GetType().BaseType.Name -eq '
|
||||
$idx = 0
|
||||
foreach ($item in $def) {
|
||||
$idx++
|
||||
$tmpJson = Join-Path ([System.IO.Path]::GetTempPath()) "meta-compile-batch-$idx.json"
|
||||
# Имя с GUID, а не "batch-$idx": фиксированное имя в общем %TEMP% сталкивало
|
||||
# два параллельных запуска навыка на одной машине — Set-Content падал с
|
||||
# «file is being used by another process». py-порт уже брал mkstemp.
|
||||
$tmpJson = Join-Path ([System.IO.Path]::GetTempPath()) "meta-compile-batch-$idx-$([guid]::NewGuid().ToString('N')).json"
|
||||
try {
|
||||
$item | ConvertTo-Json -Depth 20 | Set-Content -Encoding UTF8 $tmpJson
|
||||
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList "-NoProfile -File `"$PSCommandPath`" -JsonPath `"$tmpJson`" -OutputDir `"$OutputDir`"" -NoNewWindow -Wait -PassThru
|
||||
@@ -405,6 +473,11 @@ $validTypes = @("Catalog","Document","Enum","Constant","InformationRegister","Ac
|
||||
"Sequence","FilterCriterion","DocumentNumerator","SettingsStorage","CommonForm",
|
||||
"SessionParameter","CommonCommand","CommandGroup","CommonAttribute","FunctionalOptionsParameter","WSReference",
|
||||
"CommonPicture","CommonTemplate")
|
||||
# -notin регистронезависим, поэтому "catalog" проходил проверку и дальше шёл в ИМЯ ТЕГА и в
|
||||
# Configuration.xml как есть — выгрузка получалась с <catalog>, которую платформа не принимает.
|
||||
# Прощаем регистр, но приводим к канону списка.
|
||||
$canonType = $validTypes | Where-Object { $_ -eq $objType } | Select-Object -First 1
|
||||
if ($canonType) { $objType = $canonType }
|
||||
if ($objType -notin $validTypes) {
|
||||
Write-Error "Unsupported type: $objType. Valid: $($validTypes -join ', ')"
|
||||
exit 1
|
||||
@@ -544,6 +617,10 @@ $script:typeNamespaceMap = @{
|
||||
}
|
||||
# Типы current-config пространства (cfg:, объявлено в корне): объектные (CatalogObject.X/DataProcessorObject.X/…)
|
||||
# и голые (ConstantsSet/ReportBuilder). Ссылочные (*Ref.X/DefinedType.X) идут ОТДЕЛЬНО через локальный d5p1 (§memory).
|
||||
# Префикс current-config для ссылочных типов. 'cfg' — для файлов, чья шапка его объявляет
|
||||
# (объектный XML, Ext/Form.xml общей формы). $null на время сборки Ext/Predefined.xml, чья
|
||||
# шапка его НЕ объявляет: там и платформа уходит на локальное объявление.
|
||||
$script:cfgPrefix = 'cfg'
|
||||
$script:cfgBareTypes = @("ConstantsSet", "ReportBuilder", "FilterCriterion")
|
||||
$script:cfgObjectKinds = @("Catalog","Document","Enum","ChartOfAccounts","ChartOfCharacteristicTypes",
|
||||
"ChartOfCalculationTypes","ExchangePlan","BusinessProcess","Task","InformationRegister","AccumulationRegister",
|
||||
@@ -574,7 +651,20 @@ function Resolve-TypeStr {
|
||||
param([string]$typeStr)
|
||||
if (-not $typeStr) { return $typeStr }
|
||||
|
||||
# Check for parameterized types: Number(15,2), Строка(100), etc.
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if ($typeStr.StartsWith('cfg:')) {
|
||||
$typeStr = $typeStr.Substring(4)
|
||||
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||
}
|
||||
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||
$baseName = $Matches[1].Trim()
|
||||
$params = $Matches[2]
|
||||
@@ -583,7 +673,7 @@ function Resolve-TypeStr {
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Check for reference types: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
if ($typeStr.Contains('.')) {
|
||||
$dotIdx = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||
@@ -593,10 +683,9 @@ function Resolve-TypeStr {
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Simple name lookup
|
||||
# Простое имя
|
||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||
if ($resolved) { return $resolved }
|
||||
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
@@ -605,10 +694,44 @@ function Emit-TypeContent {
|
||||
if (-not $typeStr) { return }
|
||||
|
||||
# Composite type: "Type1 + Type2 + Type3"
|
||||
# Платформа пишет сначала ВСЕ <v8:Type>/<v8:TypeSet>, и только потом блоки
|
||||
# квалификаторов — а рекурсия ниже печатала бы каждую часть целиком (тип вместе со
|
||||
# своими квалификаторами). На одиночном типе оба порядка совпадают, поэтому
|
||||
# расхождение вылезало только на составном.
|
||||
# Порядок самих блоков квалификаторов тоже канонический и НЕ зеркалит порядок типов:
|
||||
# Number, String, Date (корпус acc+erp, контрпримеров нет — при типах
|
||||
# boolean,string,dateTime,decimal квалификаторы идут Number,String,Date).
|
||||
# Порядок типов при этом сохраняем как в DSL: он и есть порядок источника.
|
||||
if ($typeStr.Contains(' + ')) {
|
||||
$parts = $typeStr -split '\s*\+\s*'
|
||||
$typeLines = New-Object System.Collections.ArrayList
|
||||
$qualBlocks = @{} # 'Number'|'String'|'Date' → строки блока
|
||||
foreach ($part in $parts) {
|
||||
# X пишет в StringBuilder, поэтому «перехват» — это запомнить длину, вызвать
|
||||
# эмиттер и откатить добавленное. В py-порту X добавляет в список, и там тот
|
||||
# же алгоритм выражен срезом — различие рантаймов, не логики.
|
||||
$before = $script:xml.Length
|
||||
Emit-TypeContent $indent $part.Trim()
|
||||
$chunk = $script:xml.ToString($before, $script:xml.Length - $before)
|
||||
[void]$script:xml.Remove($before, $script:xml.Length - $before)
|
||||
$curQual = $null
|
||||
foreach ($line in ($chunk -split "`r?`n")) {
|
||||
if ($line -eq '') { continue }
|
||||
if ($line -match '<v8:(String|Number|Date)Qualifiers>') {
|
||||
$curQual = $Matches[1]
|
||||
$qualBlocks[$curQual] = New-Object System.Collections.ArrayList
|
||||
}
|
||||
if ($curQual) {
|
||||
[void]$qualBlocks[$curQual].Add($line)
|
||||
if ($line -match '</v8:(String|Number|Date)Qualifiers>') { $curQual = $null }
|
||||
} else {
|
||||
[void]$typeLines.Add($line)
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($line in $typeLines) { X $line }
|
||||
foreach ($q in @('Number', 'String', 'Date')) {
|
||||
if ($qualBlocks.ContainsKey($q)) { foreach ($line in $qualBlocks[$q]) { X $line } }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -723,9 +846,22 @@ function Emit-TypeContent {
|
||||
return
|
||||
}
|
||||
|
||||
# Reference types — use local xmlns declaration for 1C compatibility
|
||||
# Ссылочные типы — корневой cfg:, как пишет платформа. Раньше здесь объявлялся
|
||||
# ЛОКАЛЬНЫЙ xmlns:d5p1 на тот же URI, что уже объявлен в шапке ($script:xmlnsDecl):
|
||||
# формально эквивалентно (значим URI, не префикс) и платформой принималось, но
|
||||
# первый же цикл «загрузить в базу → выгрузить» переписывал каждый ссылочный тип
|
||||
# в cfg: — то есть давал diff-шум на ровном месте. Форма пришла из СКД, где cfg:
|
||||
# действительно не работает; в метаданных такого ограничения нет.
|
||||
# NB: локальная xmlns остаётся законной для ЧУЖИХ пространств — см. $script:typeNamespaceMap.
|
||||
# $script:cfgPrefix = $null означает «пишем файл, корень которого cfg НЕ объявляет»
|
||||
# (Ext/Predefined.xml — его шапка это predef/v8/xr/xs/xsi). Там платформа сама уходит
|
||||
# на локальную форму: в корпусе `<v8:Type xmlns:d6p1="…current-config">d6p1:CatalogRef.Валюты`.
|
||||
if ($typeStr -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$') {
|
||||
X "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$typeStr</v8:Type>"
|
||||
if ($script:cfgPrefix) {
|
||||
X "$indent<v8:Type>$($script:cfgPrefix):$typeStr</v8:Type>"
|
||||
} else {
|
||||
X "$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$typeStr</v8:Type>"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1245,15 +1381,21 @@ $script:standardAttributesByType = @{
|
||||
"Document" = @("Posted","Ref","DeletionMark","Date","Number")
|
||||
"Enum" = @("Order","Ref")
|
||||
"InformationRegister" = @("Active","LineNumber","Recorder","Period")
|
||||
"AccumulationRegister" = @("Active","LineNumber","Recorder","Period")
|
||||
"AccountingRegister" = @("Active","Period","Recorder","LineNumber","Account")
|
||||
"CalculationRegister" = @("Active","Recorder","LineNumber","RegistrationPeriod","CalculationType","ReversingEntry")
|
||||
"AccumulationRegister" = @("RecordType","Active","LineNumber","Recorder","Period")
|
||||
"AccountingRegister" = @("PeriodAdjustment","Account","RecordType","Active","LineNumber","Recorder","Period")
|
||||
"CalculationRegister" = @("RegistrationPeriod","ReversingEntry","Active","EndOfBasePeriod","BegOfBasePeriod","EndOfActionPeriod","BegOfActionPeriod","ActionPeriod","CalculationType","LineNumber","Recorder")
|
||||
"ChartOfAccounts" = @("PredefinedDataName","Order","OffBalance","Type","Description","Code","Parent","Predefined","DeletionMark","Ref")
|
||||
"ChartOfCharacteristicTypes" = @("PredefinedDataName","Predefined","Ref","DeletionMark","Description","Code","Parent","ValueType")
|
||||
"ChartOfCharacteristicTypes" = @("PredefinedDataName","ValueType","Description","Code","IsFolder","Parent","Predefined","DeletionMark","Ref")
|
||||
"ChartOfCalculationTypes" = @("PredefinedDataName","Predefined","Ref","DeletionMark","ActionPeriodIsBasic","Description","Code")
|
||||
"BusinessProcess" = @("Ref","DeletionMark","Date","Number","Started","Completed","HeadTask")
|
||||
"Task" = @("Ref","DeletionMark","Date","Number","Executed","Description","RoutePoint","BusinessProcess")
|
||||
"ExchangePlan" = @("Ref","DeletionMark","Code","Description","ThisNode","SentNo","ReceivedNo")
|
||||
"BusinessProcess" = @("Started","HeadTask","Completed","Ref","DeletionMark","Date","Number")
|
||||
"Task" = @("Executed","Description","RoutePoint","BusinessProcess","Ref","DeletionMark","Date","Number")
|
||||
# Порядок в каждом списке — канон выгрузки, снят с корпуса acc+erp (внутри типа разброса нет).
|
||||
# Условные члены перечислены в $script:stdAttrConditions — позицию они берут отсюда, а
|
||||
# присутствие определяется свойствами объекта.
|
||||
# У ПВХ IsFolder входит в фикс-список: он есть у всех 23 объектов корпуса с этим блоком.
|
||||
# У регистра расчёта список безусловен: реквизиты периода действия и базового периода
|
||||
# платформа пишет при любых ActionPeriod/BasePeriod/Periodicity (синтетика, все 4 комбинации).
|
||||
"ExchangePlan" = @("ThisNode","ReceivedNo","SentNo","Ref","DeletionMark","Description","Code")
|
||||
"DocumentJournal" = @("Type","Ref","Date","Posted","DeletionMark","Number")
|
||||
}
|
||||
|
||||
@@ -1372,6 +1514,55 @@ function Emit-StandardAttribute {
|
||||
# - stdAttrProfile[тип]: профиль материализованного блока (пусто = schema-дефолт), поверх — DSL-override.
|
||||
# Миграция типа = добавить его в stdAttrConditionalTypes + stdAttrProfile и переснять снэпшоты; КОД НЕ ТРОГАЕМ.
|
||||
$script:stdAttrConditionalTypes = @('Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document')
|
||||
|
||||
# Условные члены списка типа: позиция берётся из $script:standardAttributesByType, а присутствие —
|
||||
# из свойств самого объекта, как у платформы. Предикат ОБЯЗАН принимать определение параметром:
|
||||
# scriptblock не видит $def вызывающей функции, и отказ был бы молчаливым.
|
||||
$script:stdAttrConditions = @{
|
||||
"AccountingRegister" = @{
|
||||
"PeriodAdjustment" = { param($d) $v = 0; if ($null -ne $d.periodAdjustmentLength) { $v = [int]"$($d.periodAdjustmentLength)" }; $v -gt 0 }
|
||||
"RecordType" = { param($d) -not ($d.correspondence -eq $true) }
|
||||
}
|
||||
"AccumulationRegister" = @{
|
||||
"RecordType" = { param($d) $raw = if ($d.registerType) { "$($d.registerType)" } else { "Balance" }; (Normalize-EnumValue "RegisterType" $raw) -eq "Balance" }
|
||||
}
|
||||
}
|
||||
|
||||
# Хвостовая группа: реквизиты, которых нет в списке типа и которые идут ПОСЛЕ него.
|
||||
# У бухрегистра это пары субконто. Именами их не перечислить: их количество задаётся
|
||||
# свойством MaxExtDimensionCount плана счетов, а не константой (в корпусе везде 3, но
|
||||
# это однородность выборки, а не правило). Поэтому — шаблон, а не список.
|
||||
$script:stdAttrTailPattern = @{
|
||||
"AccountingRegister" = '^ExtDimension(Type)?\d+$'
|
||||
}
|
||||
|
||||
# Состав хвоста задаёт не DSL, а объект, на который регистр ссылается: пар субконто столько,
|
||||
# сколько у плана счетов MaxExtDimensionCount. Читаем его из выгрузки — как версию формата из
|
||||
# Configuration.xml, — чтобы регистр, описанный неполным DSL, совпал с тем, что материализует
|
||||
# платформа. План не найден → хвост не генерируем и говорим об этом в выводе.
|
||||
$script:stdAttrTailHint = $null
|
||||
$script:stdAttrTailDerived = @{
|
||||
"AccountingRegister" = {
|
||||
param($d, $objectName, $outDir)
|
||||
$ref = "$($d.chartOfAccounts)"
|
||||
if (-not $ref) { return @() }
|
||||
$chartName = $ref -replace '^.*\.', '' # ссылка вида ChartOfAccounts.X (имя объекта точек не содержит)
|
||||
$path = Join-Path (Join-Path $outDir "ChartsOfAccounts") "$chartName.xml"
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
$script:stdAttrTailHint = "ChartOfAccounts '$chartName' not found in dump — ExtDimension pairs not generated (platform will add them on load)"
|
||||
return @()
|
||||
}
|
||||
$n = 0
|
||||
if ([System.IO.File]::ReadAllText($path) -match '<MaxExtDimensionCount>(\d+)</MaxExtDimensionCount>') { $n = [int]$matches[1] }
|
||||
$out = @()
|
||||
for ($i = 1; $i -le $n; $i++) {
|
||||
# ExtDimensionN связан с Account через LinkByType (LinkItem = номер), ExtDimensionTypeN — нет.
|
||||
$out += @{ name = "ExtDimension$i"; ov = @{ LinkByType = @{ dataPath = "AccountingRegister.$objectName.StandardAttribute.Account"; linkItem = $i } } }
|
||||
$out += @{ name = "ExtDimensionType$i"; ov = @{} }
|
||||
}
|
||||
return $out
|
||||
}
|
||||
}
|
||||
function Emit-StandardAttributes {
|
||||
param([string]$indent, [string]$objectType)
|
||||
$attrs = $script:standardAttributesByType[$objectType]
|
||||
@@ -1381,14 +1572,45 @@ function Emit-StandardAttributes {
|
||||
if ($conditional -and $null -eq $sa) { return } # условный тип без кастомизации → блока нет
|
||||
if ($sa -is [string] -and $sa -eq '') { return } # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок — правило не выводимо)
|
||||
$profile = $script:stdAttrProfile[$objectType]; if (-not $profile) { $profile = @{} }
|
||||
# Доп. (опциональные) стандартные реквизиты вне фикс-списка типа — напр. ExchangeDate у части ПланОбмена
|
||||
# (легаси, присутствие не выводится из свойств). Эмитим по факту наличия ключа в DSL, ПЕРЕД фикс-списком (их позиция).
|
||||
$extra = @()
|
||||
if ($sa) { foreach ($k in $sa.PSObject.Properties.Name) { if ($attrs -notcontains $k) { $extra += $k } } }
|
||||
# Список типа задаёт ПОРЯДОК всех известных стандартных реквизитов, включая условные:
|
||||
# их позиция бывает и до, и после обязательных (у бухрегистра PeriodAdjustment идёт
|
||||
# перед Account, RecordType — после, а ExtDimension1..3/ExtDimensionType1..3 — после Period),
|
||||
# поэтому «условные скопом вперёд» не выражает канон.
|
||||
$cond = $script:stdAttrConditions[$objectType]
|
||||
# Ключи, которых нет в списке типа ВООБЩЕ. По умолчанию их позиция — ПЕРЕД списком
|
||||
# (легаси вроде ExchangeDate у части планов обмена). Подходящие под хвостовой шаблон
|
||||
# типа идут ПОСЛЕ, в порядке номера, а внутри номера — сначала ExtDimensionN, затем
|
||||
# ExtDimensionTypeN (порядок платформы).
|
||||
$tailRe = $script:stdAttrTailPattern[$objectType]
|
||||
$extra = @(); $tail = @()
|
||||
if ($sa) {
|
||||
foreach ($k in $sa.PSObject.Properties.Name) {
|
||||
if ($attrs -contains $k) { continue }
|
||||
if ($tailRe -and $k -match $tailRe) { $tail += $k } else { $extra += $k }
|
||||
}
|
||||
}
|
||||
# Хвост, выведенный из связанного объекта: дополняет DSL, а не заменяет его — лишнее из DSL
|
||||
# остаётся (прощаем), недостающее добавляется вместе со своими значениями по умолчанию.
|
||||
$derivedOv = @{}
|
||||
$gen = $script:stdAttrTailDerived[$objectType]
|
||||
if ($gen) {
|
||||
foreach ($e in @(& $gen $def $objName $OutputDir)) {
|
||||
$derivedOv[$e.name] = $e.ov
|
||||
if ($tail -notcontains $e.name) { $tail += $e.name }
|
||||
}
|
||||
}
|
||||
$tail = @($tail | Sort-Object @{e={[int]([regex]::Match($_, '\d+').Value)}}, @{e={ if ($_ -match 'Type\d+$') { 1 } else { 0 } }})
|
||||
X "$indent<StandardAttributes>"
|
||||
foreach ($a in ($extra + $attrs)) {
|
||||
foreach ($a in ($extra + $attrs + $tail)) {
|
||||
# Условный реквизит: эмитим, если так велят свойства объекта ЛИБО если ключ есть в DSL.
|
||||
# Дизъюнкция страхует роундтрип — декомпилятор перечисляет все имена блока.
|
||||
if ($cond -and $cond.ContainsKey($a)) {
|
||||
$present = ($sa -and $sa.PSObject.Properties[$a]) -or (& $cond[$a] $def)
|
||||
if (-not $present) { continue }
|
||||
}
|
||||
$ov = @{}
|
||||
if ($profile.ContainsKey($a)) { foreach ($k in $profile[$a].Keys) { $ov[$k] = $profile[$a][$k] } }
|
||||
if ($derivedOv.ContainsKey($a)) { foreach ($k in $derivedOv[$a].Keys) { $ov[$k] = $derivedOv[$a][$k] } }
|
||||
if ($sa) { # DSL-override применяем всегда при наличии ключа (для не-условных типов тоже, напр. ExchangePlan)
|
||||
$d = $sa.$a
|
||||
if ($d) {
|
||||
@@ -1946,9 +2168,12 @@ function Emit-Attribute {
|
||||
}
|
||||
}
|
||||
|
||||
# Use — only for catalog top-level attributes
|
||||
# Use — у реквизитов справочника и ПВХ. Позиция РАЗНАЯ: справочник пишет Use ПЕРЕД
|
||||
# Indexing, ПВХ — ПОСЛЕ него (корпус acc+erp: Catalog `Use,Indexing,FullTextSearch`,
|
||||
# ПВХ `Indexing,Use,FullTextSearch,DataHistory`). Отсюда отдельный контекст "cct":
|
||||
# структурно реквизит ПВХ совпадает со справочником, расходится только этим порядком.
|
||||
$use = if ($parsed.use) { $parsed.use } else { "ForItem" }
|
||||
if ($context -eq "catalog") {
|
||||
$use = if ($parsed.use) { $parsed.use } else { "ForItem" }
|
||||
X "$indent`t`t<Use>$use</Use>"
|
||||
}
|
||||
|
||||
@@ -1964,6 +2189,7 @@ function Emit-Attribute {
|
||||
if ($parsed.indexing) { $indexing = $parsed.indexing }
|
||||
X "$indent`t`t<Indexing>$indexing</Indexing>"
|
||||
}
|
||||
if ($context -eq "cct") { X "$indent`t`t<Use>$use</Use>" }
|
||||
|
||||
# Реквизит адресации задачи: AddressingDimension (ссылка на измерение регистра исполнителей), между Indexing и FullTextSearch.
|
||||
if ($context -eq "task-addressing" -and $elemTag -eq "AddressingAttribute") {
|
||||
@@ -2131,6 +2357,11 @@ function Emit-EnumValue {
|
||||
X "$indent`t`t<Name>$(Esc-XmlText $parsed.name)</Name>"
|
||||
Emit-MLText "$indent`t`t" "Synonym" $parsed.synonym
|
||||
if ($parsed.comment) { X "$indent`t`t<Comment>$(Esc-XmlText $parsed.comment)</Comment>" } else { X "$indent`t`t<Comment/>" }
|
||||
# Цвет значения перечисления — свойство формата 2.21 (8.5), последним в Properties.
|
||||
if ($script:isFormat221) {
|
||||
$color = if ($parsed.color) { "$($parsed.color)" } else { "auto" }
|
||||
X "$indent`t`t<Color>$(Esc-XmlText $color)</Color>"
|
||||
}
|
||||
X "$indent`t</Properties>"
|
||||
X "$indent</EnumValue>"
|
||||
}
|
||||
@@ -2797,6 +3028,11 @@ function Emit-CommonFormProperties {
|
||||
} else {
|
||||
X "$i<UsePurposes/>"
|
||||
}
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# между UsePurposes и UseStandardCommands.
|
||||
if ($script:isFormat221) {
|
||||
X "$i<UseInInterfaceCompatibilityMode>$(Get-EnumProp 'UseInInterfaceCompatibilityMode' 'useInInterfaceCompatibilityMode' 'Any')</UseInInterfaceCompatibilityMode>"
|
||||
}
|
||||
$useStdCmds = if (Get-BoolProp "useStandardCommands" $false) { "true" } else { "false" }
|
||||
X "$i<UseStandardCommands>$useStdCmds</UseStandardCommands>"
|
||||
Emit-MLText $i "ExtendedPresentation" $def.extendedPresentation
|
||||
@@ -3040,7 +3276,9 @@ function Emit-ScheduledJobProperties {
|
||||
if ($description) { X "$i<Description>$(Esc-XmlText $description)</Description>" } else { X "$i<Description/>" }
|
||||
|
||||
$key = if ($def.key) { "$($def.key)" } else { "" }
|
||||
X "$i<Key>$(Esc-XmlText $key)</Key>"
|
||||
# Пустое значение → самозакрывающийся, как у <Description> выше: Конфигуратор
|
||||
# не пишет пустых пар.
|
||||
if ($key) { X "$i<Key>$(Esc-XmlText $key)</Key>" } else { X "$i<Key/>" }
|
||||
|
||||
$use = if ($def.use -eq $true) { "true" } else { "false" }
|
||||
X "$i<Use>$use</Use>"
|
||||
@@ -3105,6 +3343,8 @@ function Emit-ReportProperties {
|
||||
Emit-VerbatimRef $i "DefaultSettingsForm" $def.defaultSettingsForm
|
||||
Emit-VerbatimRef $i "AuxiliarySettingsForm" $def.auxiliarySettingsForm
|
||||
Emit-VerbatimRef $i "DefaultVariantForm" $def.defaultVariantForm
|
||||
# Вспомогательная форма варианта отчёта — свойство формата 2.21 (8.5).
|
||||
if ($script:isFormat221) { Emit-VerbatimRef $i "AuxiliaryVariantForm" $def.auxiliaryVariantForm }
|
||||
Emit-VerbatimRef $i "VariantsStorage" $def.variantsStorage
|
||||
Emit-VerbatimRef $i "SettingsStorage" $def.settingsStorage
|
||||
$inclHelp = if (Get-BoolProp "includeHelpInContents" $false) { "true" } else { "false" }
|
||||
@@ -3840,7 +4080,8 @@ function Emit-WebServiceProperties {
|
||||
if ($def.comment) { X "$i<Comment>$(Esc-XmlText "$($def.comment)")</Comment>" } else { X "$i<Comment/>" }
|
||||
|
||||
$namespace = if ($def.namespace) { "$($def.namespace)" } else { "" }
|
||||
X "$i<Namespace>$(Esc-XmlText $namespace)</Namespace>"
|
||||
# Пустое значение → самозакрывающийся, как у <Comment> выше.
|
||||
if ($namespace) { X "$i<Namespace>$(Esc-XmlText $namespace)</Namespace>" } else { X "$i<Namespace/>" }
|
||||
|
||||
# XDTOPackages — СПИСОК элементов, а не скаляр: значение либо ссылка на пакет конфигурации
|
||||
# (xr:MDObjectRef "XDTOPackage.Имя"), либо URI внешнего пространства имён (xs:string).
|
||||
@@ -4082,6 +4323,14 @@ $script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.
|
||||
function Detect-FormatVersion([string]$dir) {
|
||||
$d = $dir
|
||||
while ($d) {
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
$extPath = "$d.xml"
|
||||
if (Test-Path $extPath) {
|
||||
$extText = [System.IO.File]::ReadAllText($extPath, [System.Text.Encoding]::UTF8)
|
||||
$extHead = $extText.Substring(0, [Math]::Min(2000, $extText.Length))
|
||||
if ($extHead -match '<(ExternalDataProcessor|ExternalReport)[ >]' -and $extHead -match '<MetaDataObject[^>]+version="(\d+\.\d+)"') { return $Matches[1] }
|
||||
}
|
||||
$cfgPath = Join-Path $d "Configuration.xml"
|
||||
if (Test-Path $cfgPath) {
|
||||
$cfgText = [System.IO.File]::ReadAllText($cfgPath, [System.Text.Encoding]::UTF8)
|
||||
@@ -4143,6 +4392,15 @@ $script:compatMode = Detect-CompatibilityMode $OutputDir
|
||||
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
||||
$script:isFormat218 = (Get-FormatRank $script:formatVersion) -ge 218
|
||||
$script:isFormat220 = (Get-FormatRank $script:formatVersion) -ge 220
|
||||
$script:isFormat221 = (Get-FormatRank $script:formatVersion) -ge 221
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту.
|
||||
# Только для шапок MetaDataObject и Form — в файлах с корнем extrnprops
|
||||
# (Ext/ClientApplicationInterface.xml и т.п.) платформа его не пишет.
|
||||
if ($script:isFormat221) {
|
||||
$palNs = ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette"'
|
||||
$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', "$palNs xmlns:style="
|
||||
}
|
||||
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
||||
$script:lineNumberLengthDefault = if ((Get-CompatModeRank $script:compatMode) -ge 80327) { 9 } else { 5 }
|
||||
|
||||
@@ -4268,7 +4526,7 @@ if ($objType -in $typesWithAttrTS) {
|
||||
"Catalog" { "catalog" }
|
||||
"Document" { "document" }
|
||||
{ $_ -in @("DataProcessor","Report") } { "processor" }
|
||||
"ChartOfCharacteristicTypes" { "catalog" } # реквизиты ПВХ структурно как у справочника (Use/FillFromFillingValue/DataHistory)
|
||||
"ChartOfCharacteristicTypes" { "cct" } # как catalog (Use/FillFromFillingValue/DataHistory), но Use ПОСЛЕ Indexing
|
||||
{ $_ -in @("ChartOfAccounts","ChartOfCalculationTypes") } { "account" } # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
||||
default { "object" }
|
||||
}
|
||||
@@ -4356,16 +4614,31 @@ if ($objType -in @("InformationRegister","AccumulationRegister","AccountingRegis
|
||||
$regCtx = switch ($objType) { "InformationRegister" { "register-info" } "CalculationRegister" { "register-calc" } default { "register-other" } }
|
||||
# Все семейства регистров: ресурсы/измерения — через богатый Emit-Attribute (общий слой object-свойств).
|
||||
$dimResCtx = switch ($objType) { "InformationRegister" { "register-info" } "AccumulationRegister" { "register-accum" } "CalculationRegister" { "register-calc" } "AccountingRegister" { "register-account" } default { $null } }
|
||||
foreach ($r in $resources) {
|
||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $r $dimResCtx "Resource" }
|
||||
else { Emit-Resource "`t`t`t" $r $objType }
|
||||
}
|
||||
foreach ($d in $dims) {
|
||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $d $dimResCtx "Dimension" }
|
||||
else { Emit-Dimension "`t`t`t" $d $objType }
|
||||
}
|
||||
foreach ($a in $regAttrs) {
|
||||
Emit-Attribute "`t`t`t" $a $regCtx
|
||||
# Порядок видов детей — канон выгрузки, снят с корпуса (acc+erp, разброса внутри
|
||||
# типа нет): у большинства регистров Resource, Attribute, Dimension, а у
|
||||
# бухгалтерского — Dimension, Resource, Attribute. Команды у платформы идут
|
||||
# последними, как и здесь.
|
||||
$kindOrder = if ($objType -eq "AccountingRegister") { @('dim','res','attr') } else { @('res','attr','dim') }
|
||||
foreach ($kind in $kindOrder) {
|
||||
switch ($kind) {
|
||||
'res' {
|
||||
foreach ($r in $resources) {
|
||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $r $dimResCtx "Resource" }
|
||||
else { Emit-Resource "`t`t`t" $r $objType }
|
||||
}
|
||||
}
|
||||
'dim' {
|
||||
foreach ($d in $dims) {
|
||||
if ($dimResCtx) { Emit-Attribute "`t`t`t" $d $dimResCtx "Dimension" }
|
||||
else { Emit-Dimension "`t`t`t" $d $objType }
|
||||
}
|
||||
}
|
||||
'attr' {
|
||||
foreach ($a in $regAttrs) {
|
||||
Emit-Attribute "`t`t`t" $a $regCtx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($cmd in $regCommands) {
|
||||
Emit-Command "`t`t`t" $cmd.name $cmd.def
|
||||
@@ -4609,9 +4882,13 @@ function Build-PredefinedXml {
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"$xsiType`" version=`"$($script:formatVersion)`">`n")
|
||||
foreach ($it in $items) { Emit-PredefItem $sb $it "`t" $codeType }
|
||||
# Шапка Predefined.xml не объявляет cfg (predef/v8/xr/xs/xsi) — на время сборки этого
|
||||
# файла ссылочный тип уходит на локальную форму, как делает и платформа.
|
||||
$savedCfgPrefix = $script:cfgPrefix; $script:cfgPrefix = $null
|
||||
try { foreach ($it in $items) { Emit-PredefItem $sb $it "`t" $codeType } }
|
||||
finally { $script:cfgPrefix = $savedCfgPrefix }
|
||||
[void]$sb.Append("</PredefinedData>`n")
|
||||
return $sb.ToString()
|
||||
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
}
|
||||
|
||||
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
||||
@@ -4697,9 +4974,12 @@ function Build-PredefinedAccountXml {
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.Append("<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n")
|
||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"ChartOfAccountsPredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
||||
foreach ($it in $items) { Emit-PredefAccount $sb $it "`t" $objName $acctFlagNames $extDimFlagNames $extDimTypesRef }
|
||||
# См. Build-PredefinedXml: шапка этого файла cfg не объявляет.
|
||||
$savedCfgPrefix = $script:cfgPrefix; $script:cfgPrefix = $null
|
||||
try { foreach ($it in $items) { Emit-PredefAccount $sb $it "`t" $objName $acctFlagNames $extDimFlagNames $extDimTypesRef } }
|
||||
finally { $script:cfgPrefix = $savedCfgPrefix }
|
||||
[void]$sb.Append("</PredefinedData>`n")
|
||||
return $sb.ToString()
|
||||
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
}
|
||||
|
||||
# --- Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase). Строка "(Код) Имя [Наим]"
|
||||
@@ -4726,7 +5006,7 @@ function Build-PredefinedCalcTypeXml {
|
||||
[void]$sb.Append("<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"CalculationTypePredefinedItems`" version=`"$($script:formatVersion)`">`n")
|
||||
foreach ($it in $items) { Emit-PredefCalcType $sb $it "`t" }
|
||||
[void]$sb.Append("</PredefinedData>`n")
|
||||
return $sb.ToString()
|
||||
return ($sb.ToString() -replace "`r`n", "`n") -replace "`n", "`r`n"
|
||||
}
|
||||
|
||||
$extDir = Join-Path $objSubDir "Ext"
|
||||
@@ -4741,7 +5021,20 @@ if ($objType -notin $typesNoSubDir) {
|
||||
}
|
||||
|
||||
$enc = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($mainXmlPath, $metadataXml, $enc)
|
||||
|
||||
# Единая точка записи XML. Конфигуратор не пишет перевод строки в конце файла —
|
||||
# последний байт `>`; сборка через AppendLine добавляла лишний.
|
||||
# KeepEol в имени — отличие от одноимённой функции в скелетных навыках
|
||||
# (cf-init и др.): та ЕЩЁ и нормализует EOL к CRLF, а здесь этого делать
|
||||
# НЕЛЬЗЯ — в объектном XML бывают многострочные текстовые узлы (запрос,
|
||||
# синоним, значение заполнения), и сплошная нормализация меняла бы содержимое.
|
||||
# Разделители тут и так CRLF: документ собран через AppendLine.
|
||||
# Модули .bsl сюда НЕ идут: у них свой хвост.
|
||||
function Write-XmlFileKeepEol([string]$path, [string]$text, $encoding) {
|
||||
[System.IO.File]::WriteAllText($path, $text.TrimEnd("`r", "`n"), $encoding)
|
||||
}
|
||||
|
||||
Write-XmlFileKeepEol $mainXmlPath $metadataXml $enc
|
||||
|
||||
# Module files
|
||||
$modulesCreated = @()
|
||||
@@ -4822,8 +5115,10 @@ if ($objType -eq "CommonForm") {
|
||||
$cfFormXmlPath = Join-Path $extDir "Form.xml"
|
||||
if (-not (Test-Path $cfFormXmlPath)) {
|
||||
$cfFormNs = 'xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
||||
$cfFormXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`n<Form $cfFormNs version=`"$($script:formatVersion)`">`n`t<AutoCommandBar name=`"ФормаКоманднаяПанель`" id=`"-1`">`n`t`t<Autofill>true</Autofill>`n`t</AutoCommandBar>`n`t<ChildItems/>`n</Form>`n"
|
||||
[System.IO.File]::WriteAllText($cfFormXmlPath, $cfFormXml, $enc)
|
||||
# Шапка Form на 2.21 тоже несёт палитру — см. комментарий у $script:xmlnsDecl.
|
||||
if ($script:isFormat221) { $cfFormNs = $cfFormNs -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=' }
|
||||
$cfFormXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<Form $cfFormNs version=`"$($script:formatVersion)`">`r`n`t<AutoCommandBar name=`"ФормаКоманднаяПанель`" id=`"-1`">`r`n`t`t<Autofill>true</Autofill>`r`n`t</AutoCommandBar>`r`n`t<ChildItems/>`r`n</Form>`r`n"
|
||||
Write-XmlFileKeepEol $cfFormXmlPath $cfFormXml $enc
|
||||
$modulesCreated += $cfFormXmlPath
|
||||
}
|
||||
$cfModuleDir = Join-Path $extDir "Form"
|
||||
@@ -4876,7 +5171,7 @@ if ($objType -eq "ExchangePlan") {
|
||||
[void]$sbC.Append("`t</Item>`r`n")
|
||||
}
|
||||
[void]$sbC.Append("</ExchangePlanContent>`r`n")
|
||||
[System.IO.File]::WriteAllText($contentPath, $sbC.ToString(), $enc)
|
||||
Write-XmlFileKeepEol $contentPath $sbC.ToString() $enc
|
||||
$modulesCreated += $contentPath
|
||||
} elseif (-not (Test-Path $contentPath)) {
|
||||
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
||||
@@ -4884,7 +5179,7 @@ if ($objType -eq "ExchangePlan") {
|
||||
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
||||
Ensure-ExtDir
|
||||
$contentXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<ExchangePlanContent $xepNs version=`"$($script:formatVersion)`"/>`r`n"
|
||||
[System.IO.File]::WriteAllText($contentPath, $contentXml, $enc)
|
||||
Write-XmlFileKeepEol $contentPath $contentXml $enc
|
||||
$modulesCreated += $contentPath
|
||||
}
|
||||
}
|
||||
@@ -4893,7 +5188,7 @@ if ($objType -eq "BusinessProcess") {
|
||||
if (-not (Test-Path $flowchartPath)) {
|
||||
Ensure-ExtDir
|
||||
$flowchartXml = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<Flowchart xmlns=`"http://v8.1c.ru/8.3/MDClasses`" version=`"$($script:formatVersion)`"/>`r`n"
|
||||
[System.IO.File]::WriteAllText($flowchartPath, $flowchartXml, $enc)
|
||||
Write-XmlFileKeepEol $flowchartPath $flowchartXml $enc
|
||||
$modulesCreated += $flowchartPath
|
||||
}
|
||||
}
|
||||
@@ -4908,20 +5203,20 @@ if ($objType -eq 'ChartOfAccounts' -and $def.predefined -and @($def.predefined).
|
||||
$edtRef = if ($def.extDimensionTypes) { Resolve-TypePrefixSyn "$($def.extDimensionTypes)" } else { '' }
|
||||
$predefXml = Build-PredefinedAccountXml @($def.predefined) $objName $afNames $edfNames $edtRef
|
||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
||||
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||
$modulesCreated += $predefPath
|
||||
} elseif ($objType -eq 'ChartOfCalculationTypes' -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
||||
Ensure-ExtDir
|
||||
$predefXml = Build-PredefinedCalcTypeXml @($def.predefined)
|
||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
||||
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||
$modulesCreated += $predefPath
|
||||
} elseif ($predefRootByType.ContainsKey($objType) -and $def.predefined -and @($def.predefined).Count -gt 0) {
|
||||
Ensure-ExtDir
|
||||
$catCodeType = if ($def.codeType) { "$($def.codeType)" } else { 'String' }
|
||||
$predefXml = Build-PredefinedXml @($def.predefined) $predefRootByType[$objType] $catCodeType
|
||||
$predefPath = Join-Path $extDir "Predefined.xml"
|
||||
[System.IO.File]::WriteAllText($predefPath, $predefXml, $enc)
|
||||
Write-XmlFileKeepEol $predefPath $predefXml $enc
|
||||
$modulesCreated += $predefPath
|
||||
}
|
||||
|
||||
@@ -4939,75 +5234,85 @@ if ($commands -and $commands.Count -gt 0) {
|
||||
|
||||
# --- 17. Register in Configuration.xml ---
|
||||
|
||||
$configXmlPath = Join-Path $OutputDir "Configuration.xml"
|
||||
$regResult = $null
|
||||
# Регистрация объекта в <ChildObjects> родительского XML. Общая реализация: эталон —
|
||||
# meta-compile, копия — role-compile. Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
# Возвращает исход: added | already | no-childobj | no-config.
|
||||
function Register-InChildObjects([string]$ParentXmlPath, [string]$ParentTag, [string]$ChildTag, [string]$ChildName) {
|
||||
if (-not (Test-Path $ParentXmlPath)) { return "no-config" }
|
||||
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.PreserveWhitespace = $true
|
||||
$doc.Load($ParentXmlPath)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($doc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
$childObjects = $doc.SelectSingleNode("//md:$ParentTag/md:ChildObjects", $nsMgr)
|
||||
if (-not $childObjects) { return "no-childobj" }
|
||||
|
||||
$existing = $childObjects.SelectNodes("md:$ChildTag", $nsMgr)
|
||||
foreach ($e in $existing) {
|
||||
if ($e.InnerText -eq $ChildName) { return "already" }
|
||||
}
|
||||
|
||||
$newElem = $doc.CreateElement($ChildTag, "http://v8.1c.ru/8.3/MDClasses")
|
||||
$newElem.InnerText = $ChildName
|
||||
|
||||
if ($existing.Count -gt 0) {
|
||||
# Insert after last existing element of same type
|
||||
$lastElem = $existing[$existing.Count - 1]
|
||||
$newWs = $doc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertAfter($newWs, $lastElem) | Out-Null
|
||||
$childObjects.InsertAfter($newElem, $newWs) | Out-Null
|
||||
} else {
|
||||
# No existing elements of this type — insert before closing whitespace.
|
||||
# Самозакрытый <ChildObjects/> попадает сюда же: LastChild пуст, идёт ветка AppendChild.
|
||||
$lastChild = $childObjects.LastChild
|
||||
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$newWs = $doc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertBefore($newWs, $lastChild) | Out-Null
|
||||
$childObjects.InsertBefore($newElem, $lastChild) | Out-Null
|
||||
} else {
|
||||
$childObjects.AppendChild($doc.CreateWhitespace("`n`t`t`t")) | Out-Null
|
||||
$childObjects.AppendChild($newElem) | Out-Null
|
||||
$childObjects.AppendChild($doc.CreateWhitespace("`n`t`t")) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Save. Пишем через MemoryStream, а не прямо в файл: нужен шаг пост-обработки
|
||||
# строки — XmlWriter отдаёт `encoding="utf-8"` и `<a />`, Конфигуратор пишет
|
||||
# `encoding="UTF-8"` и `<a/>`.
|
||||
$cfgSettings = New-Object System.Xml.XmlWriterSettings
|
||||
$cfgSettings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||
$cfgSettings.Indent = $false
|
||||
$cfgSettings.NewLineHandling = [System.Xml.NewLineHandling]::None
|
||||
$memStream = New-Object System.IO.MemoryStream
|
||||
$writer = [System.Xml.XmlWriter]::Create($memStream, $cfgSettings)
|
||||
$doc.Save($writer)
|
||||
$writer.Flush(); $writer.Close()
|
||||
|
||||
$cfgText = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
|
||||
$memStream.Close()
|
||||
if ($cfgText.Length -gt 0 -and $cfgText[0] -eq [char]0xFEFF) { $cfgText = $cfgText.Substring(1) }
|
||||
$cfgText = $cfgText.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$cfgText = [regex]::Replace($cfgText, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ([System.IO.File]::ReadAllText($ParentXmlPath) -notmatch "`r`n") { "`n" } else { "`r`n" }
|
||||
$cfgText = ($cfgText -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($ParentXmlPath, $cfgText, (New-Object System.Text.UTF8Encoding($true)))
|
||||
|
||||
return "added"
|
||||
}
|
||||
|
||||
# XML tag name for Configuration.xml ChildObjects
|
||||
$childTag = $objType
|
||||
|
||||
if (Test-Path $configXmlPath) {
|
||||
$configDoc = New-Object System.Xml.XmlDocument
|
||||
$configDoc.PreserveWhitespace = $true
|
||||
$configDoc.Load($configXmlPath)
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($configDoc.NameTable)
|
||||
$nsMgr.AddNamespace("md", "http://v8.1c.ru/8.3/MDClasses")
|
||||
|
||||
$childObjects = $configDoc.SelectSingleNode("//md:Configuration/md:ChildObjects", $nsMgr)
|
||||
if ($childObjects) {
|
||||
$existing = $childObjects.SelectNodes("md:$childTag", $nsMgr)
|
||||
$alreadyExists = $false
|
||||
foreach ($e in $existing) {
|
||||
if ($e.InnerText -eq $objName) {
|
||||
$alreadyExists = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($alreadyExists) {
|
||||
$regResult = "already"
|
||||
} else {
|
||||
$newElem = $configDoc.CreateElement($childTag, "http://v8.1c.ru/8.3/MDClasses")
|
||||
$newElem.InnerText = $objName
|
||||
|
||||
if ($existing.Count -gt 0) {
|
||||
# Insert after last existing element of same type
|
||||
$lastElem = $existing[$existing.Count - 1]
|
||||
$newWs = $configDoc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertAfter($newWs, $lastElem) | Out-Null
|
||||
$childObjects.InsertAfter($newElem, $newWs) | Out-Null
|
||||
} else {
|
||||
# No existing elements of this type — insert before closing whitespace
|
||||
$lastChild = $childObjects.LastChild
|
||||
if ($lastChild.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
|
||||
$newWs = $configDoc.CreateWhitespace("`n`t`t`t")
|
||||
$childObjects.InsertBefore($newWs, $lastChild) | Out-Null
|
||||
$childObjects.InsertBefore($newElem, $lastChild) | Out-Null
|
||||
} else {
|
||||
$childObjects.AppendChild($configDoc.CreateWhitespace("`n`t`t`t")) | Out-Null
|
||||
$childObjects.AppendChild($newElem) | Out-Null
|
||||
$childObjects.AppendChild($configDoc.CreateWhitespace("`n`t`t")) | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Save
|
||||
$cfgSettings = New-Object System.Xml.XmlWriterSettings
|
||||
$cfgSettings.Encoding = New-Object System.Text.UTF8Encoding($true)
|
||||
$cfgSettings.Indent = $false
|
||||
$stream = New-Object System.IO.FileStream($configXmlPath, [System.IO.FileMode]::Create)
|
||||
$writer = [System.Xml.XmlWriter]::Create($stream, $cfgSettings)
|
||||
$configDoc.Save($writer)
|
||||
$writer.Close()
|
||||
$stream.Close()
|
||||
|
||||
$regResult = "added"
|
||||
}
|
||||
} else {
|
||||
$regResult = "no-childobj"
|
||||
}
|
||||
} else {
|
||||
$regResult = "no-config"
|
||||
}
|
||||
$configXmlPath = Join-Path $OutputDir "Configuration.xml"
|
||||
$regResult = Register-InChildObjects $configXmlPath "Configuration" $childTag $objName
|
||||
|
||||
# --- 18. Summary ---
|
||||
|
||||
@@ -5059,6 +5364,9 @@ switch ($regResult) {
|
||||
}
|
||||
|
||||
# Cross-reference hints
|
||||
if ($script:stdAttrTailHint) {
|
||||
Write-Host "[HINT] $($script:stdAttrTailHint)"
|
||||
}
|
||||
if ($objType -eq "AccountingRegister" -and -not $def.chartOfAccounts) {
|
||||
Write-Host "[HINT] AccountingRegister requires ChartOfAccounts reference:"
|
||||
Write-Host " /meta-edit -Operation modify-property -Value `"ChartOfAccounts=ChartOfAccounts.XXX`""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# meta-compile v1.76 — Compile 1C metadata object from JSON
|
||||
# meta-compile v1.99 — Compile 1C metadata object from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -16,6 +16,131 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# ============================================================
|
||||
# Регистронезависимый ввод — паритет с PS1. В PowerShell регистр не значим нигде, куда
|
||||
# смотрит пользовательский ввод: свойства объекта из ConvertFrom-Json, ключи Hashtable,
|
||||
# -eq/-contains, имена параметров, ValidateSet. В Python совпадение точное, поэтому порт
|
||||
# молча терял свойства DSL, написанные в другом регистре. Обёртки ниже выравнивают поведение.
|
||||
# ============================================================
|
||||
|
||||
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
|
||||
@@ -206,9 +331,22 @@ def new_uuid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def write_utf8_bom(path, content):
|
||||
# newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
|
||||
# и LF на macOS, то есть вывод навыка зависел бы от ОС.
|
||||
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def write_xml_file_keep_eol(path, content):
|
||||
# Единая точка записи XML. Конфигуратор не пишет перевод строки в конце файла —
|
||||
# последний байт `>`.
|
||||
# keep_eol в имени — отличие от одноимённой функции в скелетных навыках
|
||||
# (cf-init и др.): та ЕЩЁ и нормализует EOL к CRLF, а здесь этого делать
|
||||
# НЕЛЬЗЯ — в объектном XML бывают многострочные текстовые узлы (запрос,
|
||||
# синоним, значение заполнения). Разделители и так CRLF — их даёт join строк.
|
||||
# Модули .bsl сюда НЕ идут: у них свой хвост.
|
||||
write_utf8_bom(path, content.rstrip('\r\n'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# XML builder (lines list)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -289,7 +427,7 @@ def split_camel_case(name):
|
||||
parser = argparse.ArgumentParser(allow_abbrev=False)
|
||||
parser.add_argument('-JsonPath', required=True)
|
||||
parser.add_argument('-OutputDir', required=True)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
json_path = args.JsonPath
|
||||
output_dir = args.OutputDir
|
||||
@@ -298,10 +436,9 @@ if not os.path.isfile(json_path):
|
||||
print(f'File not found: {json_path}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(json_path, 'r', encoding='utf-8-sig') as f:
|
||||
json_text = f.read()
|
||||
json_text = read_json_file(json_path)
|
||||
|
||||
defn = json.loads(json_text)
|
||||
defn = ci_json(parse_json_input(json_text, json_path))
|
||||
|
||||
assert_edit_allowed(output_dir, "editable")
|
||||
|
||||
@@ -395,6 +532,10 @@ enum_value_aliases = {
|
||||
'ИндексироватьСДопУпорядочиванием': 'IndexWithAdditionalOrder',
|
||||
}
|
||||
|
||||
# Словари, по которым ищут ПОЛЬЗОВАТЕЛЬСКИЙ ввод, — регистронезависимы, как хеш-таблицы PS1.
|
||||
object_type_synonyms = CIDict(object_type_synonyms)
|
||||
enum_value_aliases = CIDict(enum_value_aliases)
|
||||
|
||||
# Valid enum values per property (from meta-validate)
|
||||
valid_enum_values = {
|
||||
'RegisterType': ['Balance', 'Turnovers'],
|
||||
@@ -553,6 +694,8 @@ valid_types = [
|
||||
'SessionParameter', 'CommonCommand', 'CommandGroup', 'CommonAttribute', 'FunctionalOptionsParameter', 'WSReference',
|
||||
'CommonPicture', 'CommonTemplate',
|
||||
]
|
||||
# Регистр имени вида — как в PS (-contains регистронезависим): приводим к канону списка
|
||||
obj_type = next((t for t in valid_types if t.lower() == obj_type.lower()), obj_type)
|
||||
if obj_type not in valid_types:
|
||||
print(f"Unsupported type: {obj_type}. Valid: {', '.join(valid_types)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -627,47 +770,103 @@ type_namespace_map = {
|
||||
"SpreadsheetDocument": {"ns": "http://v8.1c.ru/8.2/data/spreadsheet", "prefix": "mxl"},
|
||||
}
|
||||
# Типы current-config пространства (cfg:, объявлено в корне): голые и объектные. Ссылочные — отдельно (d5p1).
|
||||
# Префикс current-config для ссылочных типов. 'cfg' — для файлов, чья шапка его объявляет
|
||||
# (объектный XML, Ext/Form.xml общей формы). None на время сборки Ext/Predefined.xml, чья
|
||||
# шапка его НЕ объявляет: там и платформа уходит на локальное объявление.
|
||||
cfg_prefix = 'cfg'
|
||||
cfg_bare_types = {"ConstantsSet", "ReportBuilder", "FilterCriterion"}
|
||||
cfg_object_kinds = {"Catalog", "Document", "Enum", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||
"ChartOfCalculationTypes", "ExchangePlan", "BusinessProcess", "Task", "InformationRegister",
|
||||
"AccumulationRegister", "AccountingRegister", "CalculationRegister", "DataProcessor", "Report",
|
||||
"DocumentJournal", "Constant", "ConstantValue", "Sequence", "Recalculation"}
|
||||
|
||||
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
type_synonyms = CIDict(type_synonyms)
|
||||
TYPE_SYNONYMS = type_synonyms
|
||||
|
||||
|
||||
def resolve_type_str(type_str):
|
||||
if not type_str:
|
||||
return type_str
|
||||
# Parameterized types: Number(15,2), Строка(100), etc.
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if type_str.startswith('cfg:'):
|
||||
type_str = type_str[4:]
|
||||
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||
type_str = type_str[type_str.index(':') + 1:]
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||
if m:
|
||||
base_name = m.group(1).strip()
|
||||
params = m.group(2)
|
||||
resolved = type_synonyms.get(base_name.lower())
|
||||
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||
if resolved:
|
||||
return f'{resolved}({params})'
|
||||
return type_str
|
||||
# Reference types: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||
if '.' in type_str:
|
||||
dot_idx = type_str.index('.')
|
||||
prefix = type_str[:dot_idx]
|
||||
suffix = type_str[dot_idx:] # includes the dot
|
||||
resolved = type_synonyms.get(prefix.lower())
|
||||
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||
if resolved:
|
||||
return f'{resolved}{suffix}'
|
||||
return type_str
|
||||
# Simple name lookup
|
||||
resolved = type_synonyms.get(type_str.lower())
|
||||
# Простое имя
|
||||
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||
if resolved:
|
||||
return resolved
|
||||
return type_str
|
||||
|
||||
def emit_type_content(indent, type_str):
|
||||
if not type_str:
|
||||
return
|
||||
# Composite type: "Type1 + Type2 + Type3"
|
||||
# Платформа пишет сначала ВСЕ <v8:Type>/<v8:TypeSet>, и только потом блоки
|
||||
# квалификаторов — а рекурсия ниже печатала бы каждую часть целиком (тип вместе со
|
||||
# своими квалификаторами). На одиночном типе оба порядка совпадают, поэтому
|
||||
# расхождение вылезало только на составном.
|
||||
# Порядок самих блоков квалификаторов тоже канонический и НЕ зеркалит порядок типов:
|
||||
# Number, String, Date (корпус acc+erp, контрпримеров нет — при типах
|
||||
# boolean,string,dateTime,decimal квалификаторы идут Number,String,Date).
|
||||
# Порядок типов при этом сохраняем как в DSL: он и есть порядок источника.
|
||||
if ' + ' in type_str:
|
||||
parts = [p.strip() for p in type_str.split('+')]
|
||||
type_lines = []
|
||||
qual_blocks = {}
|
||||
for part in parts:
|
||||
# X добавляет в список lines, поэтому «перехват» — это срез и откат хвоста.
|
||||
# В PS-порту X пишет в StringBuilder и тот же алгоритм выражен через
|
||||
# Length/Remove — различие рантаймов, не логики.
|
||||
before = len(lines)
|
||||
emit_type_content(indent, part)
|
||||
chunk = lines[before:]
|
||||
del lines[before:]
|
||||
cur_qual = None
|
||||
for line in chunk:
|
||||
if not line:
|
||||
continue
|
||||
m = re.search(r'<v8:(String|Number|Date)Qualifiers>', line)
|
||||
if m:
|
||||
cur_qual = m.group(1)
|
||||
qual_blocks[cur_qual] = []
|
||||
if cur_qual:
|
||||
qual_blocks[cur_qual].append(line)
|
||||
if re.search(r'</v8:(String|Number|Date)Qualifiers>', line):
|
||||
cur_qual = None
|
||||
else:
|
||||
type_lines.append(line)
|
||||
for line in type_lines:
|
||||
X(line)
|
||||
for q in ('Number', 'String', 'Date'):
|
||||
if q in qual_blocks:
|
||||
for line in qual_blocks[q]:
|
||||
X(line)
|
||||
return
|
||||
type_str = resolve_type_str(type_str)
|
||||
# Boolean
|
||||
@@ -760,10 +959,22 @@ def emit_type_content(indent, type_str):
|
||||
X(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
|
||||
return
|
||||
|
||||
# Reference types — use local xmlns declaration for 1C compatibility
|
||||
# Ссылочные типы — корневой cfg:, как пишет платформа. Раньше здесь объявлялся
|
||||
# ЛОКАЛЬНЫЙ xmlns:d5p1 на тот же URI, что уже объявлен в шапке: формально
|
||||
# эквивалентно (значим URI, не префикс) и платформой принималось, но первый же
|
||||
# цикл «загрузить в базу → выгрузить» переписывал каждый ссылочный тип в cfg: —
|
||||
# то есть давал diff-шум на ровном месте. Форма пришла из СКД, где cfg:
|
||||
# действительно не работает; в метаданных такого ограничения нет.
|
||||
# NB: локальная xmlns остаётся законной для ЧУЖИХ пространств — см. type_namespace_map.
|
||||
# cfg_prefix = None означает «пишем файл, корень которого cfg НЕ объявляет»
|
||||
# (Ext/Predefined.xml — его шапка это predef/v8/xr/xs/xsi). Там платформа сама уходит
|
||||
# на локальную форму: в корпусе `<v8:Type xmlns:d6p1="…current-config">d6p1:CatalogRef.Валюты`.
|
||||
m = re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|BusinessProcessRoutePointRef|TaskRef)\.(.+)$', type_str)
|
||||
if m:
|
||||
X(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
||||
if cfg_prefix:
|
||||
X(f'{indent}<v8:Type>{cfg_prefix}:{type_str}</v8:Type>')
|
||||
else:
|
||||
X(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
||||
return
|
||||
# Fallback
|
||||
X(f'{indent}<v8:Type>{type_str}</v8:Type>')
|
||||
@@ -1270,15 +1481,24 @@ standard_attributes_by_type = {
|
||||
'Document': ['Posted', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||
'Enum': ['Order', 'Ref'],
|
||||
'InformationRegister': ['Active', 'LineNumber', 'Recorder', 'Period'],
|
||||
'AccumulationRegister': ['Active', 'LineNumber', 'Recorder', 'Period'],
|
||||
'AccountingRegister': ['Active', 'Period', 'Recorder', 'LineNumber', 'Account'],
|
||||
'CalculationRegister': ['Active', 'Recorder', 'LineNumber', 'RegistrationPeriod', 'CalculationType', 'ReversingEntry'],
|
||||
'AccumulationRegister': ['RecordType', 'Active', 'LineNumber', 'Recorder', 'Period'],
|
||||
'AccountingRegister': ['PeriodAdjustment', 'Account', 'RecordType', 'Active', 'LineNumber', 'Recorder', 'Period'],
|
||||
'CalculationRegister': ['RegistrationPeriod', 'ReversingEntry', 'Active', 'EndOfBasePeriod', 'BegOfBasePeriod', 'EndOfActionPeriod', 'BegOfActionPeriod', 'ActionPeriod', 'CalculationType', 'LineNumber', 'Recorder'],
|
||||
'ChartOfAccounts': ['PredefinedDataName', 'Order', 'OffBalance', 'Type', 'Description', 'Code', 'Parent', 'Predefined', 'DeletionMark', 'Ref'],
|
||||
'ChartOfCharacteristicTypes': ['PredefinedDataName', 'Predefined', 'Ref', 'DeletionMark', 'Description', 'Code', 'Parent', 'ValueType'],
|
||||
'ChartOfCharacteristicTypes': ['PredefinedDataName', 'ValueType', 'Description', 'Code', 'IsFolder', 'Parent', 'Predefined', 'DeletionMark', 'Ref'],
|
||||
'ChartOfCalculationTypes': ['PredefinedDataName', 'Predefined', 'Ref', 'DeletionMark', 'ActionPeriodIsBasic', 'Description', 'Code'],
|
||||
'BusinessProcess': ['Ref', 'DeletionMark', 'Date', 'Number', 'Started', 'Completed', 'HeadTask'],
|
||||
'Task': ['Ref', 'DeletionMark', 'Date', 'Number', 'Executed', 'Description', 'RoutePoint', 'BusinessProcess'],
|
||||
'ExchangePlan': ['Ref', 'DeletionMark', 'Code', 'Description', 'ThisNode', 'SentNo', 'ReceivedNo'],
|
||||
# Порядок в каждом списке — канон выгрузки, снят с корпуса acc+erp (внутри типа разброса нет).
|
||||
# Условные члены перечислены в std_attr_conditions — позицию они берут отсюда, а
|
||||
# присутствие определяется свойствами объекта.
|
||||
# У ПВХ IsFolder входит в фикс-список: он есть у всех 23 объектов корпуса с этим блоком.
|
||||
# У регистра расчёта список безусловен: реквизиты периода действия и базового периода
|
||||
# платформа пишет при любых ActionPeriod/BasePeriod/Periodicity (синтетика, все 4 комбинации).
|
||||
'BusinessProcess': ['Started', 'HeadTask', 'Completed', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||
'Task': ['Executed', 'Description', 'RoutePoint', 'BusinessProcess', 'Ref', 'DeletionMark', 'Date', 'Number'],
|
||||
# Порядок снят с выгрузки: у плана обмена блок начинается с ThisNode, а не с Ref
|
||||
# (acc+erp, 8 объектов, разброса нет). Прочие типы в этой таблице совпадают с
|
||||
# платформой — расхождений порядка по ним корпусный раундтрип не показал.
|
||||
'ExchangePlan': ['ThisNode', 'ReceivedNo', 'SentNo', 'Ref', 'DeletionMark', 'Description', 'Code'],
|
||||
'DocumentJournal': ['Type', 'Ref', 'Date', 'Posted', 'DeletionMark', 'Number'],
|
||||
}
|
||||
|
||||
@@ -1398,6 +1618,64 @@ def emit_standard_attribute(indent, attr_name, ov=None):
|
||||
# std_attr_conditional_types: типы, где блок только при кастомизации (DSL-ключ standardAttributes).
|
||||
# Прочие типы → блок всегда (текущее поведение). Миграция типа = +строчка в оба справочника + снэпшоты.
|
||||
std_attr_conditional_types = {'Catalog', 'ExchangePlan', 'ChartOfCharacteristicTypes', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'Document'}
|
||||
|
||||
# Условные члены списка типа: позиция берётся из standard_attributes_by_type, а присутствие —
|
||||
# из свойств самого объекта, как у платформы. Предикат принимает определение параметром
|
||||
# (зеркало .ps1, где scriptblock не видит $def вызывающей функции).
|
||||
def _period_adjustment_used(d):
|
||||
v = d.get('periodAdjustmentLength')
|
||||
return v is not None and int(str(v)) > 0
|
||||
|
||||
std_attr_conditions = {
|
||||
'AccountingRegister': {
|
||||
'PeriodAdjustment': _period_adjustment_used,
|
||||
'RecordType': lambda d: d.get('correspondence') is not True,
|
||||
},
|
||||
'AccumulationRegister': {
|
||||
'RecordType': lambda d: normalize_enum_value('RegisterType', str(d.get('registerType') or 'Balance')) == 'Balance',
|
||||
},
|
||||
}
|
||||
|
||||
# Хвостовая группа: реквизиты, которых нет в списке типа и которые идут ПОСЛЕ него.
|
||||
# У бухрегистра это пары субконто. Именами их не перечислить: их количество задаётся
|
||||
# свойством MaxExtDimensionCount плана счетов, а не константой (в корпусе везде 3, но
|
||||
# это однородность выборки, а не правило). Поэтому — шаблон, а не список.
|
||||
std_attr_tail_pattern = {
|
||||
'AccountingRegister': r'^ExtDimension(Type)?\d+$',
|
||||
}
|
||||
|
||||
# Состав хвоста задаёт не DSL, а объект, на который регистр ссылается: пар субконто столько,
|
||||
# сколько у плана счетов MaxExtDimensionCount. Читаем его из выгрузки — как версию формата из
|
||||
# Configuration.xml, — чтобы регистр, описанный неполным DSL, совпал с тем, что материализует
|
||||
# платформа. План не найден → хвост не генерируем и говорим об этом в выводе.
|
||||
std_attr_tail_hint = None
|
||||
|
||||
def _acc_register_ext_dimension_tail(d, object_name, out_dir):
|
||||
global std_attr_tail_hint
|
||||
ref = str(d.get('chartOfAccounts') or '')
|
||||
if not ref:
|
||||
return []
|
||||
chart_name = re.sub(r'^.*\.', '', ref) # ссылка вида ChartOfAccounts.X (имя объекта точек не содержит)
|
||||
path = os.path.join(out_dir, 'ChartsOfAccounts', chart_name + '.xml')
|
||||
if not os.path.isfile(path):
|
||||
std_attr_tail_hint = ("ChartOfAccounts '%s' not found in dump — ExtDimension pairs not generated "
|
||||
"(platform will add them on load)" % chart_name)
|
||||
return []
|
||||
with open(path, encoding='utf-8-sig') as f:
|
||||
m = re.search(r'<MaxExtDimensionCount>(\d+)</MaxExtDimensionCount>', f.read())
|
||||
n = int(m.group(1)) if m else 0
|
||||
out = []
|
||||
for i in range(1, n + 1):
|
||||
# ExtDimensionN связан с Account через LinkByType (LinkItem = номер), ExtDimensionTypeN — нет.
|
||||
out.append(('ExtDimension%d' % i,
|
||||
{'LinkByType': {'dataPath': 'AccountingRegister.%s.StandardAttribute.Account' % object_name,
|
||||
'linkItem': i}}))
|
||||
out.append(('ExtDimensionType%d' % i, {}))
|
||||
return out
|
||||
|
||||
std_attr_tail_derived = {
|
||||
'AccountingRegister': _acc_register_ext_dimension_tail,
|
||||
}
|
||||
def emit_standard_attributes(indent, object_type):
|
||||
attrs = standard_attributes_by_type.get(object_type)
|
||||
if not attrs:
|
||||
@@ -1409,12 +1687,45 @@ def emit_standard_attributes(indent, object_type):
|
||||
if isinstance(sa, str) and sa == '':
|
||||
return # opt-out `standardAttributes:""` (дом-конвенция суппресса, ~5% регистров опускают all-default блок)
|
||||
profile = std_attr_profile.get(object_type, {})
|
||||
# Доп. (опциональные) стандартные реквизиты вне фикс-списка — напр. ExchangeDate у части ПланОбмена
|
||||
# (легаси, присутствие не выводится). Эмитим по факту ключа в DSL, ПЕРЕД фикс-списком (их позиция).
|
||||
extra = [k for k in sa if k not in attrs] if isinstance(sa, dict) else []
|
||||
# Список типа задаёт ПОРЯДОК всех известных стандартных реквизитов, включая условные:
|
||||
# их позиция бывает и до, и после обязательных (у бухрегистра PeriodAdjustment идёт
|
||||
# перед Account, RecordType — после, а ExtDimension1..3/ExtDimensionType1..3 — после Period),
|
||||
# поэтому «условные скопом вперёд» не выражает канон.
|
||||
cond = std_attr_conditions.get(object_type)
|
||||
# Ключи, которых нет в списке типа ВООБЩЕ. По умолчанию их позиция — ПЕРЕД списком
|
||||
# (легаси вроде ExchangeDate у части планов обмена). Подходящие под хвостовой шаблон
|
||||
# типа идут ПОСЛЕ, в порядке номера, а внутри номера — сначала ExtDimensionN, затем
|
||||
# ExtDimensionTypeN (порядок платформы).
|
||||
tail_re = std_attr_tail_pattern.get(object_type)
|
||||
extra, tail = [], []
|
||||
if isinstance(sa, dict):
|
||||
for k in sa:
|
||||
if k in attrs:
|
||||
continue
|
||||
if tail_re and re.match(tail_re, k):
|
||||
tail.append(k)
|
||||
else:
|
||||
extra.append(k)
|
||||
# Хвост, выведенный из связанного объекта: дополняет DSL, а не заменяет его — лишнее из DSL
|
||||
# остаётся (прощаем), недостающее добавляется вместе со своими значениями по умолчанию.
|
||||
derived_ov = {}
|
||||
gen = std_attr_tail_derived.get(object_type)
|
||||
if gen:
|
||||
for name, dov in gen(defn, obj_name, output_dir):
|
||||
derived_ov[name] = dov
|
||||
if name not in tail:
|
||||
tail.append(name)
|
||||
tail.sort(key=lambda k: (int(re.search(r'\d+', k).group()), 1 if re.search(r'Type\d+$', k) else 0))
|
||||
X(f'{indent}<StandardAttributes>')
|
||||
for a in extra + list(attrs):
|
||||
for a in extra + list(attrs) + tail:
|
||||
# Условный реквизит: эмитим, если так велят свойства объекта ЛИБО если ключ есть в DSL.
|
||||
# Дизъюнкция страхует роундтрип — декомпилятор перечисляет все имена блока.
|
||||
if cond and a in cond:
|
||||
present = (isinstance(sa, dict) and a in sa) or cond[a](defn)
|
||||
if not present:
|
||||
continue
|
||||
ov = dict(profile.get(a, {}))
|
||||
ov.update(derived_ov.get(a, {}))
|
||||
if isinstance(sa, dict):
|
||||
d = sa.get(a)
|
||||
if d:
|
||||
@@ -2016,8 +2327,13 @@ def emit_attribute(indent, parsed, context, elem_tag='Attribute'):
|
||||
X(f'{indent}\t\t<ExtDimensionAccountingFlag>{esc_xml_text(str(parsed["extDimensionAccountingFlag"]))}</ExtDimensionAccountingFlag>')
|
||||
else:
|
||||
X(f'{indent}\t\t<ExtDimensionAccountingFlag/>')
|
||||
# Use — у реквизитов справочника и ПВХ. Позиция РАЗНАЯ: справочник пишет Use ПЕРЕД
|
||||
# Indexing, ПВХ — ПОСЛЕ него (корпус acc+erp: Catalog `Use,Indexing,FullTextSearch`,
|
||||
# ПВХ `Indexing,Use,FullTextSearch,DataHistory`). Отсюда отдельный контекст 'cct':
|
||||
# структурно реквизит ПВХ совпадает со справочником, расходится только этим порядком.
|
||||
use_value = parsed.get("use") or "ForItem"
|
||||
if context == 'catalog':
|
||||
X(f'{indent}\t\t<Use>{parsed.get("use") or "ForItem"}</Use>')
|
||||
X(f'{indent}\t\t<Use>{use_value}</Use>')
|
||||
if context not in ('processor', 'processor-tabular'):
|
||||
# Признаки учёта ПС (account-flag) не имеют <Indexing>/<FullTextSearch>, но имеют <DataHistory>.
|
||||
if context != 'account-flag':
|
||||
@@ -2031,6 +2347,8 @@ def emit_attribute(indent, parsed, context, elem_tag='Attribute'):
|
||||
if parsed.get('indexing'):
|
||||
indexing = parsed['indexing']
|
||||
X(f'{indent}\t\t<Indexing>{indexing}</Indexing>')
|
||||
if context == 'cct':
|
||||
X(f'{indent}\t\t<Use>{use_value}</Use>')
|
||||
# Реквизит адресации задачи: AddressingDimension (между Indexing и FullTextSearch).
|
||||
if context == 'task-addressing' and elem_tag == 'AddressingAttribute':
|
||||
if parsed.get('addressingDimension'):
|
||||
@@ -2187,6 +2505,10 @@ def emit_enum_value(indent, parsed):
|
||||
X(f'{indent}\t\t<Comment>{esc_xml_text(parsed["comment"])}</Comment>')
|
||||
else:
|
||||
X(f'{indent}\t\t<Comment/>')
|
||||
# Цвет значения перечисления — свойство формата 2.21 (8.5), последним в Properties.
|
||||
if is_format_221:
|
||||
color = str(parsed['color']) if parsed.get('color') else 'auto'
|
||||
X(f'{indent}\t\t<Color>{esc_xml_text(color)}</Color>')
|
||||
X(f'{indent}\t</Properties>')
|
||||
X(f'{indent}</EnumValue>')
|
||||
|
||||
@@ -2811,6 +3133,12 @@ def emit_common_form_properties(indent):
|
||||
X(f'{i}</UsePurposes>')
|
||||
else:
|
||||
X(f'{i}<UsePurposes/>')
|
||||
# Использование в режиме совместимости интерфейса — свойство формата 2.21 (8.5),
|
||||
# между UsePurposes и UseStandardCommands.
|
||||
if is_format_221:
|
||||
X(f'{i}<UseInInterfaceCompatibilityMode>'
|
||||
f'{get_enum_prop("UseInInterfaceCompatibilityMode", "useInInterfaceCompatibilityMode", "Any")}'
|
||||
f'</UseInInterfaceCompatibilityMode>')
|
||||
use_std_cmds = 'true' if get_bool_prop('useStandardCommands', False) else 'false'
|
||||
X(f'{i}<UseStandardCommands>{use_std_cmds}</UseStandardCommands>')
|
||||
emit_mltext(i, 'ExtendedPresentation', defn.get('extendedPresentation'))
|
||||
@@ -3069,7 +3397,12 @@ def emit_scheduled_job_properties(indent):
|
||||
else:
|
||||
X(f'{i}<Description/>')
|
||||
key = str(defn['key']) if defn.get('key') else ''
|
||||
X(f'{i}<Key>{esc_xml_text(key)}</Key>')
|
||||
# Пустое значение → самозакрывающийся, как у <Description> выше: Конфигуратор
|
||||
# не пишет пустых пар.
|
||||
if key:
|
||||
X(f'{i}<Key>{esc_xml_text(key)}</Key>')
|
||||
else:
|
||||
X(f'{i}<Key/>')
|
||||
use = 'true' if defn.get('use') is True else 'false'
|
||||
X(f'{i}<Use>{use}</Use>')
|
||||
predefined = 'true' if defn.get('predefined') is True else 'false'
|
||||
@@ -3123,6 +3456,9 @@ def emit_report_properties(indent):
|
||||
emit_verbatim_ref(i, 'DefaultSettingsForm', defn.get('defaultSettingsForm'))
|
||||
emit_verbatim_ref(i, 'AuxiliarySettingsForm', defn.get('auxiliarySettingsForm'))
|
||||
emit_verbatim_ref(i, 'DefaultVariantForm', defn.get('defaultVariantForm'))
|
||||
# Вспомогательная форма варианта отчёта — свойство формата 2.21 (8.5).
|
||||
if is_format_221:
|
||||
emit_verbatim_ref(i, 'AuxiliaryVariantForm', defn.get('auxiliaryVariantForm'))
|
||||
emit_verbatim_ref(i, 'VariantsStorage', defn.get('variantsStorage'))
|
||||
emit_verbatim_ref(i, 'SettingsStorage', defn.get('settingsStorage'))
|
||||
incl_help = 'true' if get_bool_prop('includeHelpInContents', False) else 'false'
|
||||
@@ -3760,7 +4096,8 @@ def emit_web_service_properties(indent):
|
||||
emit_mltext(i, 'Synonym', synonym)
|
||||
X(f'{i}<Comment>{esc_xml_text(str(defn["comment"]))}</Comment>' if defn.get('comment') else f'{i}<Comment/>')
|
||||
namespace = str(defn['namespace']) if defn.get('namespace') else ''
|
||||
X(f'{i}<Namespace>{esc_xml_text(namespace)}</Namespace>')
|
||||
# Пустое значение → самозакрывающийся, как у <Comment> выше.
|
||||
X(f'{i}<Namespace>{esc_xml_text(namespace)}</Namespace>' if namespace else f'{i}<Namespace/>')
|
||||
# XDTOPackages — СПИСОК элементов: ссылка на пакет конфигурации (xr:MDObjectRef) либо URI
|
||||
# внешнего пространства имён (xs:string). Presentation пуст, CheckState 0 (корпус: 19/19).
|
||||
pkgs = defn.get('xdtoPackages') or []
|
||||
@@ -3982,6 +4319,16 @@ xmlns_decl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8
|
||||
|
||||
def detect_format_version(d):
|
||||
while d:
|
||||
# Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
|
||||
# корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
|
||||
ext_path = d + ".xml"
|
||||
if os.path.isfile(ext_path):
|
||||
with open(ext_path, "r", encoding="utf-8-sig") as f:
|
||||
ext_head = f.read(2000)
|
||||
if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
|
||||
m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
|
||||
if m:
|
||||
return m.group(1)
|
||||
cfg_path = os.path.join(d, "Configuration.xml")
|
||||
if os.path.isfile(cfg_path):
|
||||
with open(cfg_path, "r", encoding="utf-8-sig") as f:
|
||||
@@ -4034,6 +4381,15 @@ compat_mode = detect_compatibility_mode(output_dir)
|
||||
# глушил бы TypeReductionMode, который платформа этих версий пишет, и роундтрип бы разъезжался.
|
||||
is_format_218 = format_rank(format_version) >= 218
|
||||
is_format_220 = format_rank(format_version) >= 220
|
||||
is_format_221 = format_rank(format_version) >= 221
|
||||
# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
|
||||
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту.
|
||||
# Только для шапок MetaDataObject и Form — в файлах с корнем extrnprops
|
||||
# (Ext/ClientApplicationInterface.xml и т.п.) платформа его не пишет.
|
||||
if is_format_221:
|
||||
xmlns_decl = xmlns_decl.replace(
|
||||
' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
# Дефолт длины номера строки ТЧ: с режима 8.3.27 платформа заводит новые ТЧ с 9 разрядами.
|
||||
line_number_length_default = 9 if compat_mode_rank(compat_mode) >= 80327 else 5
|
||||
|
||||
@@ -4174,7 +4530,7 @@ if obj_type in types_with_attr_ts:
|
||||
elif obj_type in ('DataProcessor', 'Report'):
|
||||
context = 'processor'
|
||||
elif obj_type == 'ChartOfCharacteristicTypes':
|
||||
context = 'catalog' # реквизиты ПВХ структурно как у справочника (Use/FillFromFillingValue/DataHistory)
|
||||
context = 'cct' # как catalog (Use/FillFromFillingValue/DataHistory), но Use ПОСЛЕ Indexing
|
||||
elif obj_type in ('ChartOfAccounts', 'ChartOfCalculationTypes'):
|
||||
context = 'account' # как catalog, но БЕЗ <Use> (реквизиты ПС/ПВР не иерархичны как справочник)
|
||||
else:
|
||||
@@ -4245,18 +4601,27 @@ if obj_type in ('InformationRegister', 'AccumulationRegister', 'AccountingRegist
|
||||
# Все семейства регистров: ресурсы/измерения — через богатый emit_attribute (общий слой object-свойств).
|
||||
dim_res_ctx = {'InformationRegister': 'register-info', 'AccumulationRegister': 'register-accum',
|
||||
'CalculationRegister': 'register-calc', 'AccountingRegister': 'register-account'}.get(obj_type)
|
||||
for r in resources:
|
||||
if dim_res_ctx:
|
||||
emit_attribute('\t\t\t', r, dim_res_ctx, 'Resource')
|
||||
# Порядок видов детей — канон выгрузки, снят с корпуса (acc+erp, разброса внутри
|
||||
# типа нет): у большинства регистров Resource, Attribute, Dimension, а у
|
||||
# бухгалтерского — Dimension, Resource, Attribute. Команды у платформы идут
|
||||
# последними, как и здесь.
|
||||
kind_order = ['dim', 'res', 'attr'] if obj_type == 'AccountingRegister' else ['res', 'attr', 'dim']
|
||||
for kind in kind_order:
|
||||
if kind == 'res':
|
||||
for r in resources:
|
||||
if dim_res_ctx:
|
||||
emit_attribute('\t\t\t', r, dim_res_ctx, 'Resource')
|
||||
else:
|
||||
emit_resource('\t\t\t', r, obj_type)
|
||||
elif kind == 'dim':
|
||||
for d in dims:
|
||||
if dim_res_ctx:
|
||||
emit_attribute('\t\t\t', d, dim_res_ctx, 'Dimension')
|
||||
else:
|
||||
emit_dimension('\t\t\t', d, obj_type)
|
||||
else:
|
||||
emit_resource('\t\t\t', r, obj_type)
|
||||
for d in dims:
|
||||
if dim_res_ctx:
|
||||
emit_attribute('\t\t\t', d, dim_res_ctx, 'Dimension')
|
||||
else:
|
||||
emit_dimension('\t\t\t', d, obj_type)
|
||||
for a in reg_attrs:
|
||||
emit_attribute('\t\t\t', a, reg_ctx)
|
||||
for a in reg_attrs:
|
||||
emit_attribute('\t\t\t', a, reg_ctx)
|
||||
for cmd in reg_commands:
|
||||
emit_command('\t\t\t', cmd['name'], cmd['def'])
|
||||
X('\t\t</ChildObjects>')
|
||||
@@ -4360,7 +4725,7 @@ if obj_type == 'WebService':
|
||||
X(f'\t</{obj_type}>')
|
||||
X('</MetaDataObject>')
|
||||
|
||||
metadata_xml = '\n'.join(lines) + '\n'
|
||||
metadata_xml = '\r\n'.join(lines)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 16. Write files
|
||||
@@ -4422,7 +4787,7 @@ os.makedirs(type_dir, exist_ok=True)
|
||||
if obj_type not in types_no_sub_dir:
|
||||
os.makedirs(obj_sub_dir, exist_ok=True)
|
||||
|
||||
write_utf8_bom(main_xml_path, metadata_xml)
|
||||
write_xml_file_keep_eol(main_xml_path, metadata_xml)
|
||||
|
||||
# Module files
|
||||
modules_created = []
|
||||
@@ -4498,10 +4863,14 @@ if obj_type == 'CommonForm':
|
||||
'xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" '
|
||||
'xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"')
|
||||
cf_form_xml = ('<?xml version="1.0" encoding="UTF-8"?>\n<Form ' + cf_ns + ' version="' + format_version + '">\n'
|
||||
'\t<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">\n\t\t<Autofill>true</Autofill>\n\t</AutoCommandBar>\n'
|
||||
'\t<ChildItems/>\n</Form>\n')
|
||||
write_utf8_bom(cf_form_xml_path, cf_form_xml)
|
||||
# Шапка Form на 2.21 тоже несёт палитру — см. комментарий у xmlns_decl.
|
||||
if is_format_221:
|
||||
cf_ns = cf_ns.replace(' xmlns:style=',
|
||||
' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')
|
||||
cf_form_xml = ('<?xml version="1.0" encoding="UTF-8"?>\r\n<Form ' + cf_ns + ' version="' + format_version + '">\r\n'
|
||||
'\t<AutoCommandBar name="ФормаКоманднаяПанель" id="-1">\r\n\t\t<Autofill>true</Autofill>\r\n\t</AutoCommandBar>\r\n'
|
||||
'\t<ChildItems/>\r\n</Form>\r\n')
|
||||
write_xml_file_keep_eol(cf_form_xml_path, cf_form_xml)
|
||||
modules_created.append(cf_form_xml_path)
|
||||
cf_module_dir = os.path.join(ext_dir, 'Form')
|
||||
os.makedirs(cf_module_dir, exist_ok=True)
|
||||
@@ -4589,10 +4958,18 @@ def emit_predef_item(out, val, indent, code_type):
|
||||
def build_predefined_xml(items, xsi_type, code_type):
|
||||
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
||||
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="{xsi_type}" version="{format_version}">')
|
||||
for it in items:
|
||||
emit_predef_item(out, it, '\t', code_type)
|
||||
# Шапка Predefined.xml не объявляет cfg (predef/v8/xr/xs/xsi) — на время сборки этого
|
||||
# файла ссылочный тип уходит на локальную форму, как делает и платформа.
|
||||
global cfg_prefix
|
||||
saved_cfg_prefix = cfg_prefix
|
||||
cfg_prefix = None
|
||||
try:
|
||||
for it in items:
|
||||
emit_predef_item(out, it, '\t', code_type)
|
||||
finally:
|
||||
cfg_prefix = saved_cfg_prefix
|
||||
out.append('</PredefinedData>')
|
||||
return '\n'.join(out) + '\n'
|
||||
return '\r\n'.join(out)
|
||||
|
||||
# --- Предопределённые СЧЕТА Плана счетов (отдельная грамматика: AccountType/OffBalance/Order/AccountingFlags/
|
||||
# ExtDimensionTypes/ChildItems). Флаги перечисляем по def-порядку признаков плана; в DSL — только TRUE. ---
|
||||
@@ -4687,10 +5064,17 @@ def emit_predef_account(out, val, indent, obj_nm, acct_flag_names, ext_dim_flag_
|
||||
def build_predefined_account_xml(items, obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref=''):
|
||||
out = ['<?xml version="1.0" encoding="UTF-8"?>']
|
||||
out.append(f'<PredefinedData xmlns="http://v8.1c.ru/8.3/xcf/predef" xmlns:v8="http://v8.1c.ru/8.1/data/core" 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" xsi:type="ChartOfAccountsPredefinedItems" version="{format_version}">')
|
||||
for it in items:
|
||||
emit_predef_account(out, it, '\t', obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref)
|
||||
# См. build_predefined_xml: шапка этого файла cfg не объявляет.
|
||||
global cfg_prefix
|
||||
saved_cfg_prefix = cfg_prefix
|
||||
cfg_prefix = None
|
||||
try:
|
||||
for it in items:
|
||||
emit_predef_account(out, it, '\t', obj_nm, acct_flag_names, ext_dim_flag_names, ext_dim_types_ref)
|
||||
finally:
|
||||
cfg_prefix = saved_cfg_prefix
|
||||
out.append('</PredefinedData>')
|
||||
return '\n'.join(out) + '\n'
|
||||
return '\r\n'.join(out)
|
||||
|
||||
# Предопределённые ВИДЫ РАСЧЁТА (плоские: Name/Code/Description/ActionPeriodIsBase).
|
||||
def emit_predef_calc_type(out, val, indent):
|
||||
@@ -4712,7 +5096,7 @@ def build_predefined_calc_type_xml(items):
|
||||
for it in items:
|
||||
emit_predef_calc_type(out, it, '\t')
|
||||
out.append('</PredefinedData>')
|
||||
return '\n'.join(out) + '\n'
|
||||
return '\r\n'.join(out)
|
||||
|
||||
# Special files
|
||||
# --- Состав плана обмена (ExchangePlan, Ext/Content.xml). Ключ `content`/`Состав`:
|
||||
@@ -4766,7 +5150,7 @@ if obj_type == 'ExchangePlan':
|
||||
parts.append(f'\t\t<AutoRecord>{it["autoRecord"]}</AutoRecord>\r\n')
|
||||
parts.append('\t</Item>\r\n')
|
||||
parts.append('</ExchangePlanContent>\r\n')
|
||||
write_utf8_bom(content_path, ''.join(parts))
|
||||
write_xml_file_keep_eol(content_path, ''.join(parts))
|
||||
modules_created.append(content_path)
|
||||
elif not os.path.isfile(content_path):
|
||||
# При пустом составе платформа всё равно пишет пустой <ExchangePlanContent/> — проверено на
|
||||
@@ -4774,7 +5158,7 @@ if obj_type == 'ExchangePlan':
|
||||
# (Единственный план обмена БЕЗ файла найден в УТ — хвостовая аномалия конфигурации, не правило.)
|
||||
ensure_ext_dir()
|
||||
content_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<ExchangePlanContent {xep_ns} version="{format_version}"/>\r\n'
|
||||
write_utf8_bom(content_path, content_xml)
|
||||
write_xml_file_keep_eol(content_path, content_xml)
|
||||
modules_created.append(content_path)
|
||||
|
||||
if obj_type == 'BusinessProcess':
|
||||
@@ -4782,7 +5166,7 @@ if obj_type == 'BusinessProcess':
|
||||
if not os.path.isfile(flowchart_path):
|
||||
ensure_ext_dir()
|
||||
flowchart_xml = f'<?xml version="1.0" encoding="UTF-8"?>\r\n<Flowchart xmlns="http://v8.1c.ru/8.3/MDClasses" version="{format_version}"/>\r\n'
|
||||
write_utf8_bom(flowchart_path, flowchart_xml)
|
||||
write_xml_file_keep_eol(flowchart_path, flowchart_xml)
|
||||
modules_created.append(flowchart_path)
|
||||
|
||||
# Предопределённые элементы (Ext/Predefined.xml). Root-элемент по типу.
|
||||
@@ -4795,20 +5179,20 @@ if obj_type == 'ChartOfAccounts' and defn.get('predefined'):
|
||||
edt_ref = resolve_type_prefix_syn(str(defn['extDimensionTypes'])) if defn.get('extDimensionTypes') else ''
|
||||
predef_xml = build_predefined_account_xml(defn['predefined'], obj_name, af_names, edf_names, edt_ref)
|
||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||
write_utf8_bom(predef_path, predef_xml)
|
||||
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||
modules_created.append(predef_path)
|
||||
elif obj_type == 'ChartOfCalculationTypes' and defn.get('predefined'):
|
||||
ensure_ext_dir()
|
||||
predef_xml = build_predefined_calc_type_xml(defn['predefined'])
|
||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||
write_utf8_bom(predef_path, predef_xml)
|
||||
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||
modules_created.append(predef_path)
|
||||
elif obj_type in predef_root_by_type and defn.get('predefined'):
|
||||
ensure_ext_dir()
|
||||
cat_code_type = str(defn['codeType']) if defn.get('codeType') else 'String'
|
||||
predef_xml = build_predefined_xml(defn['predefined'], predef_root_by_type[obj_type], cat_code_type)
|
||||
predef_path = os.path.join(ext_dir, 'Predefined.xml')
|
||||
write_utf8_bom(predef_path, predef_xml)
|
||||
write_xml_file_keep_eol(predef_path, predef_xml)
|
||||
modules_created.append(predef_path)
|
||||
|
||||
# Модули команд (Commands/<Имя>/Ext/CommandModule.bsl) — заготовка обработчика.
|
||||
@@ -4825,78 +5209,82 @@ if commands:
|
||||
# 17. Register in Configuration.xml
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
config_xml_path = os.path.join(output_dir, 'Configuration.xml')
|
||||
reg_result = None
|
||||
def register_in_childobjects(parent_xml_path, parent_tag, child_tag, child_name):
|
||||
"""Регистрация объекта в <ChildObjects> родительского XML.
|
||||
|
||||
child_tag = obj_type
|
||||
Общая реализация: эталон — meta-compile, копия — role-compile.
|
||||
Реестр семьи: tests/skills/check-inline-drift.mjs.
|
||||
Возвращает исход: added | already | no-childobj | no-config.
|
||||
"""
|
||||
if not os.path.isfile(parent_xml_path):
|
||||
return 'no-config'
|
||||
|
||||
if os.path.isfile(config_xml_path):
|
||||
# Read raw content, preserving BOM/EOL byte-for-byte (newline='' => no translation).
|
||||
with open(config_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
# Read raw content, preserving BOM/EOL byte-for-byte (newline='' => no translation)
|
||||
with open(parent_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
config_content = f.read()
|
||||
|
||||
ns = 'http://v8.1c.ru/8.3/MDClasses'
|
||||
# ET is used ONLY read-only here: to locate ChildObjects and detect a duplicate.
|
||||
# We deliberately do NOT re-serialize Configuration.xml with ElementTree.write():
|
||||
# it drops every xmlns declaration used only inside attribute VALUES (e.g.
|
||||
# xsi:type="app:ApplicationUsePurpose" in UsePurposes) because ET never sees those
|
||||
# xsi:type="app:ApplicationUsePurpose" in UsePurposes) because ET never sees such
|
||||
# prefixes in element/attribute names. The dropped declaration makes XDTO read the
|
||||
# value as anyType and Designer refuses to load the file (issue #38). Registration is
|
||||
# therefore done by raw-text insertion, preserving BOM, EOL and all namespaces
|
||||
# byte-for-byte (same approach as subsystem-compile).
|
||||
tree = ET.parse(config_xml_path)
|
||||
tree = ET.parse(parent_xml_path)
|
||||
root = tree.getroot()
|
||||
|
||||
child_objects = root.find(f'{{{ns}}}Configuration/{{{ns}}}ChildObjects')
|
||||
child_objects = root.find(f'{{{ns}}}{parent_tag}/{{{ns}}}ChildObjects')
|
||||
if child_objects is None:
|
||||
# Try direct path
|
||||
config_elem = root.find(f'{{{ns}}}Configuration')
|
||||
if config_elem is not None:
|
||||
child_objects = config_elem.find(f'{{{ns}}}ChildObjects')
|
||||
parent_elem = root.find(f'{{{ns}}}{parent_tag}')
|
||||
if parent_elem is not None:
|
||||
child_objects = parent_elem.find(f'{{{ns}}}ChildObjects')
|
||||
|
||||
if child_objects is None:
|
||||
reg_result = 'no-childobj'
|
||||
return 'no-childobj'
|
||||
|
||||
existing = child_objects.findall(f'{{{ns}}}{child_tag}')
|
||||
if any((e.text or '').strip() == child_name for e in existing):
|
||||
return 'already'
|
||||
|
||||
eol = '\r\n' if '\r\n' in config_content else '\n'
|
||||
entry = f'<{child_tag}>{esc_xml_text(child_name)}</{child_tag}>'
|
||||
|
||||
block = re.search(r'<ChildObjects\s*>.*?</ChildObjects>', config_content, re.S)
|
||||
if block is None:
|
||||
# Empty self-closing <ChildObjects/> => open it with the first entry.
|
||||
empty = re.search(r'<ChildObjects\s*/>', config_content)
|
||||
if empty is None:
|
||||
return 'no-childobj'
|
||||
replacement = f'<ChildObjects>{eol}\t\t\t{entry}{eol}\t\t</ChildObjects>'
|
||||
new_content = config_content[:empty.start()] + replacement + config_content[empty.end():]
|
||||
write_utf8_bom(parent_xml_path, new_content)
|
||||
return 'added'
|
||||
|
||||
close_same = f'</{child_tag}>'
|
||||
last_same = config_content.rfind(close_same, block.start(), block.end())
|
||||
if last_same != -1:
|
||||
# After the last element of the same type (keeps them grouped).
|
||||
insert_at = last_same + len(close_same)
|
||||
new_content = (config_content[:insert_at]
|
||||
+ f'{eol}\t\t\t{entry}'
|
||||
+ config_content[insert_at:])
|
||||
else:
|
||||
existing = child_objects.findall(f'{{{ns}}}{child_tag}')
|
||||
already_exists = any((e.text or '').strip() == obj_name for e in existing)
|
||||
# No element of this type yet: new line before </ChildObjects>,
|
||||
# reusing the block's existing closing indent for </ChildObjects>.
|
||||
close_at = config_content.rfind('</ChildObjects>', block.start(), block.end())
|
||||
new_content = (config_content[:close_at]
|
||||
+ f'\t{entry}{eol}\t\t'
|
||||
+ config_content[close_at:])
|
||||
write_utf8_bom(parent_xml_path, new_content)
|
||||
return 'added'
|
||||
|
||||
if already_exists:
|
||||
reg_result = 'already'
|
||||
else:
|
||||
eol = '\r\n' if '\r\n' in config_content else '\n'
|
||||
entry = f'<{child_tag}>{esc_xml_text(obj_name)}</{child_tag}>'
|
||||
|
||||
block = re.search(r'<ChildObjects\s*>.*?</ChildObjects>', config_content, re.S)
|
||||
if block is None:
|
||||
# Empty self-closing <ChildObjects/> => open it with the first entry.
|
||||
empty = re.search(r'<ChildObjects\s*/>', config_content)
|
||||
if empty is None:
|
||||
reg_result = 'no-childobj'
|
||||
else:
|
||||
replacement = f'<ChildObjects>{eol}\t\t\t{entry}{eol}\t\t</ChildObjects>'
|
||||
new_content = config_content[:empty.start()] + replacement + config_content[empty.end():]
|
||||
write_utf8_bom(config_xml_path, new_content)
|
||||
reg_result = 'added'
|
||||
else:
|
||||
close_same = f'</{child_tag}>'
|
||||
last_same = config_content.rfind(close_same, block.start(), block.end())
|
||||
if last_same != -1:
|
||||
# After the last element of the same type (keeps them grouped).
|
||||
insert_at = last_same + len(close_same)
|
||||
new_content = (config_content[:insert_at]
|
||||
+ f'{eol}\t\t\t{entry}'
|
||||
+ config_content[insert_at:])
|
||||
else:
|
||||
# No element of this type yet: new line before </ChildObjects>,
|
||||
# reusing the block's existing closing indent for </ChildObjects>.
|
||||
close_at = config_content.rfind('</ChildObjects>', block.start(), block.end())
|
||||
new_content = (config_content[:close_at]
|
||||
+ f'\t{entry}{eol}\t\t'
|
||||
+ config_content[close_at:])
|
||||
write_utf8_bom(config_xml_path, new_content)
|
||||
reg_result = 'added'
|
||||
else:
|
||||
reg_result = 'no-config'
|
||||
child_tag = obj_type
|
||||
config_xml_path = os.path.join(output_dir, 'Configuration.xml')
|
||||
reg_result = register_in_childobjects(config_xml_path, 'Configuration', child_tag, obj_name)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 18. Summary
|
||||
@@ -4949,6 +5337,8 @@ elif reg_result == 'no-config':
|
||||
print(f' Configuration.xml: not found at {config_xml_path} (register manually)')
|
||||
|
||||
# Cross-reference hints
|
||||
if std_attr_tail_hint:
|
||||
print(f'[HINT] {std_attr_tail_hint}')
|
||||
if obj_type == 'AccountingRegister' and not defn.get('chartOfAccounts'):
|
||||
print('[HINT] AccountingRegister requires ChartOfAccounts reference:')
|
||||
print(' /meta-edit -Operation modify-property -Value "ChartOfAccounts=ChartOfAccounts.XXX"')
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# meta-decompile v0.63 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
||||
# meta-decompile v0.65 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
#
|
||||
# Поддержаны: Catalog, ExchangePlan, ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes, Document,
|
||||
# InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, BusinessProcess, Task, Enum. Инверс meta-compile (omit-on-default: ключ эмитим только
|
||||
# когда значение в XML отличается от умолчания компилятора). Неподдерживаемый тип / не-MetaDataObject
|
||||
# root → exit 3 (ring3, как form-decompile).
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[Alias('Path')]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# meta-decompile v0.63 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
||||
# meta-decompile v0.65 — XML объекта метаданных 1С → JSON-черновик формата meta-compile
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
#
|
||||
# Зеркало meta-decompile.ps1 (КАНОН). Структура 1:1 — те же имена функций, порядок, комментарии.
|
||||
@@ -20,6 +20,28 @@ from lxml import etree
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# --- Namespaces (зеркало XmlNamespaceManager) ---
|
||||
NS_MD = "http://v8.1c.ru/8.3/MDClasses"
|
||||
NS_V8 = "http://v8.1c.ru/8.1/data/core"
|
||||
@@ -2099,7 +2121,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description='Decompile 1C metadata object XML to JSON DSL (draft)', allow_abbrev=False)
|
||||
parser.add_argument('-ObjectPath', '-Path', dest='ObjectPath', type=str, required=True)
|
||||
parser.add_argument('-OutputPath', dest='OutputPath', type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
object_path = args.ObjectPath
|
||||
if not os.path.exists(object_path):
|
||||
|
||||
@@ -132,8 +132,7 @@ JSON — строки и/или объекты (для групп с вложе
|
||||
|
||||
**Свойства** задавайте по имени свойства 1С (PascalCase, как в конфигураторе): `Indexing`, `FillChecking`,
|
||||
`Use`, `FullTextSearch`, `DataHistory`, `PasswordMode`, `MultiLine`, `Mask`, `CreateOnInput`, `QuickChoice` и др.
|
||||
Свойство можно задать, даже если у реквизита оно ещё не выставлено. Опечатка в имени свойства → ошибка
|
||||
(правка не теряется молча).
|
||||
Свойство можно задать, даже если у реквизита оно ещё не выставлено.
|
||||
|
||||
```powershell
|
||||
-Operation modify-attribute -Value "СтароеИмя: name=НовоеИмя, type=Строка(500)"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Для сложных и комбинированных операций используйте JSON-файл вместо inline-режима.
|
||||
|
||||
```powershell
|
||||
powershell.exe -NoProfile -File .claude/skills/meta-edit/scripts/meta-edit.ps1 -DefinitionFile "<json>" -ObjectPath "<path>"
|
||||
powershell.exe -NoProfile -File ${CLAUDE_SKILL_DIR}/scripts/meta-edit.ps1 -DefinitionFile "<json>" -ObjectPath "<path>"
|
||||
```
|
||||
|
||||
## add — добавить элементы
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
```
|
||||
|
||||
Свойство можно задать, даже если оно ещё не выставлено у объекта (например `FullTextSearch`, `DataHistory`).
|
||||
Опечатка в имени свойства → ошибка (правка не теряется молча). Допустимы имена свойств соответствующего типа объекта.
|
||||
Допустимы имена свойств соответствующего типа объекта.
|
||||
|
||||
### Type — тип значения (Константа, ПВХ)
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
```powershell
|
||||
-Operation modify-property -Value "Type=String(100) + Number(15,2) + CatalogRef.Номенклатура"
|
||||
```
|
||||
Структурные свойства (со вложенными узлами) в скалярный текст не превращаются: попытка задать
|
||||
такое свойство обычным `Ключ=Значение` (кроме `Type`) завершается ошибкой до записи файла.
|
||||
Структурные свойства (со вложенными узлами) через `Ключ=Значение` не задаются — исключение только `Type`.
|
||||
|
||||
## Свойства-списки
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# meta-edit v1.24 — Edit existing 1C metadata object XML
|
||||
# meta-edit v1.42 — Edit existing 1C metadata object XML
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
[CmdletBinding(PositionalBinding=$false)]
|
||||
param(
|
||||
[string]$DefinitionFile,
|
||||
|
||||
@@ -31,6 +32,70 @@ param(
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Разбор пользовательского JSON ---
|
||||
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
|
||||
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
|
||||
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
|
||||
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
|
||||
# Возврат через -NoEnumerate: без него одноэлементный
|
||||
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
|
||||
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
|
||||
try {
|
||||
# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
|
||||
# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
|
||||
$parsed = $text | ConvertFrom-Json
|
||||
} catch {
|
||||
$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
|
||||
if ($Inline) {
|
||||
$got = ($text -replace '\s+', ' ').Trim()
|
||||
$label = 'got'
|
||||
if (-not $got) { $got = '(empty)' }
|
||||
elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
|
||||
$what = "${what}, ${label}: ${got}"
|
||||
}
|
||||
[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
|
||||
exit 1
|
||||
}
|
||||
Write-Output -NoEnumerate $parsed
|
||||
}
|
||||
|
||||
# --- Чтение входного JSON-файла ---
|
||||
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
|
||||
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
|
||||
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
|
||||
# угаданное имя уйдёт в метаданные так же молча.
|
||||
function Read-JsonInputFile([string]$path) {
|
||||
# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
|
||||
# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
|
||||
# проверкой срабатывают раньше и сохраняют свой текст.
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
[Console]::Error.WriteLine("[ERROR] File not found: $path")
|
||||
exit 1
|
||||
}
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
|
||||
exit 1
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
|
||||
return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
|
||||
return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
|
||||
return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
|
||||
}
|
||||
try {
|
||||
return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
|
||||
} catch {
|
||||
$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
|
||||
[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# ============================================================
|
||||
@@ -89,7 +154,7 @@ $script:validEnumValues = @{
|
||||
"RegisterRecordsDeletion" = @("AutoDelete","AutoDeleteOnUnpost","AutoDeleteOff")
|
||||
"RegisterRecordsWritingOnPost" = @("WriteModified","WriteSelected","WriteAll")
|
||||
"ReturnValuesReuse" = @("DontUse","DuringRequest","DuringSession")
|
||||
"ReuseSessions" = @("DontUse","AutoUse")
|
||||
"ReuseSessions" = @("DontUse","Use","AutoUse")
|
||||
"FillChecking" = @("DontCheck","ShowError","ShowWarning")
|
||||
"Indexing" = @("DontIndex","Index","IndexWithAdditionalOrder")
|
||||
}
|
||||
@@ -121,8 +186,8 @@ if ($DefinitionFile) {
|
||||
Write-Error "Definition file not found: $DefinitionFile"
|
||||
exit 1
|
||||
}
|
||||
$jsonText = Get-Content -Raw -Encoding UTF8 $DefinitionFile
|
||||
$def = $jsonText | ConvertFrom-Json
|
||||
$jsonText = Read-JsonInputFile $DefinitionFile
|
||||
$def = ConvertFrom-JsonInput $jsonText $DefinitionFile
|
||||
}
|
||||
|
||||
# --- Resolve object path ---
|
||||
@@ -313,6 +378,23 @@ function Info($msg) {
|
||||
# ============================================================
|
||||
|
||||
$root = $script:xmlDoc.DocumentElement
|
||||
|
||||
# Префикс пространства current-config, объявленный в КОРНЕ файла (у платформы — cfg).
|
||||
# Ищем по объявлениям корня, а не через GetPrefixOfNamespace: ссылочный тип живёт в
|
||||
# ТЕКСТЕ узла, поэтому XML-слой этот префикс не отслеживает. $null = корень URI не
|
||||
# объявляет → эмиттер остаётся на самодостаточной локальной форме.
|
||||
# Версия формата правимого файла — из его же корня. Нужна эмиттерам: часть свойств
|
||||
# появилась в поздних версиях (напр. <Color> у значения перечисления — в 2.21).
|
||||
$script:formatVersion = $root.GetAttribute("version")
|
||||
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }
|
||||
$script:isFormat221 = ($script:formatVersion -match '^(\d+)\.(\d+)$') -and ([int]$Matches[1] * 100 + [int]$Matches[2]) -ge 221
|
||||
|
||||
$script:cfgUri = 'http://v8.1c.ru/8.1/data/enterprise/current-config'
|
||||
$script:cfgPrefix = $null
|
||||
foreach ($a in $root.Attributes) {
|
||||
if ($a.Prefix -eq 'xmlns' -and $a.Value -eq $script:cfgUri) { $script:cfgPrefix = $a.LocalName; break }
|
||||
}
|
||||
|
||||
if ($root.LocalName -ne "MetaDataObject") {
|
||||
Write-Error "Root element must be MetaDataObject, got: $($root.LocalName)"
|
||||
exit 1
|
||||
@@ -444,7 +526,20 @@ function Resolve-TypeStr {
|
||||
param([string]$typeStr)
|
||||
if (-not $typeStr) { return $typeStr }
|
||||
|
||||
# Parameterized: Number(15,2), Строка(100)
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if ($typeStr.StartsWith('cfg:')) {
|
||||
$typeStr = $typeStr.Substring(4)
|
||||
} elseif ($typeStr.Contains('.') -and $typeStr -match '^d\d+p\d+:') {
|
||||
$typeStr = $typeStr.Substring($typeStr.IndexOf(':') + 1)
|
||||
}
|
||||
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
if ($typeStr -match '^([^(]+)\((.+)\)$') {
|
||||
$baseName = $Matches[1].Trim()
|
||||
$params = $Matches[2]
|
||||
@@ -453,17 +548,17 @@ function Resolve-TypeStr {
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Reference: СправочникСсылка.Организации
|
||||
# Ссылочные типы: СправочникСсылка.Организации → CatalogRef.Организации
|
||||
if ($typeStr.Contains('.')) {
|
||||
$dotIdx = $typeStr.IndexOf('.')
|
||||
$prefix = $typeStr.Substring(0, $dotIdx)
|
||||
$suffix = $typeStr.Substring($dotIdx)
|
||||
$suffix = $typeStr.Substring($dotIdx) # includes the dot
|
||||
$resolved = $script:typeSynonyms[$prefix.ToLower()]
|
||||
if ($resolved) { return "$resolved$suffix" }
|
||||
return $typeStr
|
||||
}
|
||||
|
||||
# Simple
|
||||
# Простое имя
|
||||
$resolved = $script:typeSynonyms[$typeStr.ToLower()]
|
||||
if ($resolved) { return $resolved }
|
||||
return $typeStr
|
||||
@@ -557,9 +652,19 @@ function Build-TypeContentXml {
|
||||
return $sb.ToString().TrimEnd("`r","`n")
|
||||
}
|
||||
|
||||
# Reference types — use local xmlns declaration for 1C compatibility
|
||||
# Ссылочные типы — префиксом, объявленным в КОРНЕ файла (у платформы это cfg).
|
||||
# Раньше здесь всегда объявлялся локальный xmlns:d5p1 на тот же URI, что уже есть
|
||||
# в шапке: платформа принимала, но при цикле «загрузить в базу → выгрузить»
|
||||
# переписывала каждый ссылочный тип в cfg: — diff-шум на ровном месте.
|
||||
# Если корень URI не объявляет (файл не от платформы), остаёмся на самодостаточной
|
||||
# локальной форме: префикс тут — ТЕКСТ узла, XML-слой про него не знает и сам
|
||||
# объявление не добавит, так что иначе получился бы неразрешимый префикс.
|
||||
if ($typeStr -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.(.+)$') {
|
||||
$sb.AppendLine("$indent<v8:Type xmlns:d5p1=`"http://v8.1c.ru/8.1/data/enterprise/current-config`">d5p1:$typeStr</v8:Type>") | Out-Null
|
||||
if ($script:cfgPrefix) {
|
||||
$sb.AppendLine("$indent<v8:Type>$($script:cfgPrefix):$typeStr</v8:Type>") | Out-Null
|
||||
} else {
|
||||
$sb.AppendLine("$indent<v8:Type xmlns:d5p1=`"$script:cfgUri`">d5p1:$typeStr</v8:Type>") | Out-Null
|
||||
}
|
||||
return $sb.ToString().TrimEnd("`r","`n")
|
||||
}
|
||||
|
||||
@@ -1279,6 +1384,9 @@ function Build-EnumValueFragment {
|
||||
$sb.AppendLine("$indent`t`t<Name>$(Esc-XmlText $parsed.name)</Name>") | Out-Null
|
||||
$sb.AppendLine($(Build-MLTextXml "$indent`t`t" "Synonym" $parsed.synonym)) | Out-Null
|
||||
$sb.AppendLine("$indent`t`t<Comment/>") | Out-Null
|
||||
# Цвет значения — свойство формата 2.21 (8.5). Без него добавленное значение
|
||||
# отличалось бы от соседних, написанных платформой.
|
||||
if ($script:isFormat221) { $sb.AppendLine("$indent`t`t<Color>auto</Color>") | Out-Null }
|
||||
$sb.AppendLine("$indent`t</Properties>") | Out-Null
|
||||
$sb.Append("$indent</EnumValue>") | Out-Null
|
||||
return $sb.ToString()
|
||||
@@ -2067,6 +2175,11 @@ function Modify-Properties($propsDef) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Значение свойства-перечисления приводим к канону (как это делает meta-compile): иначе
|
||||
# в XML уезжает то, что дала модель, и платформа отвергает выгрузку уже при загрузке.
|
||||
# Неизвестное свойство функция пропускает как есть, неизвестное значение — отвергает.
|
||||
$valueStr = Normalize-EnumValue $propName $valueStr
|
||||
|
||||
$propEl.InnerText = $valueStr
|
||||
Info "Modified property: $propName = $valueStr"
|
||||
$script:modifyCount++
|
||||
@@ -2811,7 +2924,11 @@ function Build-ChoiceParametersXml([string]$indent, $cp) {
|
||||
$script:fillBoolTrue = @('true','истина','да')
|
||||
$script:fillBoolFalse = @('false','ложь','нет')
|
||||
|
||||
function Esc-XmlText([string]$s) { return $s.Replace('&','&').Replace('<','<').Replace('>','>') }
|
||||
function Esc-XmlText {
|
||||
param([string]$s)
|
||||
# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
|
||||
return $s.Replace('&','&').Replace('<','<').Replace('>','>')
|
||||
}
|
||||
|
||||
function Get-FillTypeCategory([string]$typeStr) {
|
||||
if (-not $typeStr) { return 'String' }
|
||||
@@ -3138,7 +3255,8 @@ function Add-PredefinedItems($items) {
|
||||
$hdr = "<?xml version=`"1.0`" encoding=`"UTF-8`"?>`r`n<PredefinedData xmlns=`"http://v8.1c.ru/8.3/xcf/predef`" xmlns:v8=`"http://v8.1c.ru/8.1/data/core`" 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`" xsi:type=`"$xsiType`" version=`"$version`">`r`n"
|
||||
$text = "$hdr$itemsXml</PredefinedData>`r`n"
|
||||
}
|
||||
[System.IO.File]::WriteAllText($path, $text, $utf8Bom)
|
||||
# Создаваемый файл — по канону: без перевода строки в конце.
|
||||
[System.IO.File]::WriteAllText($path, $text.TrimEnd("`r", "`n"), $utf8Bom)
|
||||
$n = @($items).Count
|
||||
Info "Added $n predefined item(s) → $path"
|
||||
$script:addCount += $n
|
||||
@@ -3208,9 +3326,17 @@ if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) {
|
||||
$text = $text.Substring(1)
|
||||
}
|
||||
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
|
||||
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
|
||||
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
|
||||
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
|
||||
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
|
||||
|
||||
# Write with BOM
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
|
||||
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
|
||||
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
|
||||
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
|
||||
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
|
||||
|
||||
Info "Saved: $resolvedPath"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# meta-edit v1.24 — Edit existing 1C metadata object XML
|
||||
# meta-edit v1.42 — Edit existing 1C metadata object XML
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -11,6 +11,127 @@ import sys
|
||||
import uuid
|
||||
from lxml import etree
|
||||
|
||||
# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
|
||||
# регистр не различают, в argparse совпадение точное.
|
||||
|
||||
def parse_json_input(text, source, expected=None, inline=False):
|
||||
"""Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).
|
||||
|
||||
expected заполняем только для полиморфного входа: у файла подсказка
|
||||
была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
|
||||
что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.
|
||||
|
||||
Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
|
||||
(skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
|
||||
"""
|
||||
import json as _pj
|
||||
import sys as _psys
|
||||
try:
|
||||
if not str(text).strip():
|
||||
raise ValueError("input is empty")
|
||||
return _pj.loads(text)
|
||||
except ValueError as exc:
|
||||
what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
|
||||
if inline:
|
||||
got = " ".join(str(text).split())
|
||||
label = "got"
|
||||
if not got:
|
||||
got = "(empty)"
|
||||
elif len(got) > 60:
|
||||
label = "got (first 60 chars)"
|
||||
got = got[:60]
|
||||
what = "%s, %s: %s" % (what, label, got)
|
||||
print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
def read_json_file(path):
|
||||
"""Чтение входного JSON-файла с кодировкой из BOM (issue #80).
|
||||
|
||||
BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
|
||||
страницу не подбираем: угаданное имя уехало бы в метаданные молча.
|
||||
"""
|
||||
import os as _pos
|
||||
import sys as _psys
|
||||
if not _pos.path.exists(path):
|
||||
print("[ERROR] File not found: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
if _pos.path.isdir(path):
|
||||
print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
with open(path, "rb") as _fh:
|
||||
data = _fh.read()
|
||||
if data[:3] == b"\xef\xbb\xbf":
|
||||
return data[3:].decode("utf-8")
|
||||
if data[:2] == b"\xff\xfe":
|
||||
return data[2:].decode("utf-16-le")
|
||||
if data[:2] == b"\xfe\xff":
|
||||
return data[2:].decode("utf-16-be")
|
||||
try:
|
||||
return data.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
|
||||
% (path, exc), file=_psys.stderr)
|
||||
_psys.exit(1)
|
||||
|
||||
|
||||
class CIDict(dict):
|
||||
# Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
|
||||
# реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
|
||||
# сохраняется — от него зависит порядок эмиссии.
|
||||
def _actual(self, key):
|
||||
if not isinstance(key, str) or dict.__contains__(self, key):
|
||||
return key
|
||||
ci = self.__dict__.get('_ci')
|
||||
if ci is None or len(ci) != len(self):
|
||||
ci = {k.lower(): k for k in self if isinstance(k, str)}
|
||||
self.__dict__['_ci'] = ci
|
||||
return ci.get(key.lower(), key)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return dict.__getitem__(self, self._actual(key))
|
||||
|
||||
def __contains__(self, key):
|
||||
return dict.__contains__(self, self._actual(key))
|
||||
|
||||
def get(self, key, default=None):
|
||||
return dict.get(self, self._actual(key), default)
|
||||
|
||||
def pop(self, key, *default):
|
||||
return dict.pop(self, self._actual(key), *default)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
# запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
|
||||
dict.__setitem__(self, self._actual(key), value)
|
||||
|
||||
def ci_json(obj):
|
||||
"""Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
|
||||
if isinstance(obj, dict):
|
||||
return CIDict((k, ci_json(v)) for k, v in obj.items())
|
||||
if isinstance(obj, list):
|
||||
return [ci_json(v) for v in obj]
|
||||
return obj
|
||||
|
||||
def ci_parse_args(parser, argv=None):
|
||||
"""parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
names = {s.lower(): s for a in parser._actions for s in a.option_strings}
|
||||
for i, tok in enumerate(argv):
|
||||
if tok.startswith('-') and tok.lower() in names:
|
||||
argv[i] = names[tok.lower()]
|
||||
# choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
|
||||
choice_map = {}
|
||||
for a in parser._actions:
|
||||
if a.choices:
|
||||
for s in a.option_strings:
|
||||
choice_map[s] = {str(c).lower(): c for c in a.choices}
|
||||
for i in range(len(argv) - 1):
|
||||
m = choice_map.get(argv[i])
|
||||
if m and argv[i + 1].lower() in m:
|
||||
argv[i + 1] = m[argv[i + 1].lower()]
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
|
||||
@@ -198,6 +319,12 @@ V8_NS = "http://v8.1c.ru/8.1/data/core"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
XS_NS = "http://www.w3.org/2001/XMLSchema"
|
||||
CFG_NS = "http://v8.1c.ru/8.1/data/enterprise/current-config"
|
||||
# Версия формата правимого файла — из его же корня. Нужна эмиттерам: часть свойств
|
||||
# появилась в поздних версиях (напр. <Color> у значения перечисления — в 2.21).
|
||||
is_format_221 = False
|
||||
# Префикс current-config, объявленный в КОРНЕ правимого файла (у платформы — cfg).
|
||||
# None = корень его не объявляет → эмиттер ссылочных типов остаётся на локальной форме.
|
||||
cfg_prefix = None
|
||||
|
||||
NSMAP_WRAPPER = {
|
||||
None: MD_NS,
|
||||
@@ -252,7 +379,8 @@ def localname(el):
|
||||
|
||||
|
||||
def esc_xml(s):
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
|
||||
return s.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"')
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -291,6 +419,9 @@ enum_value_aliases = {
|
||||
'ИндексироватьСДопУпорядочиванием': 'IndexWithAdditionalOrder',
|
||||
}
|
||||
|
||||
# Словарь ищет ПОЛЬЗОВАТЕЛЬСКИЙ ввод — поиск регистронезависим, как хеш-таблица PS1.
|
||||
enum_value_aliases = CIDict(enum_value_aliases)
|
||||
|
||||
valid_enum_values = {
|
||||
'RegisterType': ['Balance', 'Turnovers'],
|
||||
'WriteMode': ['Independent', 'RecorderSubordinate'],
|
||||
@@ -311,7 +442,7 @@ valid_enum_values = {
|
||||
'RegisterRecordsDeletion': ['AutoDelete', 'AutoDeleteOnUnpost', 'AutoDeleteOff'],
|
||||
'RegisterRecordsWritingOnPost': ['WriteModified', 'WriteSelected', 'WriteAll'],
|
||||
'ReturnValuesReuse': ['DontUse', 'DuringRequest', 'DuringSession'],
|
||||
'ReuseSessions': ['DontUse', 'AutoUse'],
|
||||
'ReuseSessions': ['DontUse', 'Use', 'AutoUse'],
|
||||
'FillChecking': ['DontCheck', 'ShowError', 'ShowWarning'],
|
||||
'Indexing': ['DontIndex', 'Index', 'IndexWithAdditionalOrder'],
|
||||
}
|
||||
@@ -410,37 +541,48 @@ type_synonyms = {
|
||||
# ============================================================
|
||||
|
||||
|
||||
# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
|
||||
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
|
||||
TYPE_SYNONYMS = type_synonyms
|
||||
|
||||
|
||||
def resolve_type_str(type_str):
|
||||
if not type_str:
|
||||
return type_str
|
||||
|
||||
# Parameterized: Number(15,2), Строка(100)
|
||||
m = re.match(r"^([^(]+)\((.+)\)$", type_str)
|
||||
# Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
|
||||
# поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
|
||||
# «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
|
||||
# Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
|
||||
# платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
|
||||
# сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
|
||||
# d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
|
||||
if type_str.startswith('cfg:'):
|
||||
type_str = type_str[4:]
|
||||
elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
|
||||
type_str = type_str[type_str.index(':') + 1:]
|
||||
# Параметризованные типы: Number(15,2), Строка(100)
|
||||
m = re.match(r'^([^(]+)\((.+)\)$', type_str)
|
||||
if m:
|
||||
base_name = m.group(1).strip()
|
||||
params = m.group(2)
|
||||
resolved = type_synonyms.get(base_name.lower())
|
||||
resolved = TYPE_SYNONYMS.get(base_name.lower())
|
||||
if resolved:
|
||||
return f"{resolved}({params})"
|
||||
return f'{resolved}({params})'
|
||||
return type_str
|
||||
|
||||
# Reference: СправочникСсылка.Организации
|
||||
if "." in type_str:
|
||||
dot_idx = type_str.index(".")
|
||||
# Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
|
||||
if '.' in type_str:
|
||||
dot_idx = type_str.index('.')
|
||||
prefix = type_str[:dot_idx]
|
||||
suffix = type_str[dot_idx:]
|
||||
resolved = type_synonyms.get(prefix.lower())
|
||||
suffix = type_str[dot_idx:] # includes the dot
|
||||
resolved = TYPE_SYNONYMS.get(prefix.lower())
|
||||
if resolved:
|
||||
return f"{resolved}{suffix}"
|
||||
return f'{resolved}{suffix}'
|
||||
return type_str
|
||||
|
||||
# Simple
|
||||
resolved = type_synonyms.get(type_str.lower())
|
||||
# Простое имя
|
||||
resolved = TYPE_SYNONYMS.get(type_str.lower())
|
||||
if resolved:
|
||||
return resolved
|
||||
return type_str
|
||||
|
||||
|
||||
def build_type_content_xml(indent, type_str):
|
||||
if not type_str:
|
||||
return ""
|
||||
@@ -525,14 +667,23 @@ def build_type_content_xml(indent, type_str):
|
||||
lines.append(f"{indent}<v8:TypeSet>cfg:DefinedType.{dt_name}</v8:TypeSet>")
|
||||
return "\r\n".join(lines)
|
||||
|
||||
# Reference types — use local xmlns declaration for 1C compatibility
|
||||
# Ссылочные типы — префиксом, объявленным в КОРНЕ файла (у платформы это cfg).
|
||||
# Раньше здесь всегда объявлялся локальный xmlns:d5p1 на тот же URI, что уже есть
|
||||
# в шапке: платформа принимала, но при цикле «загрузить в базу → выгрузить»
|
||||
# переписывала каждый ссылочный тип в cfg: — diff-шум на ровном месте.
|
||||
# Если корень URI не объявляет (файл не от платформы), остаёмся на самодостаточной
|
||||
# локальной форме: префикс тут — ТЕКСТ узла, XML-слой про него не знает и сам
|
||||
# объявление не добавит, так что иначе получился бы неразрешимый префикс.
|
||||
m = re.match(
|
||||
r"^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|"
|
||||
r"ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef)\.(.+)$",
|
||||
type_str,
|
||||
)
|
||||
if m:
|
||||
lines.append(f'{indent}<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:{type_str}</v8:Type>')
|
||||
if cfg_prefix:
|
||||
lines.append(f'{indent}<v8:Type>{cfg_prefix}:{type_str}</v8:Type>')
|
||||
else:
|
||||
lines.append(f'{indent}<v8:Type xmlns:d5p1="{CFG_NS}">d5p1:{type_str}</v8:Type>')
|
||||
return "\r\n".join(lines)
|
||||
|
||||
# Fallback
|
||||
@@ -1254,6 +1405,10 @@ def build_enum_value_fragment(parsed, indent):
|
||||
lines.append(f"{indent}\t\t<Name>{esc_xml_text(parsed['name'])}</Name>")
|
||||
lines.append(build_mltext_xml(f"{indent}\t\t", "Synonym", parsed["synonym"]))
|
||||
lines.append(f"{indent}\t\t<Comment/>")
|
||||
# Цвет значения — свойство формата 2.21 (8.5). Без него добавленное значение
|
||||
# отличалось бы от соседних, написанных платформой.
|
||||
if is_format_221:
|
||||
lines.append(f"{indent}\t\t<Color>auto</Color>")
|
||||
lines.append(f"{indent}\t</Properties>")
|
||||
lines.append(f"{indent}</EnumValue>")
|
||||
return "\r\n".join(lines)
|
||||
@@ -1936,6 +2091,11 @@ def modify_properties(props_def):
|
||||
print(f"meta-edit: modify-property: свойство '{prop_name}' структурное (содержит дочерние узлы) — установка скалярного текста повредит XML; не поддерживается", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Значение свойства-перечисления приводим к канону (как это делает meta-compile): иначе
|
||||
# в XML уезжает то, что дала модель, и платформа отвергает выгрузку уже при загрузке.
|
||||
# Неизвестное свойство функция пропускает как есть, неизвестное значение — отвергает.
|
||||
value_str = normalize_enum_value(prop_name, value_str)
|
||||
|
||||
# Set inner text — clear children first, set text
|
||||
for ch in list(prop_el):
|
||||
prop_el.remove(ch)
|
||||
@@ -2962,21 +3122,22 @@ def _detect_xml_style(path):
|
||||
|
||||
|
||||
def _finalize_xml_bytes(xml_bytes, style):
|
||||
"""Привести сериализованные байты к стилю оригинала (или к дефолту, если style is None)."""
|
||||
enc_decl = style["enc"] if style else "utf-8"
|
||||
"""Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
|
||||
выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
|
||||
enc_decl = style["enc"] if style else "UTF-8"
|
||||
xml_bytes = xml_bytes.replace(
|
||||
b"<?xml version='1.0' encoding='UTF-8'?>",
|
||||
b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
|
||||
# Канонизировать переносы к LF (убирает от \r в tail'ах)
|
||||
xml_bytes = (xml_bytes.replace(b" \n", b"\n").replace(b" ", b"")
|
||||
.replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
|
||||
# Финальный перенос — как в оригинале (новый файл → есть)
|
||||
want_final_nl = style["final_nl"] if style else True
|
||||
# Финальный перенос — как в оригинале (новый файл → нет, канон #57)
|
||||
want_final_nl = style["final_nl"] if style else False
|
||||
xml_bytes = xml_bytes.rstrip(b"\n")
|
||||
if want_final_nl:
|
||||
xml_bytes += b"\n"
|
||||
# EOL — как в оригинале (новый файл → LF, текущее поведение)
|
||||
if style and style["crlf"]:
|
||||
# EOL — как в оригинале (новый файл → CRLF, канон #57)
|
||||
if (style["crlf"] if style else True):
|
||||
xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
|
||||
return xml_bytes
|
||||
|
||||
@@ -2985,13 +3146,9 @@ def save_xml(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")
|
||||
# Fix d5p1 namespace declarations stripped by lxml (it treats them as unused
|
||||
# because d5p1: appears only in text content, not in element/attribute names)
|
||||
xml_bytes = re.sub(
|
||||
b'(<v8:Type)(?! xmlns:d5p1)(>d5p1:)',
|
||||
b'\\1 xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config"\\2',
|
||||
xml_bytes
|
||||
)
|
||||
# Костыль, возвращавший xmlns:d5p1 (lxml выбрасывал его как неиспользуемый, ведь
|
||||
# префикс встречается только в тексте узла), удалён вместе с переходом на корневой
|
||||
# cfg: — объявление теперь берётся из шапки самого файла и не требует починки.
|
||||
xml_bytes = _finalize_xml_bytes(xml_bytes, style)
|
||||
with open(path, "wb") as f:
|
||||
if style is None or style["bom"]:
|
||||
@@ -3094,7 +3251,9 @@ def add_predefined_items(items):
|
||||
item_list = items if isinstance(items, list) else [items]
|
||||
items_xml = ''.join(build_predef_item_xml('\t', it, code_type) for it in item_list)
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8-sig') as f:
|
||||
# newline='' => без трансляции переводов строк: иначе CRLF молча схлопнется
|
||||
# в LF при чтении и файл будет переписан в LF (#44/#46/#47).
|
||||
with open(path, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
text = f.read()
|
||||
text = text.replace('</PredefinedData>', items_xml + '</PredefinedData>')
|
||||
else:
|
||||
@@ -3103,7 +3262,10 @@ def add_predefined_items(items):
|
||||
'xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
|
||||
'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
|
||||
f'xsi:type="{xsi_type}" version="{version}">\r\n')
|
||||
text = hdr + items_xml + '</PredefinedData>\r\n'
|
||||
text = hdr + items_xml + '</PredefinedData>'
|
||||
# Без перевода строки в конце — канон #57. Срезаем в ОБЕИХ ветках: файл, созданный
|
||||
# прежней версией навыка, мог унести хвост, а PS-порт срезает безусловно.
|
||||
text = text.rstrip('\r\n')
|
||||
with open(path, 'wb') as f:
|
||||
f.write(b'\xef\xbb\xbf')
|
||||
f.write(text.encode('utf-8'))
|
||||
@@ -3141,7 +3303,7 @@ def main():
|
||||
parser.add_argument("-Operation", default=None, choices=valid_operations, help="Inline operation")
|
||||
parser.add_argument("-Value", default=None, help="Inline value")
|
||||
parser.add_argument("-NoValidate", action="store_true", help="Skip auto-validation")
|
||||
args = parser.parse_args()
|
||||
args = ci_parse_args(parser)
|
||||
|
||||
# --- Mode validation ---
|
||||
if args.DefinitionFile and args.Operation:
|
||||
@@ -3154,8 +3316,7 @@ def main():
|
||||
if args.DefinitionFile:
|
||||
if not os.path.exists(args.DefinitionFile):
|
||||
die(f"Definition file not found: {args.DefinitionFile}")
|
||||
with open(args.DefinitionFile, "r", encoding="utf-8-sig") as f:
|
||||
definition = json.load(f)
|
||||
definition = ci_json(parse_json_input(read_json_file(args.DefinitionFile), args.DefinitionFile))
|
||||
|
||||
# --- Resolve object path ---
|
||||
object_path = args.ObjectPath
|
||||
@@ -3192,6 +3353,13 @@ def main():
|
||||
xml_tree = etree.parse(resolved_path, xml_parser)
|
||||
xml_root = xml_tree.getroot()
|
||||
|
||||
# Префикс current-config берём из объявлений корня — им и пишем ссылочные типы.
|
||||
global cfg_prefix, is_format_221
|
||||
cfg_prefix = next((p for p, u in (xml_root.nsmap or {}).items() if u == CFG_NS and p), None)
|
||||
_fv = xml_root.get("version") or "2.17"
|
||||
_m = re.match(r'^(\d+)\.(\d+)$', _fv)
|
||||
is_format_221 = bool(_m) and int(_m.group(1)) * 100 + int(_m.group(2)) >= 221
|
||||
|
||||
# --- Detect object type ---
|
||||
if localname(xml_root) != "MetaDataObject":
|
||||
die(f"Root element must be MetaDataObject, got: {localname(xml_root)}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user