mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-18 08:39:40 +03:00
Compare commits
140 Commits
w-2026-07-05
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f39a5022 | |||
| 637dd6d0eb | |||
| ddc1641176 | |||
| e7b5df4d50 | |||
| 7696a8b3ee | |||
| 96b38d9f91 | |||
| ad8a2e5cab | |||
| 115a966f0f | |||
| b81a7504ce | |||
| 4a778cb3b1 | |||
| bb98d3c240 | |||
| 8ccd562625 | |||
| 2a71e6c980 | |||
| 9bec6171d3 | |||
| 57c101ef3c | |||
| 9352d28008 | |||
| dc8afb402b | |||
| 037d000f20 | |||
| 3889c7279f | |||
| 3f1168b975 | |||
| 9e1341b80b | |||
| e473d6c7a9 | |||
| a862e913c8 | |||
| abb3580d60 | |||
| c178e04f92 | |||
| 870203622a | |||
| 7a9dc229c7 | |||
| 2ff7c271fb | |||
| b8a6a80d9a | |||
| 356258e9c1 | |||
| 0d02159a34 | |||
| 39dc3fc1e7 | |||
| 41df320153 | |||
| caea1b1049 | |||
| c31cff5ada | |||
| 0e68421f50 | |||
| 9b5908c3b2 | |||
| b9ed48ccf2 | |||
| f8cd7dd681 | |||
| ca1c80e164 | |||
| 700ede1df7 | |||
| 95b25b79a1 | |||
| 5d877973a4 | |||
| 9ad9c12c9b | |||
| 63591ef132 | |||
| f817787f26 | |||
| c574d1b7e8 | |||
| 4a6060d928 | |||
| b24b7146b2 | |||
| fd21f5da79 | |||
| 5988c81244 | |||
| 273296665d | |||
| f3a9880d96 | |||
| e5746c33bd | |||
| 706cc919a2 | |||
| 4ca89343ae | |||
| 30d5174351 | |||
| df42f6cc79 | |||
| 2507437eb2 | |||
| f80446c035 | |||
| f4fb260f68 | |||
| b4e6037fd4 | |||
| edc8474ddd | |||
| 47134425f3 | |||
| a13538cd16 | |||
| 55f8ece404 | |||
| 582de918fd | |||
| db29324215 | |||
| 117a06ff3e | |||
| de2e966311 | |||
| a6ca515c96 | |||
| 54225aeaf3 | |||
| 91196ea63f | |||
| 7db3a514c8 | |||
| 8af6b9d88e | |||
| 101ca6e371 | |||
| 239499a3f8 | |||
| dd22e2e17f | |||
| 07a2fd4b4d | |||
| cdba298e86 | |||
| 9a6a93b945 | |||
| 3d0c8f233e | |||
| cd6d305eea | |||
| 89537e9b5d | |||
| 87c7ca0e23 | |||
| d0e256a42f | |||
| ac3a373348 | |||
| 10933e7386 | |||
| 4e6c8311d8 | |||
| 11e7abfc3f | |||
| 126ce0bbd8 | |||
| 3e7bfc12c4 | |||
| faec6e6f19 | |||
| 2c50030789 | |||
| 90a05a58e2 | |||
| a08fcc1bcc | |||
| 7eee8086ea | |||
| b730687623 | |||
| 44bfcf2256 | |||
| 01ecd8a52d | |||
| f0840864dc | |||
| d22bebb16f | |||
| 8ea310f72e | |||
| 027e6a4a89 | |||
| 177c9bb0fa | |||
| 562f4617bc | |||
| 1212cbd9ed | |||
| d72a946eb5 | |||
| 65c4fba471 | |||
| e95fb6b619 | |||
| 8b01d7a6bd | |||
| e2f019d872 | |||
| e9e3323542 | |||
| 7cf910396b | |||
| c706b92b03 | |||
| 65de4e5c97 | |||
| 9fec03bfb6 | |||
| c7067dd319 | |||
| 5a9b111dc0 | |||
| d007da5eb8 | |||
| c2703f043f | |||
| b4d48ca656 | |||
| 1565ae23ab | |||
| ef20f6ece8 | |||
| f11fe6808d | |||
| b93fd0807f | |||
| d8146e1f1b | |||
| 1701bdac96 | |||
| 40cdd57619 | |||
| 03de2dc86d | |||
| 72d6d47454 | |||
| 5f53c3f02e | |||
| 53ed8c4489 | |||
| 253a4fbce0 | |||
| 58bc95264e | |||
| ed42d5e9cb | |||
| df5c1ee17d | |||
| 85e13e95f2 | |||
| 7000593561 | |||
| 8b8620ae44 |
@@ -1,4 +1,4 @@
|
|||||||
# cf-edit v1.7 — Edit 1C configuration root (Configuration.xml)
|
# cf-edit v1.9 — Edit 1C configuration root (Configuration.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
|
||||||
@@ -44,6 +44,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -80,10 +90,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -196,7 +209,7 @@ Info "Configuration: $($script:objName)"
|
|||||||
$script:typeOrder = @(
|
$script:typeOrder = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||||
@@ -212,7 +225,7 @@ $script:typeOrder = @(
|
|||||||
$script:typeToDir = @{
|
$script:typeToDir = @{
|
||||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
|
||||||
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
|
||||||
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
|
||||||
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
|
||||||
@@ -700,6 +713,7 @@ $script:ruTypeMap = @{
|
|||||||
"регистррасчёта" = "CalculationRegister"
|
"регистррасчёта" = "CalculationRegister"
|
||||||
"бизнеспроцесс" = "BusinessProcess"
|
"бизнеспроцесс" = "BusinessProcess"
|
||||||
"задача" = "Task"
|
"задача" = "Task"
|
||||||
|
"бот" = "Bot"
|
||||||
"планобмена" = "ExchangePlan"
|
"планобмена" = "ExchangePlan"
|
||||||
"хранилищенастроек" = "SettingsStorage"
|
"хранилищенастроек" = "SettingsStorage"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-edit v1.7 — Edit 1C configuration root (Configuration.xml)
|
# cf-edit v1.9 — Edit 1C configuration root (Configuration.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -33,6 +33,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -72,6 +84,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -79,6 +94,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -182,7 +199,7 @@ XS_NS = "http://www.w3.org/2001/XMLSchema"
|
|||||||
TYPE_ORDER = [
|
TYPE_ORDER = [
|
||||||
"Language", "Subsystem", "StyleItem", "Style",
|
"Language", "Subsystem", "StyleItem", "Style",
|
||||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||||
@@ -198,7 +215,7 @@ TYPE_ORDER = [
|
|||||||
TYPE_TO_DIR = {
|
TYPE_TO_DIR = {
|
||||||
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
"Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
|
||||||
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
"CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
|
||||||
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
"FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
|
||||||
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
"XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
|
||||||
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
"EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
|
||||||
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
"FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
|
||||||
@@ -771,6 +788,7 @@ def main():
|
|||||||
"регистррасчета": "CalculationRegister",
|
"регистррасчета": "CalculationRegister",
|
||||||
"регистррасчёта": "CalculationRegister",
|
"регистррасчёта": "CalculationRegister",
|
||||||
"бизнеспроцесс": "BusinessProcess",
|
"бизнеспроцесс": "BusinessProcess",
|
||||||
|
"бот": "Bot",
|
||||||
"задача": "Task", "планобмена": "ExchangePlan",
|
"задача": "Task", "планобмена": "ExchangePlan",
|
||||||
"хранилищенастроек": "SettingsStorage",
|
"хранилищенастроек": "SettingsStorage",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# cf-info v1.3 — Compact summary of 1C configuration root
|
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$ConfigPath,
|
||||||
@@ -89,7 +89,7 @@ function Get-PropML([string]$propName) {
|
|||||||
$typeOrder = @(
|
$typeOrder = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||||
@@ -105,6 +105,7 @@ $typeRuNames = @{
|
|||||||
"Language"="Языки"; "Subsystem"="Подсистемы"; "StyleItem"="Элементы стиля"; "Style"="Стили"
|
"Language"="Языки"; "Subsystem"="Подсистемы"; "StyleItem"="Элементы стиля"; "Style"="Стили"
|
||||||
"CommonPicture"="Общие картинки"; "SessionParameter"="Параметры сеанса"; "Role"="Роли"
|
"CommonPicture"="Общие картинки"; "SessionParameter"="Параметры сеанса"; "Role"="Роли"
|
||||||
"CommonTemplate"="Общие макеты"; "FilterCriterion"="Критерии отбора"; "CommonModule"="Общие модули"
|
"CommonTemplate"="Общие макеты"; "FilterCriterion"="Критерии отбора"; "CommonModule"="Общие модули"
|
||||||
|
"Bot"="Боты"
|
||||||
"CommonAttribute"="Общие реквизиты"; "ExchangePlan"="Планы обмена"; "XDTOPackage"="XDTO-пакеты"
|
"CommonAttribute"="Общие реквизиты"; "ExchangePlan"="Планы обмена"; "XDTOPackage"="XDTO-пакеты"
|
||||||
"WebService"="Веб-сервисы"; "HTTPService"="HTTP-сервисы"; "WSReference"="WS-ссылки"
|
"WebService"="Веб-сервисы"; "HTTPService"="HTTP-сервисы"; "WSReference"="WS-ссылки"
|
||||||
"EventSubscription"="Подписки на события"; "ScheduledJob"="Регламентные задания"
|
"EventSubscription"="Подписки на события"; "ScheduledJob"="Регламентные задания"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-info v1.3 — Compact summary of 1C configuration root
|
# cf-info v1.4 — Compact summary of 1C configuration root
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -95,7 +95,7 @@ def get_prop_ml(prop_name):
|
|||||||
type_order = [
|
type_order = [
|
||||||
"Language", "Subsystem", "StyleItem", "Style",
|
"Language", "Subsystem", "StyleItem", "Style",
|
||||||
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
"CommonPicture", "SessionParameter", "Role", "CommonTemplate",
|
||||||
"FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
|
"FilterCriterion", "CommonModule", "Bot", "CommonAttribute", "ExchangePlan",
|
||||||
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
"XDTOPackage", "WebService", "HTTPService", "WSReference",
|
||||||
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
"EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
|
||||||
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
"FunctionalOptionsParameter", "DefinedType", "CommonCommand", "CommandGroup",
|
||||||
@@ -111,6 +111,7 @@ type_ru_names = {
|
|||||||
"Language": "Языки", "Subsystem": "Подсистемы", "StyleItem": "Элементы стиля", "Style": "Стили",
|
"Language": "Языки", "Subsystem": "Подсистемы", "StyleItem": "Элементы стиля", "Style": "Стили",
|
||||||
"CommonPicture": "Общие картинки", "SessionParameter": "Параметры сеанса", "Role": "Роли",
|
"CommonPicture": "Общие картинки", "SessionParameter": "Параметры сеанса", "Role": "Роли",
|
||||||
"CommonTemplate": "Общие макеты", "FilterCriterion": "Критерии отбора", "CommonModule": "Общие модули",
|
"CommonTemplate": "Общие макеты", "FilterCriterion": "Критерии отбора", "CommonModule": "Общие модули",
|
||||||
|
"Bot": "Боты",
|
||||||
"CommonAttribute": "Общие реквизиты", "ExchangePlan": "Планы обмена", "XDTOPackage": "XDTO-пакеты",
|
"CommonAttribute": "Общие реквизиты", "ExchangePlan": "Планы обмена", "XDTOPackage": "XDTO-пакеты",
|
||||||
"WebService": "Веб-сервисы", "HTTPService": "HTTP-сервисы", "WSReference": "WS-ссылки",
|
"WebService": "Веб-сервисы", "HTTPService": "HTTP-сервисы", "WSReference": "WS-ссылки",
|
||||||
"EventSubscription": "Подписки на события", "ScheduledJob": "Регламентные задания",
|
"EventSubscription": "Подписки на события", "ScheduledJob": "Регламентные задания",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# cf-validate v1.3 — Validate 1C configuration root structure
|
# cf-validate v1.4 — Validate 1C configuration root structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -104,11 +104,11 @@ $validClassIds = @(
|
|||||||
"fb282519-d103-4dd3-bc12-cb271d631dfc" # home page / client app interface
|
"fb282519-d103-4dd3-bc12-cb271d631dfc" # home page / client app interface
|
||||||
)
|
)
|
||||||
|
|
||||||
# 44 types in canonical order
|
# 45 types in canonical order
|
||||||
$childObjectTypes = @(
|
$childObjectTypes = @(
|
||||||
"Language","Subsystem","StyleItem","Style",
|
"Language","Subsystem","StyleItem","Style",
|
||||||
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
"CommonPicture","SessionParameter","Role","CommonTemplate",
|
||||||
"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
|
"FilterCriterion","CommonModule","Bot","CommonAttribute","ExchangePlan",
|
||||||
"XDTOPackage","WebService","HTTPService","WSReference",
|
"XDTOPackage","WebService","HTTPService","WSReference",
|
||||||
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
|
||||||
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
"FunctionalOptionsParameter","DefinedType","CommonCommand","CommandGroup",
|
||||||
@@ -125,6 +125,7 @@ $childTypeDirMap = @{
|
|||||||
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
|
||||||
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"
|
||||||
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
"CommonTemplate"="CommonTemplates"; "FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"
|
||||||
|
"Bot"="Bots"
|
||||||
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
"CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"; "XDTOPackage"="XDTOPackages"
|
||||||
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
"WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
|
||||||
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# cf-validate v1.3 — Validate 1C configuration XML structure
|
# cf-validate v1.4 — Validate 1C configuration XML structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
"""Validates Configuration.xml: root structure, InternalInfo, properties, ChildObjects, languages."""
|
||||||
import sys, os, argparse, re
|
import sys, os, argparse, re
|
||||||
@@ -33,11 +33,11 @@ VALID_CLASS_IDS = [
|
|||||||
'fb282519-d103-4dd3-bc12-cb271d631dfc', # home page / client app interface
|
'fb282519-d103-4dd3-bc12-cb271d631dfc', # home page / client app interface
|
||||||
]
|
]
|
||||||
|
|
||||||
# 44 types in canonical order
|
# 45 types in canonical order
|
||||||
CHILD_OBJECT_TYPES = [
|
CHILD_OBJECT_TYPES = [
|
||||||
'Language', 'Subsystem', 'StyleItem', 'Style',
|
'Language', 'Subsystem', 'StyleItem', 'Style',
|
||||||
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
'CommonPicture', 'SessionParameter', 'Role', 'CommonTemplate',
|
||||||
'FilterCriterion', 'CommonModule', 'CommonAttribute', 'ExchangePlan',
|
'FilterCriterion', 'CommonModule', 'Bot', 'CommonAttribute', 'ExchangePlan',
|
||||||
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
'XDTOPackage', 'WebService', 'HTTPService', 'WSReference',
|
||||||
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
'EventSubscription', 'ScheduledJob', 'SettingsStorage', 'FunctionalOption',
|
||||||
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
'FunctionalOptionsParameter', 'DefinedType', 'CommonCommand', 'CommandGroup',
|
||||||
@@ -54,6 +54,7 @@ CHILD_TYPE_DIR_MAP = {
|
|||||||
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
'Language': 'Languages', 'Subsystem': 'Subsystems', 'StyleItem': 'StyleItems', 'Style': 'Styles',
|
||||||
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
'CommonPicture': 'CommonPictures', 'SessionParameter': 'SessionParameters', 'Role': 'Roles',
|
||||||
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
'CommonTemplate': 'CommonTemplates', 'FilterCriterion': 'FilterCriteria', 'CommonModule': 'CommonModules',
|
||||||
|
'Bot': 'Bots',
|
||||||
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
'CommonAttribute': 'CommonAttributes', 'ExchangePlan': 'ExchangePlans', 'XDTOPackage': 'XDTOPackages',
|
||||||
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
'WebService': 'WebServices', 'HTTPService': 'HTTPServices', 'WSReference': 'WSReferences',
|
||||||
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
'EventSubscription': 'EventSubscriptions', 'ScheduledJob': 'ScheduledJobs',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-add v1.7 — Add managed form to 1C config object
|
# form-add v1.9 — Add managed form to 1C config object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -33,6 +33,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -69,10 +79,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -195,7 +208,7 @@ $supportedTypes = @(
|
|||||||
"Document", "Catalog", "DataProcessor", "Report",
|
"Document", "Catalog", "DataProcessor", "Report",
|
||||||
"ExternalDataProcessor", "ExternalReport",
|
"ExternalDataProcessor", "ExternalReport",
|
||||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||||
"ExchangePlan", "BusinessProcess", "Task"
|
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal"
|
||||||
)
|
)
|
||||||
|
|
||||||
$objectType = $null
|
$objectType = $null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-add v1.7 — Add managed form to 1C config object
|
# form-add v1.9 — Add managed form to 1C config object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -32,6 +32,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -71,6 +83,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -78,6 +93,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -256,7 +273,7 @@ def main():
|
|||||||
"Document", "Catalog", "DataProcessor", "Report",
|
"Document", "Catalog", "DataProcessor", "Report",
|
||||||
"ExternalDataProcessor", "ExternalReport",
|
"ExternalDataProcessor", "ExternalReport",
|
||||||
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
"InformationRegister", "AccumulationRegister", "ChartOfAccounts", "ChartOfCharacteristicTypes",
|
||||||
"ExchangePlan", "BusinessProcess", "Task",
|
"ExchangePlan", "BusinessProcess", "Task", "DocumentJournal",
|
||||||
]
|
]
|
||||||
|
|
||||||
object_type = None
|
object_type = None
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-compile v1.174 — Compile 1C managed form from JSON or object metadata
|
# form-compile v1.175 — Compile 1C managed form from JSON or object metadata
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[string]$JsonPath,
|
[string]$JsonPath,
|
||||||
@@ -1362,6 +1362,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -1398,10 +1408,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-compile v1.174 — Compile 1C managed form from JSON or object metadata
|
# form-compile v1.175 — Compile 1C managed form from JSON or object metadata
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import copy
|
import copy
|
||||||
@@ -34,6 +34,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -73,6 +85,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -80,6 +95,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-edit v1.3 — Edit 1C managed form elements
|
# form-edit v1.4 — Edit 1C managed form elements
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -27,6 +27,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -63,10 +73,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-edit v1.3 — Edit 1C managed form elements (Python port)
|
# form-edit v1.4 — Edit 1C managed form elements (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# form-info v1.4 — Analyze 1C managed form structure
|
# form-info v1.5 — Analyze 1C managed form structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -372,6 +372,16 @@ if ($formsIdx -ge 0 -and ($formsIdx + 1) -lt $parts.Count) {
|
|||||||
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
|
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
|
||||||
# uuid of the nearest element meta-xml (form/template/etc.) and the config root
|
# uuid of the nearest element meta-xml (form/template/etc.) and the config root
|
||||||
# bin. Never throws — degrades to "не на поддержке".
|
# bin. Never throws — degrades to "не на поддержке".
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -390,8 +400,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -448,7 +460,8 @@ if ($formTitle) { $header += " — `"$formTitle`"" }
|
|||||||
if ($objectContext) { $header += " ($objectContext)" }
|
if ($objectContext) { $header += " ($objectContext)" }
|
||||||
$header += " ==="
|
$header += " ==="
|
||||||
$lines += $header
|
$lines += $header
|
||||||
$lines += "Поддержка: $(Get-SupportStatusForPath $FormPath)"
|
$support = Get-SupportStatusForPath $FormPath
|
||||||
|
if ($null -ne $support) { $lines += "Поддержка: $support" }
|
||||||
|
|
||||||
# --- Form properties (Title excluded — shown in header) ---
|
# --- Form properties (Title excluded — shown in header) ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# form-info v1.4 — Analyze 1C managed form structure
|
# form-info v1.5 — Analyze 1C managed form structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -353,14 +353,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -513,7 +528,9 @@ def main():
|
|||||||
header += f" ({object_context})"
|
header += f" ({object_context})"
|
||||||
header += " ==="
|
header += " ==="
|
||||||
lines.append(header)
|
lines.append(header)
|
||||||
lines.append(f"Поддержка: {get_support_status_for_path(form_path)}")
|
_support = get_support_status_for_path(form_path)
|
||||||
|
if _support is not None:
|
||||||
|
lines.append(f"Поддержка: {_support}")
|
||||||
|
|
||||||
# --- Form properties (Title excluded -- shown in header) ---
|
# --- Form properties (Title excluded -- shown in header) ---
|
||||||
prop_names = [
|
prop_names = [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# help-add v1.7 — Add built-in help to 1C object
|
# help-add v1.8 — Add built-in help to 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -28,6 +28,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -64,10 +74,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# add-help v1.7 — Add built-in help to 1C object
|
# add-help v1.8 — Add built-in help to 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -33,6 +33,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -72,6 +84,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -79,6 +94,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# interface-edit v1.6 — Edit 1C CommandInterface.xml
|
# interface-edit v1.7 — Edit 1C CommandInterface.xml
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$CIPath,
|
||||||
@@ -39,6 +39,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -75,10 +85,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# interface-edit v1.6 — Edit 1C CommandInterface.xml
|
# interface-edit v1.7 — Edit 1C CommandInterface.xml
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -9,18 +9,16 @@ allowed-tools:
|
|||||||
- Glob
|
- Glob
|
||||||
---
|
---
|
||||||
|
|
||||||
# /meta-compile — генерация объектов метаданных из JSON DSL
|
# /meta-compile — генерация объектов метаданных из JSON
|
||||||
|
|
||||||
Принимает JSON-определение объекта метаданных → генерирует XML + модули в структуре выгрузки конфигурации + регистрирует в Configuration.xml.
|
Принимает JSON-определение объекта → генерирует XML + модули в структуре выгрузки конфигурации и
|
||||||
|
регистрирует объект в `Configuration.xml`.
|
||||||
|
|
||||||
## Порядок работы
|
## Порядок работы
|
||||||
|
|
||||||
1. Составь JSON по синтаксису и примерам ниже → запиши во временный файл
|
1. Составь JSON по синтаксису ниже → запиши во временный файл.
|
||||||
2. Запусти скрипт meta-compile
|
2. Запусти скрипт.
|
||||||
3. Если нужно изменить созданный объект — `/meta-edit`
|
3. Изменить созданный объект — `/meta-edit`; проверить — `/meta-validate`.
|
||||||
4. Если нужно проверить — `/meta-validate`
|
|
||||||
|
|
||||||
## Команда
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-compile.ps1" -JsonPath "<json>" -OutputDir "<ConfigDir>"
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-compile.ps1" -JsonPath "<json>" -OutputDir "<ConfigDir>"
|
||||||
@@ -28,92 +26,110 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-compile.ps1" -
|
|||||||
|
|
||||||
| Параметр | Описание |
|
| Параметр | Описание |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| `JsonPath` | Путь к JSON-файлу (один объект `{...}` или массив `[{...}, ...]`) |
|
| `JsonPath` | Путь к JSON-файлу |
|
||||||
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/` и т.д.) |
|
| `OutputDir` | Корень выгрузки конфигурации (где `Configuration.xml`, `Catalogs/`, `Documents/`, …) |
|
||||||
|
|
||||||
## JSON DSL
|
## Формат JSON
|
||||||
|
|
||||||
### Общая структура
|
**Один объект** `{ ... }` или **массив** объектов `[{ ... }, { ... }]` (batch — несколько объектов за прогон).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "Catalog", "name": "Номенклатура", ...свойства типа... }
|
{ "type": "Catalog", "name": "Номенклатура", "...свойства типа...": "..." }
|
||||||
```
|
```
|
||||||
|
|
||||||
`type` и `name` — обязательные. `synonym` генерируется из `name` автоматически (CamelCase → слова через пробел). Можно задать явно: `"synonym": "Мой синоним"`.
|
`type` и `name` — обязательные. Остальное — по типу (см. индекс ниже). `synonym` по умолчанию выводится из
|
||||||
|
`name` (CamelCase → слова через пробел); можно задать явно строкой или мультиязычно: `"synonym": { "ru": "…", "en": "…" }`.
|
||||||
|
|
||||||
### Shorthand реквизитов
|
## Реквизиты (shorthand)
|
||||||
|
|
||||||
Используется в `attributes`, `dimensions`, `resources`, `tabularSections`:
|
Массивы `attributes`, `dimensions`, `resources` и колонки в `tabularSections` задаются строками:
|
||||||
|
|
||||||
```
|
```
|
||||||
"ИмяРеквизита" → String(10) по умолчанию
|
"Имя" → String(10)
|
||||||
"ИмяРеквизита: Тип" → с типом
|
"Имя: Тип" → с типом
|
||||||
"ИмяРеквизита: Тип | req, index" → с флагами
|
"Имя: Тип | req, index" → с флагами
|
||||||
```
|
```
|
||||||
|
|
||||||
Типы: `String(100)`, `Number(15,2)`, `Boolean`, `Date`, `DateTime`, `CatalogRef.Xxx`, `DocumentRef.Xxx`, `EnumRef.Xxx`, `DefinedType.Xxx` и др. ссылочные.
|
**Типы:** `String(100)`, `String(10, fixed)` (фикс. длина), `Number(15,2)`, `Boolean`, `Date`, `DateTime`,
|
||||||
|
`Time`, ссылочные `CatalogRef.Xxx` / `DocumentRef.Xxx` / `EnumRef.Xxx` / `DefinedType.Xxx` и т.п.
|
||||||
|
Составной тип — через `+`: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
||||||
|
|
||||||
Составной тип: `"Значение: String + Number(15,2) + CatalogRef.Контрагенты"`.
|
**Флаги** (после `|`, через запятую):
|
||||||
|
|
||||||
Флаги: `req`, `index`, `indexAdditional`, `nonneg`, `master`, `mainFilter`, `denyIncomplete`, `useInTotals`.
|
| Флаг | Значение | Где |
|
||||||
|
|------|----------|-----|
|
||||||
|
| `req` | обязательное заполнение | attributes, dimensions, resources |
|
||||||
|
| `index` | индексировать | attributes, dimensions |
|
||||||
|
| `indexAdditional` | индекс с доп. упорядочиванием | attributes |
|
||||||
|
| `multiline` | многострочное поле | attributes |
|
||||||
|
| `nonneg` | неотрицательное (Number) | attributes, resources |
|
||||||
|
| `master` | ведущее измерение | dimensions (регистры) |
|
||||||
|
| `mainFilter` | основной отбор | dimensions (регистры) |
|
||||||
|
| `denyIncomplete` | запрет незаполненных | dimensions |
|
||||||
|
| `useInTotals` | использовать в итогах | dimensions (регистр накопления) |
|
||||||
|
|
||||||
### Свойства по типам
|
Реквизиту нужны свойства сверх shorthand (значение заполнения, параметры выбора, формат, подсказка, …) —
|
||||||
|
задаётся **объектной формой**, см. `reference/attributes.md`.
|
||||||
|
|
||||||
Примеров и shorthand-синтаксиса выше достаточно для типовых задач. Если нужны свойства типа, не показанные в примерах, и их допустимые значения — см. reference-файл:
|
## Табличные части
|
||||||
|
|
||||||
- `reference/types-basic.md` — Catalog, Document, Enum, Constant, DefinedType, Report, DataProcessor
|
|
||||||
- `reference/types-registers.md` — InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes
|
|
||||||
- `reference/types-process.md` — BusinessProcess, Task, ExchangePlan, CommonModule, ScheduledJob, EventSubscription, DocumentJournal
|
|
||||||
- `reference/types-web.md` — HTTPService, WebService
|
|
||||||
|
|
||||||
Эта инструкция и reference-файлы — полная документация для генерации. Не ищи примеры XML в выгрузках конфигураций.
|
|
||||||
|
|
||||||
## Примеры паттернов DSL
|
|
||||||
|
|
||||||
### Минимальный объект
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "type": "Catalog", "name": "Валюты" }
|
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
### С реквизитами
|
Ключ — имя ТЧ, значение — массив колонок (shorthand) ЛИБО объект со свойствами ТЧ (см. `reference/attributes.md`).
|
||||||
|
|
||||||
|
## Индекс: свойства по типам
|
||||||
|
|
||||||
|
Для каждого типа — свой reference-файл со свойствами, дефолтами и допустимыми значениями:
|
||||||
|
|
||||||
|
| Тип(ы) | Файл |
|
||||||
|
|--------|------|
|
||||||
|
| Catalog (справочник) | `reference/catalog.md` |
|
||||||
|
| Document, DocumentJournal, Sequence, DocumentNumerator | `reference/document.md` |
|
||||||
|
| InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister | `reference/registers.md` |
|
||||||
|
| ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes | `reference/charts.md` |
|
||||||
|
| ExchangePlan | `reference/exchangeplan.md` |
|
||||||
|
| BusinessProcess, Task | `reference/process.md` |
|
||||||
|
| Report, DataProcessor | `reference/report-dataprocessor.md` |
|
||||||
|
| CommonModule, ScheduledJob, EventSubscription | `reference/code.md` |
|
||||||
|
| HTTPService, WebService | `reference/web.md` |
|
||||||
|
| Enum, Constant, DefinedType | `reference/simple.md` |
|
||||||
|
| FunctionalOption, FilterCriterion, SettingsStorage, CommonForm, CommonPicture, CommonTemplate, служебные | `reference/other-types.md` |
|
||||||
|
|
||||||
|
Кросс-типовые детали:
|
||||||
|
- **`reference/attributes.md`** — объектная форма реквизита и колонки ТЧ (значение заполнения, параметры
|
||||||
|
выбора, формат, подсказка, границы, …) + свойства самой ТЧ.
|
||||||
|
- **`reference/blocks.md`** — блоки объекта: представления, команды (+ характеристики/стандартные реквизиты).
|
||||||
|
|
||||||
|
Эта инструкция и reference-файлы — полная документация. Не ищи примеры XML в выгрузках конфигураций.
|
||||||
|
|
||||||
|
## Примеры
|
||||||
|
|
||||||
|
Справочник с реквизитами:
|
||||||
```json
|
```json
|
||||||
{
|
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||||
"type": "Catalog", "name": "Организации",
|
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"] }
|
||||||
"descriptionLength": 100,
|
|
||||||
"attributes": ["ИНН: String(12)", "КПП: String(9)", "Директор: CatalogRef.ФизическиеЛица"]
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### С табличной частью
|
Документ с движениями и ТЧ:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||||
"type": "Document", "name": "ПриходнаяНакладная",
|
|
||||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
||||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] }
|
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)", "Цена: Number(15,2)"] } }
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Регистровый паттерн (измерения + ресурсы)
|
Регистр сведений:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||||
"type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
|
||||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"]
|
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Batch — несколько объектов в одном файле
|
Batch:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[ { "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
||||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "Закрыт"] },
|
|
||||||
{ "type": "Catalog", "name": "Валюты" },
|
{ "type": "Catalog", "name": "Валюты" },
|
||||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" } ]
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Объектная форма реквизита и табличной части
|
||||||
|
|
||||||
|
Когда реквизиту (в `attributes` / `dimensions` / `resources` / колонках ТЧ) нужны свойства сверх
|
||||||
|
shorthand — вместо строки задаётся объект:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "Цена", "type": "Number(15,2)", "tooltip": "Цена за единицу", "fillValue": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` и `type` обязательны (тип можно задать и раздельно: `"type": "Number", "length": 15, "precision": 2`).
|
||||||
|
Остальные ключи — ниже, все со значением по умолчанию (не задавать, если устраивает дефолт).
|
||||||
|
|
||||||
|
## Свойства реквизита
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `synonym` | из имени | ML (строка или `{ru,en}`) |
|
||||||
|
| `tooltip` | пусто | ML |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (то же, что флаг `req`) |
|
||||||
|
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||||
|
| `fillFromFillingValue` | `false` | bool |
|
||||||
|
| `fillValue` | по типу (см. ниже) | значение заполнения |
|
||||||
|
| `createOnInput` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `quickChoice` | `Auto` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||||
|
| `dataHistory` | `Use` | `Use` / `DontUse` |
|
||||||
|
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (реквизит иерархического справочника) |
|
||||||
|
| `passwordMode` | `false` | bool |
|
||||||
|
| `multiLine` | `false` | bool (то же, что флаг `multiline`) |
|
||||||
|
| `extendedEdit` | `false` | bool (расширенное редактирование — многострочный ввод) |
|
||||||
|
| `mask` | пусто | строка маски ввода |
|
||||||
|
| `format` / `editFormat` | пусто | форматная строка 1С (ML) |
|
||||||
|
| `markNegatives` | `false` | bool (выделять отрицательные, для Number) |
|
||||||
|
| `minValue` / `maxValue` | не задано | граница диапазона (см. ниже) |
|
||||||
|
| `choiceParameterLinks` | пусто | связи параметров выбора (см. ниже) |
|
||||||
|
| `choiceParameters` | пусто | параметры выбора (см. ниже) |
|
||||||
|
| `choiceForm` | пусто | ссылка на форму выбора `Тип.Объект.Form.ИмяФормы` |
|
||||||
|
| `choiceFoldersAndItems` | `Items` | `Items` / `Folders` / `FoldersAndItems` (что выбирать в иерарх. справочнике) |
|
||||||
|
|
||||||
|
Индексирование задаётся флагом `index` / `indexAdditional` в shorthand, либо в объекте — как и в строковой форме,
|
||||||
|
через `"type": "… | index"`.
|
||||||
|
|
||||||
|
### `fillValue` — значение заполнения
|
||||||
|
|
||||||
|
Пустое значение по типу компилятор подставляет сам — ключ **не задают**:
|
||||||
|
|
||||||
|
| Тип реквизита | Пустое значение |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| String | пустая строка |
|
||||||
|
| Number | `0` |
|
||||||
|
| Boolean, Date, ссылочный, составной | не задано (nil) |
|
||||||
|
|
||||||
|
Ключ `fillValue` задают для **конкретного** значения — интерпретируется по типу реквизита:
|
||||||
|
|
||||||
|
- **Boolean** — `true` / `false`.
|
||||||
|
- **Number** — число (`21`, `1.5`).
|
||||||
|
- **String** — строка.
|
||||||
|
- **Date** — ISO-строка `"2020-01-01T00:00:00"`.
|
||||||
|
- **Ссылочный** — путь: `"Catalog.Валюты.EmptyRef"` (пустая ссылка), `"Enum.Периодичность.EnumValue.Месяц"`
|
||||||
|
(значение перечисления), `"Catalog.СтраныМира.Россия"` (предопределённый элемент).
|
||||||
|
- **`null`** — явно «значение не задано» (nil), когда нужно перекрыть непустой дефолт типа.
|
||||||
|
- **`{ "emptyRef": true }`** — пустая ссылка для реквизита типа `DefinedType.X` (когда тип из пути не выводится).
|
||||||
|
|
||||||
|
> Пустая ссылка (`EmptyRef`) и `null` — разное: платформа хранит их отдельно.
|
||||||
|
|
||||||
|
### `minValue` / `maxValue` — границы диапазона
|
||||||
|
|
||||||
|
Число → числовая граница; строка → строковая (напр. год `"2000"`). Без ключа — граница не задана.
|
||||||
|
|
||||||
|
### `choiceParameterLinks` — связи параметров выбора
|
||||||
|
|
||||||
|
Связывают параметр выбора этого реквизита с другим реквизитом объекта. Массив строк или объектов:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"choiceParameterLinks": ["Отбор.Организация=Организация", "Отбор.Договор=Договор:DontChange"]
|
||||||
|
"choiceParameterLinks": [{ "name": "Отбор.Организация", "dataPath": "Организация", "valueChange": "Clear" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `dataPath` — реквизит **того же объекта**: имя обычного реквизита (`"Организация"`) или стандартного
|
||||||
|
(`"Владелец"`, `"Ссылка"`).
|
||||||
|
- `valueChange` — `Clear` (по умолчанию) / `DontChange`.
|
||||||
|
|
||||||
|
### `choiceParameters` — параметры выбора
|
||||||
|
|
||||||
|
Фиксируют параметр выбора значением. Массив строк или объектов:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"choiceParameters": ["Отбор.ЭтоГруппа=false"]
|
||||||
|
"choiceParameters": [{ "name": "Отбор.Владелец", "value": "Catalog.Организации.EmptyRef" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `value` — bool / число / строка / ссылочный путь (несёт тип) ИЛИ массив (список фиксированных значений).
|
||||||
|
- Для набора голых имён-значений добавьте `type` (тип поля-фильтра), чтобы они стали ссылками:
|
||||||
|
`{ "name": "Отбор.Тип", "type": "EnumRef.ТипыВЕТИС", "value": ["EmptyRef", "ТТН"] }`.
|
||||||
|
|
||||||
|
### Редкие ключи
|
||||||
|
|
||||||
|
`linkByType` — связь по типу (тип реквизита-Характеристики берётся из другого реквизита):
|
||||||
|
`{ "dataPath": "Свойство", "linkItem": 0 }` или строка-путь. Применяется для реквизитов-характеристик.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Табличная часть — объектная форма
|
||||||
|
|
||||||
|
Значение в `tabularSections` — массив колонок ЛИБО объект со свойствами самой ТЧ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"tabularSections": {
|
||||||
|
"Товары": {
|
||||||
|
"synonym": { "ru": "Товары", "en": "Goods" },
|
||||||
|
"tooltip": "Строки заказа",
|
||||||
|
"fillChecking": "ShowError",
|
||||||
|
"attributes": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `synonym` | из имени | ML |
|
||||||
|
| `tooltip` | пусто | ML |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `fillChecking` | `DontCheck` | `DontCheck` / `ShowError` / `ShowWarning` (обязательность заполнения ТЧ) |
|
||||||
|
| `use` | `ForItem` | `ForItem` / `ForFolder` / `ForFolderAndItem` (только Catalog / ChartOfCharacteristicTypes) |
|
||||||
|
| `attributes` | `[]` | колонки (shorthand или объектная форма реквизита) |
|
||||||
|
| `lineNumber` | — | кастомизация стандартного реквизита НомерСтроки (см. ниже) |
|
||||||
|
|
||||||
|
### `lineNumber` — стандартный реквизит НомерСтроки
|
||||||
|
|
||||||
|
У каждой ТЧ есть стандартный реквизит НомерСтроки. По умолчанию все его свойства типовые. Ключ `lineNumber`
|
||||||
|
на объектной форме ТЧ их переопределяет:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"Строки": { "lineNumber": { "synonym": "Номер п/п", "fullTextSearch": "DontUse" }, "attributes": [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Переопределяемые: `synonym`, `comment`, `fullTextSearch` (`Use`/`DontUse`), `tooltip`, `format`, `editFormat`,
|
||||||
|
`choiceHistoryOnInput` (`Auto`/`DontUse`).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Блоки объекта
|
||||||
|
|
||||||
|
Кросс-типовые блоки уровня объекта (применимы к ссылочным типам — Catalog, Document, ChartOf*, ExchangePlan,
|
||||||
|
BusinessProcess, Task и др.).
|
||||||
|
|
||||||
|
## Представления
|
||||||
|
|
||||||
|
Тексты представления объекта в интерфейсе (ML — строка или `{ru,en}`, по умолчанию пусто):
|
||||||
|
|
||||||
|
| Ключ | Смысл |
|
||||||
|
|------|-------|
|
||||||
|
| `objectPresentation` | представление объекта |
|
||||||
|
| `extendedObjectPresentation` | расширенное представление объекта |
|
||||||
|
| `listPresentation` | представление списка |
|
||||||
|
| `extendedListPresentation` | расширенное представление списка |
|
||||||
|
| `explanation` | пояснение |
|
||||||
|
|
||||||
|
Набор доступных ключей зависит от типа (у списочных без формы объекта нет `objectPresentation` и т.п.).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"listPresentation": "Организации", "objectPresentation": { "ru": "Организация", "en": "Company" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
Команды объекта. Ключ — имя команды, значение — объект свойств (map `имя → объект` или массив `[{name, …}]`).
|
||||||
|
Для каждой команды создаётся заготовка модуля с обработчиком `ОбработкаКоманды`.
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `synonym` | из имени | ML |
|
||||||
|
| `tooltip` | пусто | ML |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `group` | **обязательно** | группа размещения (см. ниже) |
|
||||||
|
| `commandParameterType` | пусто | тип параметра (напр. `CatalogRef.Номенклатура`) — **только для групп формы** |
|
||||||
|
| `parameterUseMode` | `Single` | `Single` / `Multiple` |
|
||||||
|
| `modifiesData` | `false` | bool |
|
||||||
|
| `representation` | `Auto` | вид отображения |
|
||||||
|
| `picture` | пусто | ссылка на картинку (`StdPicture.Print`, `CommonPicture.Загрузка`) |
|
||||||
|
| `shortcut` | пусто | сочетание клавиш |
|
||||||
|
|
||||||
|
```json
|
||||||
|
"commands": {
|
||||||
|
"ПечатьЭтикеток": { "synonym": "Печать этикеток", "group": "FormCommandBarImportant",
|
||||||
|
"commandParameterType": "CatalogRef.Номенклатура", "picture": "StdPicture.Print" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Группа (`group`) обязательна** — каждая команда размещается в группе командного интерфейса:
|
||||||
|
|
||||||
|
- **Командный интерфейс раздела** (панель навигации / панель действий; `commandParameterType` **недоступен**):
|
||||||
|
`NavigationPanelImportant` / `NavigationPanelOrdinary` / `NavigationPanelSeeAlso`,
|
||||||
|
`ActionsPanelCreate` / `ActionsPanelReports` / `ActionsPanelTools`.
|
||||||
|
- **Командный интерфейс формы** (`commandParameterType` допустим): `FormCommandBarImportant` /
|
||||||
|
`FormCommandBarCreateBasedOn`, `FormNavigationPanelImportant` / `FormNavigationPanelGoTo` / `FormNavigationPanelSeeAlso`.
|
||||||
|
- **Кастомная группа:** `CommandGroup.<Имя>` (параметр допустим).
|
||||||
|
|
||||||
|
Группа раздела вместе с `commandParameterType` → ошибка.
|
||||||
|
|
||||||
|
## `inputByString` / `dataLockFields` / `basedOn`
|
||||||
|
|
||||||
|
Списки полей/объектов уровня объекта. Поля — по имени реквизита объекта (обычного или стандартного).
|
||||||
|
|
||||||
|
- **`inputByString`** — поля быстрого ввода по строке. По умолчанию выводятся из Кода/Наименования — ключ не нужен;
|
||||||
|
задать при другом наборе/порядке, либо `[]` для отключения.
|
||||||
|
```json
|
||||||
|
"inputByString": ["Код", "Наименование", "Контрагент"]
|
||||||
|
```
|
||||||
|
- **`dataLockFields`** — поля управляемой блокировки данных (по умолчанию пусто).
|
||||||
|
```json
|
||||||
|
"dataLockFields": ["Организация", "Контрагент"]
|
||||||
|
```
|
||||||
|
- **`basedOn`** — «ввод на основании»: список ссылок на объекты метаданных (по умолчанию пусто).
|
||||||
|
```json
|
||||||
|
"basedOn": ["Catalog.Контрагенты", "Document.ЗаказПоставщику"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## `standardAttributes` — кастомизация стандартных реквизитов
|
||||||
|
|
||||||
|
Стандартные реквизиты объекта (Наименование, Код, Владелец, …) переопределяются блоком
|
||||||
|
`standardAttributes` — объект `{ ИмяРеквизита: { переопределения } }`. Имена — как в 1С: `Description`, `Code`,
|
||||||
|
`Owner`, `Parent`, `DeletionMark`, `Ref` и т.д. (для Document — `Date`, `Number`, `Posted`).
|
||||||
|
|
||||||
|
Переопределяемые поля — как у обычного реквизита (`synonym`, `tooltip`, `fillChecking`, `fillValue`,
|
||||||
|
`choiceParameters`, `comment`, `mask`, `choiceForm`; полный набор — `attributes.md`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"standardAttributes": {
|
||||||
|
"Description": { "synonym": "Наименование контрагента" },
|
||||||
|
"Code": { "fillChecking": "ShowError" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `characteristics` — «Дополнительные реквизиты и сведения»
|
||||||
|
|
||||||
|
Привязка плана видов характеристик. Массив; каждый элемент связывает **источник типов** (где определены
|
||||||
|
характеристики) и **источник значений** (где хранятся значения).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"characteristics": [{
|
||||||
|
"types": { "from": "Catalog.НаборыДопРеквизитов.ДополнительныеРеквизиты",
|
||||||
|
"key": "Свойство", "filterField": "Ссылка", "filterValue": "Справочник_Организации" },
|
||||||
|
"values": { "from": "Catalog.Организации.TabularSection.ДополнительныеРеквизиты",
|
||||||
|
"object": "Ссылка", "type": "Свойство", "value": "Значение" }
|
||||||
|
}]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `from` — таблица-источник; `key`/`filterField`/`object`/`type`/`value` — поля источника (по имени реквизита).
|
||||||
|
- `filterValue` — значение фильтра типов: имя предопределённого набора (строка) или путь к элементу.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Catalog (Справочник)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Catalog", "name": "Организации", "descriptionLength": 100,
|
||||||
|
"attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Свойства
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `hierarchical` | `false` | bool |
|
||||||
|
| `hierarchyType` | `HierarchyFoldersAndItems` | `HierarchyFoldersAndItems` / `HierarchyOfItems` |
|
||||||
|
| `limitLevelCount` | `false` | bool (ограничивать кол-во уровней) |
|
||||||
|
| `levelCount` | `2` | число уровней (при `limitLevelCount`) |
|
||||||
|
| `foldersOnTop` | `true` | bool (группы сверху) |
|
||||||
|
| `owners` | `[]` | массив ссылок-владельцев: `["CatalogRef.Контрагенты"]` |
|
||||||
|
| `subordinationUse` | `ToItems` | `ToItems` / `ToFolders` / `ToFoldersAndItems` (кому подчинён) |
|
||||||
|
| `codeLength` | `9` | длина кода (0 — без кода) |
|
||||||
|
| `codeType` | `String` | `String` / `Number` |
|
||||||
|
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||||
|
| `codeSeries` | `WholeCatalog` | `WholeCatalog` / `WithinSubordination` / `WithinOwnerSubordination` |
|
||||||
|
| `autonumbering` | `true` | bool (автонумерация) |
|
||||||
|
| `checkUnique` | `false` | bool (контроль уникальности кода) |
|
||||||
|
| `descriptionLength` | `25` | длина наименования |
|
||||||
|
| `defaultPresentation` | `AsDescription` | `AsDescription` / `AsCode` |
|
||||||
|
| `quickChoice` | `true` | bool (быстрый выбор) |
|
||||||
|
| `choiceMode` | `BothWays` | `BothWays` / `QuickChoice` / `FromForm` |
|
||||||
|
| `editType` | `InDialog` | `InDialog` / `InList` / `BothWays` |
|
||||||
|
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `choiceHistoryOnInput` | `Auto` | `Auto` / `DontUse` |
|
||||||
|
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||||
|
| `fullTextSearchOnInputByString` | `DontUse` | `Use` / `DontUse` |
|
||||||
|
| `searchStringModeOnInputByString` | `Begin` | `Begin` / `AnyPart` |
|
||||||
|
| `predefinedDataUpdate` | `Auto` | `Auto` / `DontAutoUpdate` / `AutoUpdate` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `useStandardCommands` | `true` | bool |
|
||||||
|
| `includeHelpInContents` | `false` | bool |
|
||||||
|
| `attributes` | `[]` | реквизиты (shorthand / объектная форма) |
|
||||||
|
| `tabularSections` | `{}` | табличные части |
|
||||||
|
|
||||||
|
**Формы.** Ссылка на форму — `Тип.Объект.Form.ИмяФормы` (напр. `Catalog.Организации.Form.ФормаЭлемента`).
|
||||||
|
Слоты основных форм: `defaultObjectForm`, `defaultFolderForm`, `defaultListForm`, `defaultChoiceForm`,
|
||||||
|
`defaultFolderChoiceForm`; вспомогательных — те же имена с префиксом `auxiliary` (`auxiliaryObjectForm`, …).
|
||||||
|
|
||||||
|
## `predefined` — предопределённые элементы
|
||||||
|
|
||||||
|
Массив предопределённых элементов → `Ext/Predefined.xml`. Элемент — строка (плоский случай) или объект (иерархия).
|
||||||
|
|
||||||
|
**Строка:** `"(Код) Имя [Наименование]"` — `Имя` обязательно; `(Код)` и `[Наименование]` опциональны.
|
||||||
|
Без `[...]` наименование выводится из имени; `[]` — пустое; `[текст]` — заданное.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"predefined": [
|
||||||
|
"Основной",
|
||||||
|
"(1) ДокументОПриемке [Документ о приемке]",
|
||||||
|
{ "name": "Группа1", "isFolder": true, "description": "Прочие",
|
||||||
|
"childItems": ["Факс", "(7) Скайп"] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Объект:** `name` (обязательно), `code`, `description` (наименование), `isFolder` (признак группы),
|
||||||
|
`childItems` (вложенные, рекурсивно). Тип кода — по свойству `codeType`.
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
- Свойства реквизитов и табличных частей — `attributes.md`.
|
||||||
|
- Представления (`objectPresentation`, `listPresentation`, …), команды объекта, характеристики
|
||||||
|
(«ДопРеквизиты и сведения»), кастомизация стандартных реквизитов, `inputByString` / `dataLockFields` /
|
||||||
|
`basedOn` — `blocks.md`.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Планы: ChartOfCharacteristicTypes, ChartOfAccounts, ChartOfCalculationTypes
|
||||||
|
|
||||||
|
Все три — ссылочные типы (наследуют слой Catalog: коды, `standardAttributes`, `characteristics`, `inputByString`,
|
||||||
|
формы, представления — см. `catalog.md` / `attributes.md` / `blocks.md`) с предопределёнными элементами и своими
|
||||||
|
специальными свойствами.
|
||||||
|
|
||||||
|
## ChartOfCharacteristicTypes (План видов характеристик)
|
||||||
|
|
||||||
|
Хранит определения характеристик (видов). Иерархический (папки+элементы).
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `valueType` | любой примитив | тип значения характеристики (составной — строка `"A + B"` или массив `valueTypes`) |
|
||||||
|
| `characteristicExtValues` | пусто | ссылка на справочник доп. значений |
|
||||||
|
| `hierarchical` | `false` | bool |
|
||||||
|
| `foldersOnTop` | `true` | bool |
|
||||||
|
| `codeLength` | `9` | длина кода |
|
||||||
|
| `descriptionLength` | `100` | длина наименования |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
|
| `autonumbering` | `true` | bool |
|
||||||
|
| `codeSeries` | `WholeCharacteristicKind` | серия кодов |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `predefined` | `[]` | предопределённые виды (несут тип значения — см. ниже) |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
**Предопределённые виды** несут **тип значения на элемент** — короткой строкой после `:`
|
||||||
|
(`"(Код) Имя [Наименование]: Тип"`, составной через `+`) или объектной формой с ключом `type`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"predefined": [
|
||||||
|
"(000001) Цвет: CatalogRef.Цвета",
|
||||||
|
"(000002) Размер [Размер одежды]: String(50) + Number(3,0)",
|
||||||
|
{ "name": "Группа", "isFolder": true, "type": "" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## ChartOfAccounts (План счетов)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `extDimensionTypes` | пусто | ссылка на ПВХ видов субконто `ChartOfCharacteristicTypes.X` |
|
||||||
|
| `maxExtDimensionCount` | `0` (без ПВХ) / `3` (с ПВХ) | макс. число субконто |
|
||||||
|
| `codeMask` | пусто | маска кода счёта (напр. `"@@@.@@"`) |
|
||||||
|
| `codeLength` | `9` | длина кода |
|
||||||
|
| `descriptionLength` | `25` | длина наименования |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
|
| `codeSeries` | `WholeChartOfAccounts` | серия кодов |
|
||||||
|
| `defaultPresentation` | `AsCode` | `AsCode` / `AsDescription` |
|
||||||
|
| `autoOrderByCode` | `true` | bool |
|
||||||
|
| `orderLength` | `9` | длина строки упорядочивания |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `accountingFlags` | `[]` | признаки учёта (как реквизиты, тип по умолчанию Boolean; массив имён/реквизитов) |
|
||||||
|
| `extDimensionAccountingFlags` | `[]` | признаки учёта субконто (как реквизиты) |
|
||||||
|
| `predefined` | `[]` | предопределённые счета (см. ниже) |
|
||||||
|
|
||||||
|
**Предопределённый счёт** (объектная форма):
|
||||||
|
|
||||||
|
| Поле | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `name` | — | имя (обязательно) |
|
||||||
|
| `code` | пусто | код счёта |
|
||||||
|
| `description` | из имени | наименование |
|
||||||
|
| `accountType` | `ActivePassive` | `Active` / `Passive` / `ActivePassive` |
|
||||||
|
| `offBalance` | `false` | bool (забалансовый) |
|
||||||
|
| `order` | — | строка сортировки |
|
||||||
|
| `flags` | `[]` | включённые признаки учёта (только TRUE) |
|
||||||
|
| `subconto` | `[]` | виды субконто (см. ниже) |
|
||||||
|
| `childItems` | `[]` | подчинённые счета |
|
||||||
|
|
||||||
|
`subconto` — строка `"Вид | Признак1, Признак2"` (после `|` — включённые признаки учёта субконто; токен `Turnover` —
|
||||||
|
«только обороты») или объект `{ type, turnover, flags }`. `Вид` — имя предопределённого вида из ПВХ `extDimensionTypes`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"predefined": [
|
||||||
|
{ "name": "ОсновныеСредства", "code": "01", "accountType": "Active", "order": " 01",
|
||||||
|
"flags": ["Количественный"], "subconto": ["Номенклатура | Суммовой, Валютный"],
|
||||||
|
"childItems": [ { "name": "ОСВОрганизации", "code": "01.01", "accountType": "Active", "order": " 01.01" } ] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## ChartOfCalculationTypes (План видов расчёта)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `codeLength` | `5` | длина кода |
|
||||||
|
| `descriptionLength` | `100` | длина наименования |
|
||||||
|
| `codeAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||||
|
| `dependenceOnCalculationTypes` | `DontUse` | `DontUse` / `OnPeriod` / `OnActionPeriod` |
|
||||||
|
| `baseCalculationTypes` | `[]` | базовые виды расчёта (список ссылок `ChartOfCalculationTypes.X`) |
|
||||||
|
| `actionPeriodUse` | `false` | bool (использовать период действия) |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `predefined` | `[]` | предопределённые виды расчёта (см. ниже) |
|
||||||
|
|
||||||
|
**Предопределённый вид расчёта** — плоский: строка `"(Код) Имя [Наименование]"` или объект
|
||||||
|
`{ name, code, description, actionPeriodIsBase }` (`actionPeriodIsBase` — bool, по умолчанию `false`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"predefined": [ "(00001) Оклад [Оклад по дням]", { "name": "Премия", "code": "00002", "actionPeriodIsBase": true } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **ChartOfAccounts** ссылается на ПВХ через `extDimensionTypes`. Регистр бухгалтерии/расчёта требует
|
||||||
|
> соответствующий план (см. `registers.md`).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# CommonModule, ScheduledJob, EventSubscription (объекты, привязанные к коду)
|
||||||
|
|
||||||
|
## CommonModule (Общий модуль)
|
||||||
|
|
||||||
|
Флаги контекста выполнения (все bool, по умолчанию `false`). Создаёт пустой `Ext/Module.bsl`.
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `context` | — | шорткат флагов (см. ниже) |
|
||||||
|
| `global` | `false` | bool |
|
||||||
|
| `server` | `false` | bool |
|
||||||
|
| `serverCall` | `false` | bool (вызов сервера) |
|
||||||
|
| `clientManagedApplication` | `false` | bool (клиент управляемого приложения) |
|
||||||
|
| `clientOrdinaryApplication` | `false` | bool (клиент обычного приложения) |
|
||||||
|
| `externalConnection` | `false` | bool |
|
||||||
|
| `privileged` | `false` | bool |
|
||||||
|
| `returnValuesReuse` | `DontUse` | `DontUse` / `DuringRequest` / `DuringSession` |
|
||||||
|
|
||||||
|
Шорткат `context`: `"server"` → Server+ServerCall; `"client"` → ClientManagedApplication;
|
||||||
|
`"serverClient"` → Server+ClientManagedApplication.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "CommonModule", "name": "ОбменДаннымиСервер", "context": "server", "returnValuesReuse": "DuringRequest" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## ScheduledJob (Регламентное задание)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `methodName` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||||
|
| `description` | пусто | наименование задания |
|
||||||
|
| `key` | пусто | ключ |
|
||||||
|
| `use` | `false` | bool (использование) |
|
||||||
|
| `predefined` | `false` | bool (предопределённое) |
|
||||||
|
| `restartCountOnFailure` | `3` | число повторов при сбое |
|
||||||
|
| `restartIntervalOnFailure` | `10` | интервал повтора, сек |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить", "use": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## EventSubscription (Подписка на событие)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `source` | `[]` | объекты-источники: `["CatalogObject.Контрагенты", "DocumentObject.Реализация"]` |
|
||||||
|
| `event` | `BeforeWrite` | `BeforeWrite` / `OnWrite` / `BeforeDelete` / `OnReadAtServer` / `FillCheckProcessing` … |
|
||||||
|
| `handler` | пусто | метод-обработчик `"МодульСервер.Процедура"` (дополняется до `CommonModule.…`) |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "EventSubscription", "name": "ПередЗаписьюКонтрагента",
|
||||||
|
"source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite",
|
||||||
|
"handler": "ОбщегоНазначенияСервер.ПередЗаписьюКонтрагента" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> Процедура-обработчик (`methodName` / `handler`) должна существовать в указанном общем модуле (экспортная).
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Document, DocumentJournal, Sequence, DocumentNumerator
|
||||||
|
|
||||||
|
## Document (Документ)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Document", "name": "ПриходнаяНакладная",
|
||||||
|
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
||||||
|
"attributes": ["Организация: CatalogRef.Организации"],
|
||||||
|
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `numerator` | пусто | ссылка на нумератор `DocumentNumerator.X` |
|
||||||
|
| `numberType` | `String` | `String` / `Number` |
|
||||||
|
| `numberLength` | `11` | длина номера |
|
||||||
|
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||||
|
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / `Month` / `Quarter` / `Year` |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
|
| `autonumbering` | `true` | bool |
|
||||||
|
| `posting` | `Allow` | `Allow` / `Deny` (проведение) |
|
||||||
|
| `realTimePosting` | `Deny` | `Allow` / `Deny` (оперативное проведение) |
|
||||||
|
| `registerRecordsDeletion` | `AutoDelete` | `AutoDelete` / `AutoDeleteOnUnpost` / `AutoDeleteOff` |
|
||||||
|
| `registerRecordsWritingOnPost` | `WriteSelected` | `WriteModified` / `WriteSelected` / `WriteAll` |
|
||||||
|
| `sequenceFilling` | `AutoFill` | заполнение последовательностей |
|
||||||
|
| `postInPrivilegedMode` | `true` | bool |
|
||||||
|
| `unpostInPrivilegedMode` | `true` | bool |
|
||||||
|
| `createOnInput` | `Use` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `fullTextSearch` | `Use` | `Use` / `DontUse` |
|
||||||
|
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||||
|
| `registerRecords` | `[]` | движения: список ссылок `["AccumulationRegister.ОстаткиТоваров", "InformationRegister.Цены"]` |
|
||||||
|
| `useStandardCommands` | `true` | bool |
|
||||||
|
| `includeHelpInContents` | `false` | bool |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
Формы: `defaultObjectForm`, `defaultListForm`, `defaultChoiceForm`, `auxiliary*` (см. `catalog.md`).
|
||||||
|
Реквизиты и ТЧ — `attributes.md`. Представления, команды, характеристики, `basedOn`, `standardAttributes`,
|
||||||
|
`inputByString`, `dataLockFields` — `blocks.md`.
|
||||||
|
|
||||||
|
## DocumentJournal (Журнал документов)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `registeredDocuments` | `[]` | документы журнала: `["Document.Встреча", "Document.Звонок"]` |
|
||||||
|
| `columns` | `[]` | графы журнала (см. ниже) |
|
||||||
|
|
||||||
|
Графа — строка `"Имя"` или объект `{ name, synonym, indexing, references }`, где `indexing` — `Index`/`DontIndex`,
|
||||||
|
`references` — пути к реквизитам документов, отображаемым в графе.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "DocumentJournal", "name": "Взаимодействия",
|
||||||
|
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
||||||
|
"columns": [{ "name": "Организация", "indexing": "Index",
|
||||||
|
"references": ["Document.Встреча.Attribute.Организация"] }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequence (Последовательность документов)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `moveBoundaryOnPosting` | `DontMove` | сдвиг границы при проведении |
|
||||||
|
| `documents` | `[]` | документы последовательности (список ссылок) |
|
||||||
|
| `registerRecords` | `[]` | движения (список ссылок) |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dimensions` | `[]` | измерения `{name, type, documentMap[], registerRecordsMap[]}` |
|
||||||
|
|
||||||
|
`documentMap` / `registerRecordsMap` — пути к реквизитам документов / движениям, соответствующим измерению.
|
||||||
|
|
||||||
|
## DocumentNumerator (Нумератор документов)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `numberType` | `String` | `String` / `Number` |
|
||||||
|
| `numberLength` | `11` | длина номера |
|
||||||
|
| `numberAllowedLength` | `Variable` | `Variable` / `Fixed` |
|
||||||
|
| `numberPeriodicity` | `Year` | `Nonperiodical` / `Day` / … / `Year` |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# ExchangePlan (План обмена)
|
||||||
|
|
||||||
|
Близок к справочнику (без иерархии/владельцев), плюс состав объектов обмена. Наследует слой Catalog:
|
||||||
|
`codeLength`, `codeAllowedLength`, `descriptionLength`, `defaultPresentation`, `editType`, `quickChoice`,
|
||||||
|
`choiceMode`, формы, `standardAttributes`, `characteristics`, `inputByString`, `basedOn`, представления —
|
||||||
|
см. `catalog.md` / `attributes.md` / `blocks.md`.
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `distributedInfoBase` | `false` | bool (распределённая ИБ — РИБ) |
|
||||||
|
| `includeConfigurationExtensions` | `false` | bool (включать расширения конфигурации) |
|
||||||
|
| `descriptionLength` | `150` | длина наименования |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dataHistory` | `DontUse` | `Use` / `DontUse` |
|
||||||
|
| `useStandardCommands` | `true` | bool |
|
||||||
|
| `content` | `[]` | состав обмена (см. ниже) |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
## `content` — состав плана обмена
|
||||||
|
|
||||||
|
Список объектов-участников обмена; у каждого — признак авторегистрации изменений (по умолчанию выключена).
|
||||||
|
Элемент — ссылка на объект метаданных (строка) или объект с признаком:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"content": [
|
||||||
|
"Catalog.Организации", // авторегистрация выключена
|
||||||
|
"InformationRegister.Курсы: autoRecord", // авторегистрация включена (токен)
|
||||||
|
{ "metadata": "Document.РеализацияТоваров", "autoRecord": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Строка `"Тип.Имя"` — авторегистрация выключена; суффикс `: autoRecord` — включена.
|
||||||
|
- Объект: `metadata` (ссылка), `autoRecord` (bool или `Allow`/`Deny`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ExchangePlan", "name": "ОбменССайтом", "distributedInfoBase": false,
|
||||||
|
"content": ["Catalog.Номенклатура: autoRecord", "Catalog.Контрагенты: autoRecord"],
|
||||||
|
"attributes": ["АдресСервера: String(200)"] }
|
||||||
|
```
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Прочие типы
|
||||||
|
|
||||||
|
Редкие/служебные объекты. Каждый — минимальный набор свойств.
|
||||||
|
|
||||||
|
## FunctionalOption (Функциональная опция)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `location` | пусто | где хранится значение: `Constant.X` / `InformationRegister.X.Resource.Y` / `<Тип>.X.Attribute.Y` |
|
||||||
|
| `content` | `[]` | реквизиты/измерения/ресурсы, зависящие от опции (полные пути к объектам) |
|
||||||
|
| `privilegedGetMode` | `true` | bool |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "FunctionalOption", "name": "ВестиУчетПоСкладам", "location": "Constant.ВестиУчетПоСкладам",
|
||||||
|
"content": ["Document.РеализацияТоваров.TabularSection.Товары.Attribute.Склад"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## FilterCriterion (Критерий отбора)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `valueType` | — | тип значения отбора (составной через `+`) |
|
||||||
|
| `content` | `[]` | реквизиты, по которым идёт отбор (пути к объектам) |
|
||||||
|
| `useStandardCommands` | `true` | bool |
|
||||||
|
| `defaultForm` / `auxiliaryForm` | пусто | формы |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "FilterCriterion", "name": "ДокументыПоКонтрагенту", "valueType": "CatalogRef.Контрагенты",
|
||||||
|
"content": ["Document.Реализация.Attribute.Контрагент"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## SettingsStorage (Хранилище настроек)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `defaultSaveForm` / `defaultLoadForm` | пусто | формы сохранения / загрузки |
|
||||||
|
| `auxiliarySaveForm` / `auxiliaryLoadForm` | пусто | вспомогательные формы |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
|
||||||
|
## CommonForm (Общая форма)
|
||||||
|
|
||||||
|
Создаёт метаданные + заготовку формы. Содержимое формы наполняется `/form-compile` или `/form-edit`.
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `formType` | `Managed` | тип формы |
|
||||||
|
| `usePurposes` | `[PlatformApplication, MobilePlatformApplication]` | назначение (массив) |
|
||||||
|
| `useStandardCommands` | `false` | bool |
|
||||||
|
| `includeHelpInContents` | `false` | bool |
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "CommonForm", "name": "НастройкиОбмена", "usePurposes": ["PlatformApplication"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## CommonPicture / CommonTemplate (Общие картинки и макеты)
|
||||||
|
|
||||||
|
Только метаданные + регистрация; содержимое (`Ext/Picture*`, `Ext/Template.*`) импортируется отдельно
|
||||||
|
(для табличного макета — `/mxl-compile`).
|
||||||
|
|
||||||
|
- **CommonPicture** — `availabilityForChoice` / `availabilityForAppearance` (bool, по умолчанию `false`).
|
||||||
|
- **CommonTemplate** — `templateType` (`SpreadsheetDocument` по умолчанию / `TextDocument` / `HTMLDocument` /
|
||||||
|
`BinaryData` / `AddIn` / `DataCompositionSchema` / `DataCompositionAppearanceTemplate` / `GraphicalSchema`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "CommonTemplate", "name": "ПечатьЗаказа", "templateType": "SpreadsheetDocument" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Служебные типы
|
||||||
|
|
||||||
|
- **SessionParameter** (параметр сеанса) — `valueType` (тип значения, составной через `+`).
|
||||||
|
- **FunctionalOptionsParameter** (параметр функциональной опции) — `use` (массив измерений/реквизитов).
|
||||||
|
- **WSReference** (WS-ссылка) — `locationURL` (URL WSDL).
|
||||||
|
- **CommandGroup** (группа команд) — `category` (по умолч. `NavigationPanel`) — где размещается группа:
|
||||||
|
`NavigationPanel` / `ActionsPanel` (командный интерфейс раздела) или `FormCommandBar` / `FormNavigationPanel`
|
||||||
|
(командный интерфейс формы); `representation` (`Auto`), `tooltip` (ML), `picture`. Команды объекта ссылаются на
|
||||||
|
группу через `group: "CommandGroup.<Имя>"` (см. `blocks.md`).
|
||||||
|
- **CommonCommand** (общая команда) — `group`, `representation`, `tooltip`, `picture`, `shortcut`,
|
||||||
|
`commandParameterType`, `parameterUseMode` (`Single`/`Multiple`), `modifiesData`, `includeHelpInContents`.
|
||||||
|
Создаёт `Ext/CommandModule.bsl`.
|
||||||
|
- **CommonAttribute** (общий реквизит) — `valueType` (по умолчанию `String(0)`) + свойства реквизита
|
||||||
|
(`attributes.md`) + `content` (объекты, куда входит реквизит) + свойства разделения данных
|
||||||
|
(`dataSeparation`, `separatedDataUse`, `usersSeparation`, … — по умолчанию `DontUse`/`Independently`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "CommonAttribute", "name": "Организация", "valueType": "CatalogRef.Организации",
|
||||||
|
"autoUse": "Use", "content": ["Document.РеализацияТоваров", "Document.ПоступлениеТоваров"] }
|
||||||
|
```
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# BusinessProcess, Task (Бизнес-процессы и Задачи)
|
||||||
|
|
||||||
|
Ссылочные типы. Наследуют слой Catalog (нумерация, формы, `standardAttributes`, `characteristics`, `basedOn`,
|
||||||
|
представления — см. `catalog.md` / `attributes.md` / `blocks.md`). Бизнес-процесс всегда связан с задачей.
|
||||||
|
|
||||||
|
## BusinessProcess (Бизнес-процесс)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `task` | пусто | ссылка на задачу `Task.X` (обязательна для рабочего БП) |
|
||||||
|
| `numberType` | `String` | `String` / `Number` |
|
||||||
|
| `numberLength` | `11` | длина номера |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
|
| `autonumbering` | `true` | bool |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
Создаётся с картой маршрута (`Ext/Flowchart.xml`) и модулем объекта.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "BusinessProcess", "name": "Согласование", "task": "Task.ЗадачаИсполнителя",
|
||||||
|
"attributes": ["Документ: DocumentRef.ЗаявкаНаРасход"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task (Задача)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `numberType` | `String` | `String` / `Number` |
|
||||||
|
| `numberLength` | `14` | длина номера |
|
||||||
|
| `checkUnique` | `true` | bool |
|
||||||
|
| `autonumbering` | `true` | bool |
|
||||||
|
| `descriptionLength` | `150` | длина наименования |
|
||||||
|
| `addressing` | пусто | ссылка на регистр сведений адресации `InformationRegister.X` |
|
||||||
|
| `mainAddressingAttribute` | пусто | основной реквизит адресации (имя реквизита адресации) |
|
||||||
|
| `currentPerformer` | пусто | реквизит текущего исполнителя |
|
||||||
|
| `createOnInput` | `DontUse` | `Auto` / `Use` / `DontUse` |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `addressingAttributes` | `[]` | реквизиты адресации (см. ниже) |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
**Реквизит адресации** — shorthand `"Имя: Тип"` или объект `{ name, type, addressingDimension }`
|
||||||
|
(`addressingDimension` — измерение регистра адресации).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Task", "name": "ЗадачаИсполнителя",
|
||||||
|
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи", "Роль: CatalogRef.Роли"] }
|
||||||
|
```
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Регистры: Information, Accumulation, Accounting, Calculation
|
||||||
|
|
||||||
|
**Измерения и ресурсы** задаются как реквизиты (shorthand `"Имя: Тип | флаги"` или объектная форма, см.
|
||||||
|
`attributes.md`). Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (регистр накопления).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter"],
|
||||||
|
"resources": ["Сумма: Number(15,2)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## InformationRegister (Регистр сведений)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `writeMode` | `Independent` | `Independent` / `RecorderSubordinate` |
|
||||||
|
| `periodicity` | `Nonperiodical` | `Nonperiodical` / `Second` / `Day` / `Month` / `Quarter` / `Year` / `RecorderPosition` |
|
||||||
|
| `mainFilterOnPeriod` | `false` | bool (основной отбор по периоду) |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
||||||
|
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
||||||
|
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## AccumulationRegister (Регистр накопления)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `registerType` | `Balance` | `Balance` (остатки) / `Turnovers` (обороты) |
|
||||||
|
| `enableTotalsSplitting` | `true` | bool (разделение итогов) |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
||||||
|
"dimensions": ["Номенклатура: CatalogRef.Номенклатура", "Склад: CatalogRef.Склады"],
|
||||||
|
"resources": ["Количество: Number(15,3)"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## AccountingRegister (Регистр бухгалтерии)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `chartOfAccounts` | — | **обязательно**: ссылка на план счетов `ChartOfAccounts.X` |
|
||||||
|
| `correspondence` | `false` | bool (корреспонденция) |
|
||||||
|
| `periodAdjustmentLength` | `0` | длина периода корректировки |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "AccountingRegister", "name": "Хозрасчетный",
|
||||||
|
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
||||||
|
"dimensions": ["Организация: CatalogRef.Организации"], "resources": ["Сумма: Number(15,2)"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## CalculationRegister (Регистр расчёта)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `chartOfCalculationTypes` | — | **обязательно**: ссылка на ПВР `ChartOfCalculationTypes.X` |
|
||||||
|
| `periodicity` | `Month` | периодичность |
|
||||||
|
| `actionPeriod` | `false` | bool (период действия) |
|
||||||
|
| `basePeriod` | `false` | bool (базовый период) |
|
||||||
|
| `schedule` | пусто | ссылка на регистр сведений графиков |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
| `dimensions` / `resources` / `attributes` | `[]` | измерения / ресурсы / реквизиты |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "CalculationRegister", "name": "Начисления",
|
||||||
|
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления", "periodicity": "Month",
|
||||||
|
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"], "resources": ["Сумма: Number(15,2)"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
> **AccountingRegister** требует план счетов, **CalculationRegister** — план видов расчёта (и оба — документ-регистратор).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Report, DataProcessor (Отчёты и Обработки)
|
||||||
|
|
||||||
|
Почти идентичны по составу: реквизиты, табличные части, формы, макеты, команды. Модуль объекта — `Ext/ObjectModule.bsl`.
|
||||||
|
Реквизиты и ТЧ — `attributes.md`; команды — `blocks.md`.
|
||||||
|
|
||||||
|
Ссылки на формы/схемы/хранилища пишутся **как есть** (имя формы может быть буквально «Форма»).
|
||||||
|
|
||||||
|
## Report (Отчёт)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `useStandardCommands` | `true` | bool (доступность через стандартный командный интерфейс) |
|
||||||
|
| `mainDataCompositionSchema` | пусто | основной макет СКД (`Report.X.Template.ОсновнаяСхемаКомпоновкиДанных`) |
|
||||||
|
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||||
|
| `defaultSettingsForm` / `auxiliarySettingsForm` / `defaultVariantForm` | пусто | формы настроек / вариантов |
|
||||||
|
| `variantsStorage` / `settingsStorage` | пусто | хранилища вариантов / настроек (`SettingsStorage.X`) |
|
||||||
|
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||||
|
| `includeHelpInContents` | `false` | bool |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Report", "name": "АнализПродаж", "useStandardCommands": false,
|
||||||
|
"mainDataCompositionSchema": "Report.АнализПродаж.Template.ОсновнаяСхемаКомпоновкиДанных",
|
||||||
|
"attributes": ["Период: StandardPeriod"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## DataProcessor (Обработка)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `comment` | пусто | строка |
|
||||||
|
| `useStandardCommands` | `true` | bool |
|
||||||
|
| `defaultForm` / `auxiliaryForm` | пусто | основная / вспомогательная форма |
|
||||||
|
| `extendedPresentation` / `explanation` | пусто | представление / пояснение (ML) |
|
||||||
|
| `includeHelpInContents` | `false` | bool |
|
||||||
|
| `attributes` / `tabularSections` | `[]` / `{}` | реквизиты / табличные части |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "DataProcessor", "name": "ЗагрузкаТаблиц", "useStandardCommands": false,
|
||||||
|
"attributes": [{ "name": "Таблица", "type": "ValueTree" }, { "name": "Произвольные", "type": "" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
> Реквизиты отчётов/обработок допускают платформенные типы-коллекции: `ValueTable`, `ValueTree`, `ValueList`,
|
||||||
|
> `StandardPeriod`, `SpreadsheetDocument` и др., а также `"type": ""` — реквизит без типа.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Enum, Constant, DefinedType
|
||||||
|
|
||||||
|
## Enum (Перечисление)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `values` | `[]` | значения перечисления (массив имён или объектов) |
|
||||||
|
|
||||||
|
Значение — строка `"ИмяЗначения"` или объект `{ name, synonym }`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constant (Константа)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `valueType` | `String` | тип значения (shorthand типа) |
|
||||||
|
| `dataLockControlMode` | `Managed` | `Automatic` / `Managed` |
|
||||||
|
|
||||||
|
`valueType` принимает shorthand: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`,
|
||||||
|
составной через `+`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## DefinedType (Определяемый тип)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `valueTypes` | `[]` | состав типа (массив shorthand-типов) |
|
||||||
|
| `valueType` | — | то же одной строкой (`"A + B"`) или строкой одного типа |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "DefinedType", "name": "ДенежныеСредства",
|
||||||
|
"valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
||||||
|
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
||||||
|
```
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# Базовые типы: Catalog, Document, Enum, Constant, DefinedType, Report, DataProcessor
|
|
||||||
|
|
||||||
## Catalog
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `hierarchical` | `false` | Hierarchical |
|
|
||||||
| `hierarchyType` | `HierarchyFoldersAndItems` | HierarchyType |
|
|
||||||
| `limitLevelCount` | `false` | LimitLevelCount |
|
|
||||||
| `levelCount` | `2` | LevelCount |
|
|
||||||
| `foldersOnTop` | `true` | FoldersOnTop |
|
|
||||||
| `codeLength` | `9` | CodeLength |
|
|
||||||
| `codeType` | `String` | CodeType |
|
|
||||||
| `codeAllowedLength` | `Variable` | CodeAllowedLength |
|
|
||||||
| `codeSeries` | `WholeCatalog` | CodeSeries |
|
|
||||||
| `descriptionLength` | `25` | DescriptionLength |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `checkUnique` | `false` | CheckUnique |
|
|
||||||
| `defaultPresentation` | `AsDescription` | DefaultPresentation |
|
|
||||||
| `subordinationUse` | `ToItems` | SubordinationUse |
|
|
||||||
| `quickChoice` | `false` | QuickChoice |
|
|
||||||
| `choiceMode` | `BothWays` | ChoiceMode |
|
|
||||||
| `owners` | `[]` | Owners |
|
|
||||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Catalog", "name": "Организации", "attributes": ["ИНН: String(12)", "КПП: String(9)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Document
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `numberType` | `String` | NumberType |
|
|
||||||
| `numberLength` | `11` | NumberLength |
|
|
||||||
| `numberAllowedLength` | `Variable` | NumberAllowedLength |
|
|
||||||
| `numberPeriodicity` | `Year` | NumberPeriodicity |
|
|
||||||
| `checkUnique` | `true` | CheckUnique |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `posting` | `Allow` | Posting |
|
|
||||||
| `realTimePosting` | `Deny` | RealTimePosting |
|
|
||||||
| `registerRecordsDeletion` | `AutoDelete` | RegisterRecordsDeletion |
|
|
||||||
| `registerRecordsWritingOnPost` | `WriteModified` | RegisterRecordsWritingOnPost |
|
|
||||||
| `postInPrivilegedMode` | `true` | PostInPrivilegedMode |
|
|
||||||
| `unpostInPrivilegedMode` | `true` | UnpostInPrivilegedMode |
|
|
||||||
| `registerRecords` | `[]` | RegisterRecords |
|
|
||||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
|
||||||
|
|
||||||
RegisterRecords — массив строк: `"AccumulationRegister.Продажи"`, `"InformationRegister.Цены"`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "Document", "name": "ПриходнаяНакладная",
|
|
||||||
"registerRecords": ["AccumulationRegister.ОстаткиТоваров"],
|
|
||||||
"attributes": ["Организация: CatalogRef.Организации", "Контрагент: CatalogRef.Контрагенты"],
|
|
||||||
"tabularSections": { "Товары": ["Номенклатура: CatalogRef.Номенклатура", "Количество: Number(15,3)"] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Enum
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `values` | `[]` | → EnumValue в ChildObjects |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Enum", "name": "Статусы", "values": ["Новый", "ВРаботе", "Закрыт"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Constant
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `valueType` | `String` | Type |
|
|
||||||
|
|
||||||
`valueType` принимает shorthand типа: `"String(100)"`, `"Number(15,2)"`, `"Boolean"`, `"CatalogRef.Валюты"`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Constant", "name": "ОсновнаяВалюта", "valueType": "CatalogRef.Валюты" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## DefinedType
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `valueTypes` | `[]` | Type (составной тип) |
|
|
||||||
| `valueType` | — | Алиас для `valueTypes` (строка или массив) |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "DefinedType", "name": "ДенежныеСредства", "valueTypes": ["CatalogRef.БанковскиеСчета", "CatalogRef.Кассы"] }
|
|
||||||
{ "type": "DefinedType", "name": "ФлагАктивности", "valueType": "Boolean" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Report
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "Report", "name": "ОстаткиТоваров" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## DataProcessor
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `attributes` | `[]` | → Attribute в ChildObjects |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection в ChildObjects |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "DataProcessor", "name": "ЗагрузкаДанных", "attributes": ["ПутьКФайлу: String(500)"] }
|
|
||||||
```
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Процессы и сервисные: BusinessProcess, Task, ExchangePlan, CommonModule, ScheduledJob, EventSubscription, DocumentJournal
|
|
||||||
|
|
||||||
## BusinessProcess
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `task` | `""` | Task (ссылка `Task.XXX`) |
|
|
||||||
| `numberType` | `String` | NumberType |
|
|
||||||
| `numberLength` | `11` | NumberLength |
|
|
||||||
| `checkUnique` | `true` | CheckUnique |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
|
|
||||||
Модули: `Ext/ObjectModule.bsl`, `Ext/Flowchart.xml`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "BusinessProcess", "name": "Задание", "task": "Task.ЗадачаИсполнителя", "attributes": ["Описание: String(200)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `numberType` | `String` | NumberType |
|
|
||||||
| `numberLength` | `14` | NumberLength |
|
|
||||||
| `checkUnique` | `true` | CheckUnique |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `descriptionLength` | `150` | DescriptionLength |
|
|
||||||
| `addressing` | `""` | Addressing (ссылка на РС адресации) |
|
|
||||||
| `mainAddressingAttribute` | `""` | MainAddressingAttribute |
|
|
||||||
| `currentPerformer` | `""` | CurrentPerformer |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
| `addressingAttributes` | `[]` | → AddressingAttribute (shorthand или объект) |
|
|
||||||
|
|
||||||
AddressingAttribute — shorthand `"Имя: Тип"` или объект `{ "name", "type", "addressingDimension" }`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "Task", "name": "ЗадачаИсполнителя",
|
|
||||||
"addressingAttributes": ["Исполнитель: CatalogRef.Пользователи"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ExchangePlan
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `codeLength` | `9` | CodeLength |
|
|
||||||
| `descriptionLength` | `100` | DescriptionLength |
|
|
||||||
| `distributedInfoBase` | `false` | DistributedInfoBase |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
|
|
||||||
Модули: `Ext/ObjectModule.bsl`, `Ext/Content.xml`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "ExchangePlan", "name": "ОбменССайтом", "attributes": ["АдресСервера: String(200)"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## CommonModule
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `context` | — | Шорткат (см. ниже) |
|
|
||||||
| `global` | `false` | Global |
|
|
||||||
| `server` | `false` | Server |
|
|
||||||
| `serverCall` | `false` | ServerCall |
|
|
||||||
| `clientManagedApplication` | `false` | ClientManagedApplication |
|
|
||||||
| `externalConnection` | `false` | ExternalConnection |
|
|
||||||
| `privileged` | `false` | Privileged |
|
|
||||||
| `returnValuesReuse` | `DontUse` | ReturnValuesReuse |
|
|
||||||
|
|
||||||
Шорткаты `context`: `"server"` → Server+ServerCall, `"client"` → ClientManagedApplication, `"serverClient"` → Server+ClientManagedApplication.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "CommonModule", "name": "ОбщиеФункции", "context": "serverClient" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## ScheduledJob
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `methodName` | `""` | MethodName |
|
|
||||||
| `description` | = synonym | Description |
|
|
||||||
| `use` | `false` | Use |
|
|
||||||
| `predefined` | `false` | Predefined |
|
|
||||||
| `restartCountOnFailure` | `3` | RestartCountOnFailure |
|
|
||||||
| `restartIntervalOnFailure` | `10` | RestartIntervalOnFailure |
|
|
||||||
|
|
||||||
Формат `methodName`: `"МодульСервер.Процедура"` — авто-дополняется до `CommonModule.МодульСервер.Процедура`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "ScheduledJob", "name": "ОбменДанными", "methodName": "ОбменДаннымиСервер.Выполнить" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## EventSubscription
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `source` | `[]` | Source (массив, формат `XxxObject.Name`) |
|
|
||||||
| `event` | `BeforeWrite` | Event |
|
|
||||||
| `handler` | `""` | Handler |
|
|
||||||
|
|
||||||
Формат `handler`: `"МодульСервер.Процедура"` — авто-дополняется до `CommonModule.МодульСервер.Процедура`.
|
|
||||||
|
|
||||||
Значения `event`: `BeforeWrite`, `OnWrite`, `BeforeDelete`, `OnReadAtServer`, `FillCheckProcessing`.
|
|
||||||
|
|
||||||
Формат `source`: `"CatalogObject.Xxx"`, `"DocumentObject.Xxx"`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "EventSubscription", "name": "ПередЗаписью", "source": ["CatalogObject.Контрагенты"], "event": "BeforeWrite", "handler": "ОбщиеФункции.ПередЗаписью" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## DocumentJournal
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `registeredDocuments` | `[]` | RegisteredDocuments (массив `"Document.Xxx"`) |
|
|
||||||
| `columns` | `[]` | → Column |
|
|
||||||
|
|
||||||
Колонки — строка `"Имя"` или объект `{ "name", "synonym", "indexing": "Index"/"DontIndex", "references": ["Document.Xxx.Attribute.Yyy"] }`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "DocumentJournal", "name": "Взаимодействия",
|
|
||||||
"registeredDocuments": ["Document.Встреча", "Document.Звонок"],
|
|
||||||
"columns": [{ "name": "Организация", "indexing": "Index", "references": ["Document.Встреча.Attribute.Организация"] }]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
- **ScheduledJob/EventSubscription** — процедура-обработчик должна существовать в модуле (экспортная)
|
|
||||||
- **BusinessProcess** → `Task` (задача должна существовать)
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
# Регистры и планы: InformationRegister, AccumulationRegister, AccountingRegister, CalculationRegister, ChartOfAccounts, ChartOfCharacteristicTypes, ChartOfCalculationTypes
|
|
||||||
|
|
||||||
## Измерения и ресурсы (общее)
|
|
||||||
|
|
||||||
Синтаксис аналогичен реквизитам (shorthand `"Имя: Тип | флаги"`).
|
|
||||||
|
|
||||||
Флаги измерений: `master`, `mainFilter`, `denyIncomplete`, `useInTotals` (AccumulationRegister only, default `true`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
"dimensions": ["Организация: CatalogRef.Организации | master, mainFilter, denyIncomplete"],
|
|
||||||
"resources": ["Сумма: Number(15,2)"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## InformationRegister
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `writeMode` | `Independent` | WriteMode |
|
|
||||||
| `periodicity` | `Nonperiodical` | InformationRegisterPeriodicity |
|
|
||||||
| `mainFilterOnPeriod` | авто* | MainFilterOnPeriod |
|
|
||||||
| `dimensions` | `[]` | → Dimension |
|
|
||||||
| `resources` | `[]` | → Resource |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
|
|
||||||
\* `mainFilterOnPeriod` = `true` если `periodicity` != `Nonperiodical`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "InformationRegister", "name": "КурсыВалют", "periodicity": "Day",
|
|
||||||
"dimensions": ["Валюта: CatalogRef.Валюты | master, mainFilter, denyIncomplete"],
|
|
||||||
"resources": ["Курс: Number(15,4)", "Кратность: Number(10,0)"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## AccumulationRegister
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `registerType` | `Balance` | RegisterType (`Balance` / `Turnovers`) |
|
|
||||||
| `enableTotalsSplitting` | `true` | EnableTotalsSplitting |
|
|
||||||
| `dimensions` | `[]` | → Dimension |
|
|
||||||
| `resources` | `[]` | → Resource |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "AccumulationRegister", "name": "ОстаткиТоваров", "registerType": "Balance",
|
|
||||||
"dimensions": ["Номенклатура: CatalogRef.Номенклатура"],
|
|
||||||
"resources": ["Количество: Number(15,3)"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## AccountingRegister
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `chartOfAccounts` | `""` | ChartOfAccounts (**обязательная** ссылка на план счетов) |
|
|
||||||
| `correspondence` | `false` | Correspondence |
|
|
||||||
| `periodAdjustmentLength` | `0` | PeriodAdjustmentLength |
|
|
||||||
| `dimensions` | `[]` | → Dimension |
|
|
||||||
| `resources` | `[]` | → Resource |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "AccountingRegister", "name": "Хозрасчетный",
|
|
||||||
"chartOfAccounts": "ChartOfAccounts.Хозрасчетный",
|
|
||||||
"dimensions": ["Организация: CatalogRef.Организации"],
|
|
||||||
"resources": ["Сумма: Number(15,2)"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CalculationRegister
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `chartOfCalculationTypes` | `""` | ChartOfCalculationTypes (**обязательная** ссылка на ПВР) |
|
|
||||||
| `periodicity` | `Month` | Periodicity |
|
|
||||||
| `actionPeriod` | `false` | ActionPeriod |
|
|
||||||
| `basePeriod` | `false` | BasePeriod |
|
|
||||||
| `schedule` | `""` | Schedule (ссылка на РС графиков) |
|
|
||||||
| `dimensions` | `[]` | → Dimension |
|
|
||||||
| `resources` | `[]` | → Resource |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "CalculationRegister", "name": "Начисления",
|
|
||||||
"chartOfCalculationTypes": "ChartOfCalculationTypes.Начисления",
|
|
||||||
"periodicity": "Month",
|
|
||||||
"dimensions": ["Сотрудник: CatalogRef.Сотрудники"],
|
|
||||||
"resources": ["Сумма: Number(15,2)"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ChartOfCharacteristicTypes
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `codeLength` | `9` | CodeLength |
|
|
||||||
| `descriptionLength` | `25` | DescriptionLength |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `checkUnique` | `false` | CheckUnique |
|
|
||||||
| `characteristicExtValues` | `""` | CharacteristicExtValues |
|
|
||||||
| `valueTypes` | авто* | Type (составной тип значений характеристик) |
|
|
||||||
| `hierarchical` | `false` | Hierarchical |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
|
|
||||||
\* По умолчанию: Boolean, String(100), Number(15,2), DateTime.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "ChartOfCharacteristicTypes", "name": "ВидыСубконто",
|
|
||||||
"valueTypes": ["CatalogRef.Номенклатура", "CatalogRef.Контрагенты", "Boolean", "String", "Number(15,2)"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ChartOfAccounts
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `extDimensionTypes` | `""` | ExtDimensionTypes (ссылка на ПВХ) |
|
|
||||||
| `maxExtDimensionCount` | `3` | MaxExtDimensionCount |
|
|
||||||
| `codeMask` | `""` | CodeMask |
|
|
||||||
| `codeLength` | `8` | CodeLength |
|
|
||||||
| `descriptionLength` | `120` | DescriptionLength |
|
|
||||||
| `codeSeries` | `WholeChartOfAccounts` | CodeSeries |
|
|
||||||
| `autoOrderByCode` | `true` | AutoOrderByCode |
|
|
||||||
| `orderLength` | `5` | OrderLength |
|
|
||||||
| `hierarchical` | `false` | Hierarchical |
|
|
||||||
| `accountingFlags` | `[]` | → AccountingFlag (Boolean-тип, массив имён) |
|
|
||||||
| `extDimensionAccountingFlags` | `[]` | → ExtDimensionAccountingFlag (Boolean-тип, массив имён) |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "ChartOfAccounts", "name": "Хозрасчетный",
|
|
||||||
"extDimensionTypes": "ChartOfCharacteristicTypes.ВидыСубконто", "maxExtDimensionCount": 3,
|
|
||||||
"codeLength": 8, "codeMask": "@@@.@@.@",
|
|
||||||
"accountingFlags": ["Валютный", "Количественный"],
|
|
||||||
"extDimensionAccountingFlags": ["Суммовой", "Валютный"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ChartOfCalculationTypes
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `codeLength` | `9` | CodeLength |
|
|
||||||
| `descriptionLength` | `25` | DescriptionLength |
|
|
||||||
| `autonumbering` | `true` | Autonumbering |
|
|
||||||
| `checkUnique` | `false` | CheckUnique |
|
|
||||||
| `dependenceOnCalculationTypes` | `DontUse` | DependenceOnCalculationTypes |
|
|
||||||
| `actionPeriodUse` | `false` | ActionPeriodUse |
|
|
||||||
| `attributes` | `[]` | → Attribute |
|
|
||||||
| `tabularSections` | `{}` | → TabularSection |
|
|
||||||
|
|
||||||
`dependenceOnCalculationTypes`: `DontUse`, `OnActionPeriod`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "type": "ChartOfCalculationTypes", "name": "Начисления", "dependenceOnCalculationTypes": "OnActionPeriod" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
- **AccountingRegister** требует `ChartOfAccounts` (и документ-регистратор)
|
|
||||||
- **CalculationRegister** требует `ChartOfCalculationTypes` (и документ-регистратор)
|
|
||||||
- **ChartOfAccounts** ссылается на `ChartOfCharacteristicTypes` через `extDimensionTypes`
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# Веб-сервисы: HTTPService, WebService
|
|
||||||
|
|
||||||
## HTTPService
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `rootURL` | `= name.toLower()` | RootURL |
|
|
||||||
| `reuseSessions` | `DontUse` | ReuseSessions |
|
|
||||||
| `sessionMaxAge` | `20` | SessionMaxAge |
|
|
||||||
| `urlTemplates` | `{}` | → URLTemplate |
|
|
||||||
|
|
||||||
Модули: `Ext/Module.bsl`.
|
|
||||||
|
|
||||||
### urlTemplates — вложенная структура
|
|
||||||
|
|
||||||
`urlTemplates` — объект `{ "TemplateName": templateDef, ... }`.
|
|
||||||
|
|
||||||
Каждый `templateDef`:
|
|
||||||
- Строка — URL-шаблон: `"/v1/users"` (без методов)
|
|
||||||
- Объект:
|
|
||||||
|
|
||||||
| Поле | Умолчание | Описание |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `template` | `"/templatename"` | URL-путь (с параметрами `{id}`) |
|
|
||||||
| `methods` | `{}` | Методы: `{ "MethodName": "HTTPMethod" }` |
|
|
||||||
|
|
||||||
Допустимые HTTPMethod: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
|
||||||
|
|
||||||
Обработчик метода генерируется автоматически: `{TemplateName}{MethodName}` — должен быть реализован в `Ext/Module.bsl`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "HTTPService", "name": "API", "rootURL": "api",
|
|
||||||
"urlTemplates": {
|
|
||||||
"Users": {
|
|
||||||
"template": "/v1/users/{id}",
|
|
||||||
"methods": { "Get": "GET", "Create": "POST", "Update": "PUT", "Delete": "DELETE" }
|
|
||||||
},
|
|
||||||
"Health": "/health"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebService
|
|
||||||
|
|
||||||
| Поле JSON | Умолчание | XML элемент |
|
|
||||||
|-----------|----------|-------------|
|
|
||||||
| `namespace` | `""` | Namespace (URI пространства имён WSDL) |
|
|
||||||
| `xdtoPackages` | `""` | XDTOPackages |
|
|
||||||
| `reuseSessions` | `DontUse` | ReuseSessions |
|
|
||||||
| `sessionMaxAge` | `20` | SessionMaxAge |
|
|
||||||
| `operations` | `{}` | → Operation |
|
|
||||||
|
|
||||||
Модули: `Ext/Module.bsl`.
|
|
||||||
|
|
||||||
### operations — вложенная структура
|
|
||||||
|
|
||||||
`operations` — объект `{ "OperationName": operationDef, ... }`.
|
|
||||||
|
|
||||||
Каждый `operationDef`:
|
|
||||||
- Строка — тип возврата: `"xs:boolean"` (параметров нет, обработчик = имя операции)
|
|
||||||
- Объект:
|
|
||||||
|
|
||||||
| Поле | Умолчание | Описание |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `returnType` | `xs:string` | XDTO-тип возврата |
|
|
||||||
| `nillable` | `false` | Может ли вернуть null |
|
|
||||||
| `transactioned` | `false` | Выполнять в транзакции |
|
|
||||||
| `handler` | `= operationName` | Имя процедуры в модуле |
|
|
||||||
| `parameters` | `{}` | Параметры операции |
|
|
||||||
|
|
||||||
### parameters — параметры операции
|
|
||||||
|
|
||||||
`parameters` — объект `{ "ParamName": paramDef, ... }`.
|
|
||||||
|
|
||||||
Каждый `paramDef`:
|
|
||||||
- Строка — XDTO-тип: `"xs:string"` (direction = In, nillable = true)
|
|
||||||
- Объект:
|
|
||||||
|
|
||||||
| Поле | Умолчание | Описание |
|
|
||||||
|------|----------|----------|
|
|
||||||
| `type` | `xs:string` | XDTO-тип параметра |
|
|
||||||
| `nillable` | `true` | Может ли быть null |
|
|
||||||
| `direction` | `In` | Направление: `In`, `Out`, `InOut` |
|
|
||||||
|
|
||||||
Стандартные XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "WebService", "name": "DataExchange",
|
|
||||||
"namespace": "http://www.1c.ru/DataExchange",
|
|
||||||
"operations": {
|
|
||||||
"TestConnection": {
|
|
||||||
"returnType": "xs:boolean",
|
|
||||||
"handler": "ПроверкаПодключения",
|
|
||||||
"parameters": {
|
|
||||||
"ErrorMessage": { "type": "xs:string", "direction": "Out" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"GetVersion": "xs:string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# HTTPService, WebService (Веб-сервисы)
|
||||||
|
|
||||||
|
Модуль обоих — `Ext/Module.bsl`, в нём реализуются обработчики.
|
||||||
|
|
||||||
|
## HTTPService (HTTP-сервис)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `rootURL` | `= name` (в нижнем регистре) | корневой URL |
|
||||||
|
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||||
|
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||||
|
| `urlTemplates` | `{}` | шаблоны URL (см. ниже) |
|
||||||
|
|
||||||
|
`urlTemplates` — объект `{ "ИмяШаблона": def }`, где `def`:
|
||||||
|
- строка — URL-путь без методов: `"/health"`;
|
||||||
|
- объект: `template` (путь с параметрами `{id}`, по умолчанию `/имяшаблона`), `methods` — `{ "ИмяМетода": "HTTPMethod" }`.
|
||||||
|
|
||||||
|
HTTP-методы: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, `CONNECT`, `TRACE`, `MERGE`.
|
||||||
|
Обработчик метода в модуле именуется `{ИмяШаблона}{ИмяМетода}`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "HTTPService", "name": "API", "rootURL": "api",
|
||||||
|
"urlTemplates": {
|
||||||
|
"Users": { "template": "/v1/users/{id}", "methods": { "Get": "GET", "Create": "POST", "Delete": "DELETE" } },
|
||||||
|
"Health": "/health"
|
||||||
|
} }
|
||||||
|
```
|
||||||
|
|
||||||
|
## WebService (Веб-сервис, SOAP)
|
||||||
|
|
||||||
|
| Ключ | Умолчание | Значения |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `namespace` | пусто | URI пространства имён WSDL |
|
||||||
|
| `xdtoPackages` | пусто | XDTO-пакеты |
|
||||||
|
| `reuseSessions` | `DontUse` | `DontUse` / `AutoUse` |
|
||||||
|
| `sessionMaxAge` | `20` | время жизни сессии, сек |
|
||||||
|
| `operations` | `{}` | операции (см. ниже) |
|
||||||
|
|
||||||
|
`operations` — объект `{ "ИмяОперации": def }`, где `def`:
|
||||||
|
- строка — XDTO-тип возврата без параметров: `"xs:string"`;
|
||||||
|
- объект: `returnType` (по умолчанию `xs:string`), `nillable` (bool), `transactioned` (bool),
|
||||||
|
`handler` (имя процедуры, по умолчанию = имя операции), `parameters`.
|
||||||
|
|
||||||
|
`parameters` — объект `{ "ИмяПараметра": def }`, где `def`:
|
||||||
|
- строка — XDTO-тип (`direction` = `In`);
|
||||||
|
- объект: `type` (по умолчанию `xs:string`), `nillable` (bool, по умолчанию `true`), `direction` (`In` / `Out` / `InOut`).
|
||||||
|
|
||||||
|
XDTO-типы: `xs:string`, `xs:boolean`, `xs:int`, `xs:long`, `xs:decimal`, `xs:dateTime`, `xs:base64Binary`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "WebService", "name": "DataExchange", "namespace": "http://www.1c.ru/DataExchange",
|
||||||
|
"operations": {
|
||||||
|
"TestConnection": { "returnType": "xs:boolean", "handler": "ПроверкаПодключения",
|
||||||
|
"parameters": { "ErrorMessage": { "type": "xs:string", "direction": "Out" } } },
|
||||||
|
"GetVersion": "xs:string"
|
||||||
|
} }
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: meta-decompile
|
||||||
|
description: Декомпиляция объекта метаданных 1С в JSON-заготовку формата meta-compile. Используй когда нужно получить черновик DSL-описания нового объекта по образцу другого. Не сохраняет UUID/модули/формы.
|
||||||
|
argument-hint: <ObjectPath> [-OutputPath <out.json>]
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools:
|
||||||
|
- Bash
|
||||||
|
- Read
|
||||||
|
- Write
|
||||||
|
- Glob
|
||||||
|
---
|
||||||
|
|
||||||
|
# /meta-decompile — DSL-заготовка из XML объекта метаданных
|
||||||
|
|
||||||
|
Читает XML объекта метаданных (`Catalogs/Имя.xml` и т.п.) и эмитит компактный JSON в формате `/meta-compile`. Назначение — **взять существующий объект образцом и собрать по нему НОВЫЙ**: декомпилировать → поправить → скомпилировать под другим именем.
|
||||||
|
|
||||||
|
## ⚠️ Главное: это НЕ обратимая выгрузка
|
||||||
|
|
||||||
|
Компиляция черновика создаёт **новый объект с новой идентичностью**, а не копию исходного. В JSON **не** попадают: UUID (идентичность самого объекта и всех дочерних), тела модулей, формы, макеты, права. Захватываются только структура и свойства.
|
||||||
|
|
||||||
|
Отсюда правило: **никогда не компилируй черновик поверх объекта-источника и не выдавай его за «реимпорт»** — у пересобранного объекта другие UUID, поэтому все ссылки на исходный объект (из кода, других объектов, состава подсистем, предопределённых данных) сломаются, а код модулей и формы пропадут.
|
||||||
|
|
||||||
|
## Когда использовать
|
||||||
|
|
||||||
|
**Собрать новый объект по образцу существующего** — получить DSL-заготовку рабочего объекта, переименовать и адаптировать состав, скомпилировать в новый. Быстрее, чем писать DSL с нуля для богатого объекта.
|
||||||
|
|
||||||
|
## Когда **не** использовать
|
||||||
|
|
||||||
|
- **Точечная правка существующего объекта** (добавить реквизит, ТЧ, свойство) → `/meta-edit`. Цикл decompile→compile тут вреден: даёт объект с новой идентичностью и теряет модули/формы.
|
||||||
|
- **Сохранить / восстановить / перенести тот же объект** (бэкап, миграция между конфигурациями с сохранением ссылок) → штатная выгрузка 1С (`/db-dump-xml` ↔ `/db-load-xml`, CF), а не decompile.
|
||||||
|
- **Просто понять структуру** объекта (реквизиты, ТЧ, типы) без пересборки → `/meta-info` (дешевле, не плодит файл).
|
||||||
|
|
||||||
|
## Параметры
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `ObjectPath` | Путь к XML объекта (`Catalogs/Имя.xml`), обязательный |
|
||||||
|
| `OutputPath` | Путь к выходному JSON. Если не задан — JSON в stdout |
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/meta-decompile.ps1" -ObjectPath "<Объект.xml>" -OutputPath "<out.json>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Неподдерживаемый тип объекта или не-`MetaDataObject` root → ненулевой код выхода и сообщение в stderr.
|
||||||
|
|
||||||
|
## Workflow (сборка нового объекта по образцу)
|
||||||
|
|
||||||
|
1. `/meta-decompile <Образец.xml> -OutputPath draft.json` — получить заготовку.
|
||||||
|
2. В `draft.json` **сменить `name`** на имя нового объекта и адаптировать состав (реквизиты/ТЧ/свойства). Ссылки на *другие* объекты (владельцы, ввод на основании, типы) — по имени, сохраняются как есть.
|
||||||
|
3. `/meta-compile -JsonPath draft.json -OutputDir <ConfigDir>` — собрать (объект получит свежие UUID).
|
||||||
|
4. `/meta-validate` + `/meta-info` — проверить.
|
||||||
|
5. Модули, формы, макеты, права — добавить отдельно (в черновик они не попадают).
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,9 @@ Batch через `;;` во всех операциях. Подробный си
|
|||||||
|
|
||||||
Позиционная вставка: `"Склад: CatalogRef.Склады >> after Организация"`.
|
Позиционная вставка: `"Склад: CatalogRef.Склады >> after Организация"`.
|
||||||
|
|
||||||
|
`modify-attribute` умеет и структурные свойства реквизита — формат/подсказку, форму и параметры выбора,
|
||||||
|
значение заполнения, границы (`Format`, `ChoiceForm`, `ChoiceParameters`, `FillValue`, `MinValue`/`MaxValue` и др.).
|
||||||
|
|
||||||
### Свойства объекта — [properties-reference.md](properties-reference.md)
|
### Свойства объекта — [properties-reference.md](properties-reference.md)
|
||||||
|
|
||||||
| Операция | Формат Value | Пример |
|
| Операция | Формат Value | Пример |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
**Shorthand-формат** реквизитов: `ИмяРеквизита: Тип | флаги`
|
**Shorthand-формат** реквизитов: `ИмяРеквизита: Тип | флаги`
|
||||||
|
|
||||||
Флаги: `req` (FillChecking=ShowError), `index` (Indexing=Index), `master` (Master=true, только dimensions), `mainFilter` (MainFilterOperand, только dimensions).
|
Флаги: `req` — обязательное заполнение; `index` — индексировать; `master` — ведущее измерение (только dimensions); `mainFilter` — основной отбор (только dimensions).
|
||||||
|
|
||||||
**Позиционная вставка**: `>> after ИмяЭлемента` или `<< before ИмяЭлемента`:
|
**Позиционная вставка**: `>> after ИмяЭлемента` или `<< before ИмяЭлемента`:
|
||||||
```powershell
|
```powershell
|
||||||
@@ -84,6 +84,27 @@ Batch через `;;` — можно указать разные ТЧ: `"Тов
|
|||||||
|
|
||||||
Формат аналогичен `modify-attribute`: `ИмяТЧ: ключ=значение, ключ=значение`.
|
Формат аналогичен `modify-attribute`: `ИмяТЧ: ключ=значение, ключ=значение`.
|
||||||
|
|
||||||
|
## add-predefined
|
||||||
|
|
||||||
|
Добавить предопределённые элементы (Catalog, ChartOfCharacteristicTypes). Существующие элементы и их
|
||||||
|
идентификаторы сохраняются, новые получают свежий id.
|
||||||
|
|
||||||
|
Inline — строка `(Код) Имя [Наименование]` (batch через `;;`; `[Наименование]` необязательно — иначе авто из имени):
|
||||||
|
```powershell
|
||||||
|
-Operation add-predefined -Value "(001) Основной ;; (002) Резервный [Резервный склад]"
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON — строки и/или объекты (для групп с вложенными):
|
||||||
|
```json
|
||||||
|
{ "add": { "predefined": [
|
||||||
|
"(001) Основной",
|
||||||
|
{ "name": "Группа", "isFolder": true, "childItems": ["(002) Вложенный"] }
|
||||||
|
] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключи объекта: `name`, `code`, `description`, `isFolder`, `childItems` (дерево). Тип кода (строковый/числовой)
|
||||||
|
берётся из объекта автоматически.
|
||||||
|
|
||||||
## add-enumValue / add-form / add-template / add-command
|
## add-enumValue / add-form / add-template / add-command
|
||||||
|
|
||||||
Просто имена (batch через `;;`):
|
Просто имена (batch через `;;`):
|
||||||
@@ -107,10 +128,34 @@ Batch через `;;` — можно указать разные ТЧ: `"Тов
|
|||||||
|
|
||||||
Формат: `ИмяЭлемента: ключ=значение, ключ=значение`
|
Формат: `ИмяЭлемента: ключ=значение, ключ=значение`
|
||||||
|
|
||||||
Ключи: `name` (rename), `type`, `synonym`, `indexing`, `fillChecking`, `use` и др.
|
**Спец-операции** (строчные ключи): `name` (переименование), `type` (смена типа), `synonym`.
|
||||||
|
|
||||||
|
**Свойства** задавайте по имени свойства 1С (PascalCase, как в конфигураторе): `Indexing`, `FillChecking`,
|
||||||
|
`Use`, `FullTextSearch`, `DataHistory`, `PasswordMode`, `MultiLine`, `Mask`, `CreateOnInput`, `QuickChoice` и др.
|
||||||
|
Свойство можно задать, даже если у реквизита оно ещё не выставлено. Опечатка в имени свойства → ошибка
|
||||||
|
(правка не теряется молча).
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
-Operation modify-attribute -Value "СтароеИмя: name=НовоеИмя, type=Строка(500)"
|
-Operation modify-attribute -Value "СтароеИмя: name=НовоеИмя, type=Строка(500)"
|
||||||
-Operation modify-attribute -Value "Комментарий: indexing=Index"
|
-Operation modify-attribute -Value "Комментарий: Indexing=Index, FullTextSearch=Use"
|
||||||
-Operation modify-enumValue -Value "СтароеЗначение: name=НовоеЗначение"
|
-Operation modify-enumValue -Value "СтароеЗначение: name=НовоеЗначение"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Структурные свойства реквизита
|
||||||
|
|
||||||
|
Свойства со сложным значением задавайте через JSON DSL (`{ "modify": { "attributes": { "Имя": { ... } } } }`):
|
||||||
|
|
||||||
|
| Ключ | Значение | Пример (JSON) |
|
||||||
|
|------|----------|---------------|
|
||||||
|
| `Format` / `EditFormat` / `ToolTip` | строка (мультиязычная) | `"Format": "ДФ=dd.MM.yyyy"` |
|
||||||
|
| `ChoiceForm` | путь формы выбора | `"ChoiceForm": "Catalog.Товары.Form.ФормаВыбора"` |
|
||||||
|
| `MinValue` / `MaxValue` | число или строка | `"MinValue": 0, "MaxValue": 100` |
|
||||||
|
| `FillValue` | значение заполнения | `"FillValue": "EmptyRef"` · `true` · `10` · `{"nil": true}` |
|
||||||
|
| `LinkByType` | `{dataPath, linkItem?}` | `"LinkByType": {"dataPath": "Вид", "linkItem": 0}` |
|
||||||
|
| `ChoiceParameterLinks` | `[{name, dataPath, valueChange?}]` | `["Отбор.Организация=Организация"]` |
|
||||||
|
| `ChoiceParameters` | `[{name, type?, value?}]` | `[{"name": "Отбор.ЭтоГруппа", "value": false}]` |
|
||||||
|
|
||||||
|
- `FillValue`: `"EmptyRef"` — пустая ссылка по типу реквизита; `{"emptyRef": true}` / `{"nil": true}` — явные маркеры.
|
||||||
|
- `ChoiceParameters` value — булево/число/строка/ссылочный путь или массив; укажите `type` (напр.
|
||||||
|
`EnumRef.СтавкиНДС`), чтобы задавать значения короткими именами (`"Оптовая"` вместо полного пути).
|
||||||
|
- В путях данных (`LinkByType`/`ChoiceParameterLinks`) можно писать короткое имя реквизита вместо полного пути.
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
# Свойства объекта и complex properties
|
# Свойства объекта и свойства-списки
|
||||||
|
|
||||||
Справочник операций для скалярных свойств объекта и свойств со вложенной XML-структурой (Owners, RegisterRecords, BasedOn, InputByString).
|
Справочник операций для обычных свойств объекта и свойств-списков (Owners, RegisterRecords, BasedOn, InputByString).
|
||||||
|
|
||||||
## modify-property
|
## modify-property
|
||||||
|
|
||||||
Изменение скалярных свойств объекта. Формат: `Ключ=Значение` (batch через `;;`):
|
Изменение свойств объекта по имени свойства 1С (PascalCase, как в конфигураторе). Формат: `Ключ=Значение`
|
||||||
|
(batch через `;;`):
|
||||||
```powershell
|
```powershell
|
||||||
-Operation modify-property -Value "CodeLength=11 ;; DescriptionLength=150"
|
-Operation modify-property -Value "CodeLength=11 ;; DescriptionLength=150"
|
||||||
-Operation modify-property -Value "Hierarchical=true"
|
-Operation modify-property -Value "Hierarchical=true"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Complex properties
|
Свойство можно задать, даже если оно ещё не выставлено у объекта (например `FullTextSearch`, `DataHistory`).
|
||||||
|
Опечатка в имени свойства → ошибка (правка не теряется молча). Допустимы имена свойств соответствующего типа объекта.
|
||||||
|
|
||||||
Свойства со вложенной XML-структурой. Поддерживаются через inline `add-*` / `remove-*` / `set-*` и через JSON `modify.properties`.
|
## Свойства-списки
|
||||||
|
|
||||||
|
Свойства, значение которых — список ссылок. Управляются через inline `add-*` / `remove-*` / `set-*` и через JSON `modify.properties`.
|
||||||
|
|
||||||
| Свойство | Объекты | Inline-значение |
|
| Свойство | Объекты | Inline-значение |
|
||||||
|----------|---------|-----------------|
|
|----------|---------|-----------------|
|
||||||
@@ -20,31 +24,36 @@
|
|||||||
| RegisterRecords | Document | `AccumulationRegister.XXX` |
|
| RegisterRecords | Document | `AccumulationRegister.XXX` |
|
||||||
| BasedOn | Document, Catalog, BP, Task | `Document.XXX` |
|
| BasedOn | Document, Catalog, BP, Task | `Document.XXX` |
|
||||||
| InputByString | Catalog, ChartOf*, Task | `StandardAttribute.Description` |
|
| InputByString | Catalog, ChartOf*, Task | `StandardAttribute.Description` |
|
||||||
|
| DataLockFields | Catalog, Document, регистры и др. | `Организация` (короткое имя реквизита → полный путь) |
|
||||||
|
| RegisteredDocuments | DocumentJournal | `Document.XXX` |
|
||||||
|
|
||||||
### add-owner / add-registerRecord / add-basedOn
|
### add-owner / add-registerRecord / add-basedOn / add-registeredDocument
|
||||||
|
|
||||||
Полное имя метаданных `MetaType.Name`:
|
Полное имя метаданных `MetaType.Name`:
|
||||||
```powershell
|
```powershell
|
||||||
-Operation add-owner -Value "Catalog.Контрагенты ;; Catalog.Организации"
|
-Operation add-owner -Value "Catalog.Контрагенты ;; Catalog.Организации"
|
||||||
-Operation add-registerRecord -Value "AccumulationRegister.ОстаткиТоваров"
|
-Operation add-registerRecord -Value "AccumulationRegister.ОстаткиТоваров"
|
||||||
-Operation add-basedOn -Value "Document.ЗаказКлиента"
|
-Operation add-basedOn -Value "Document.ЗаказКлиента"
|
||||||
|
-Operation add-registeredDocument -Value "Document.РасходныйОрдер"
|
||||||
```
|
```
|
||||||
|
|
||||||
### add-inputByString
|
### add-inputByString / add-dataLockField
|
||||||
|
|
||||||
Пути полей (префикс `MetaType.Name.` добавляется автоматически):
|
Пути полей (короткое имя реквизита разворачивается в полный путь автоматически):
|
||||||
```powershell
|
```powershell
|
||||||
-Operation add-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
-Operation add-inputByString -Value "StandardAttribute.Description ;; StandardAttribute.Code"
|
||||||
|
-Operation add-dataLockField -Value "Организация ;; Контрагент"
|
||||||
```
|
```
|
||||||
|
|
||||||
### remove-owner / remove-registerRecord / remove-basedOn / remove-inputByString
|
### remove-owner / remove-registerRecord / remove-basedOn / remove-inputByString / remove-dataLockField / remove-registeredDocument
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
-Operation remove-owner -Value "Catalog.Контрагенты"
|
-Operation remove-owner -Value "Catalog.Контрагенты"
|
||||||
-Operation remove-inputByString -Value "Catalog.МойСпр.StandardAttribute.Code"
|
-Operation remove-inputByString -Value "Catalog.МойСпр.StandardAttribute.Code"
|
||||||
|
-Operation remove-dataLockField -Value "Организация"
|
||||||
```
|
```
|
||||||
|
|
||||||
### set-owners / set-registerRecords / set-basedOn / set-inputByString
|
### set-owners / set-registerRecords / set-basedOn / set-inputByString / set-dataLockFields / set-registeredDocuments
|
||||||
|
|
||||||
Заменяют **весь список** (в отличие от add/remove):
|
Заменяют **весь список** (в отличие от add/remove):
|
||||||
```powershell
|
```powershell
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-edit v1.9 — Edit existing 1C metadata object XML (inline mode + complex properties + TS attribute ops + modify-ts)
|
# meta-edit v1.20 — Edit existing 1C metadata object XML (+add-predefined предопределённые Ext/Predefined.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[string]$DefinitionFile,
|
[string]$DefinitionFile,
|
||||||
@@ -12,14 +12,17 @@ param(
|
|||||||
"add-attribute", "add-ts", "add-dimension", "add-resource",
|
"add-attribute", "add-ts", "add-dimension", "add-resource",
|
||||||
"add-enumValue", "add-column", "add-form", "add-template", "add-command",
|
"add-enumValue", "add-column", "add-form", "add-template", "add-command",
|
||||||
"add-owner", "add-registerRecord", "add-basedOn", "add-inputByString",
|
"add-owner", "add-registerRecord", "add-basedOn", "add-inputByString",
|
||||||
|
"add-dataLockField", "add-registeredDocument", "add-predefined",
|
||||||
"remove-attribute", "remove-ts", "remove-dimension", "remove-resource",
|
"remove-attribute", "remove-ts", "remove-dimension", "remove-resource",
|
||||||
"remove-enumValue", "remove-column", "remove-form", "remove-template", "remove-command",
|
"remove-enumValue", "remove-column", "remove-form", "remove-template", "remove-command",
|
||||||
"remove-owner", "remove-registerRecord", "remove-basedOn", "remove-inputByString",
|
"remove-owner", "remove-registerRecord", "remove-basedOn", "remove-inputByString",
|
||||||
|
"remove-dataLockField", "remove-registeredDocument",
|
||||||
"add-ts-attribute", "remove-ts-attribute", "modify-ts-attribute", "modify-ts",
|
"add-ts-attribute", "remove-ts-attribute", "modify-ts-attribute", "modify-ts",
|
||||||
"modify-attribute", "modify-dimension", "modify-resource",
|
"modify-attribute", "modify-dimension", "modify-resource",
|
||||||
"modify-enumValue", "modify-column",
|
"modify-enumValue", "modify-column",
|
||||||
"modify-property",
|
"modify-property",
|
||||||
"set-owners", "set-registerRecords", "set-basedOn", "set-inputByString"
|
"set-owners", "set-registerRecords", "set-basedOn", "set-inputByString",
|
||||||
|
"set-dataLockFields", "set-registeredDocuments"
|
||||||
)]
|
)]
|
||||||
[string]$Operation,
|
[string]$Operation,
|
||||||
[string]$Value,
|
[string]$Value,
|
||||||
@@ -78,7 +81,7 @@ $script:validEnumValues = @{
|
|||||||
"Posting" = @("Allow","Deny")
|
"Posting" = @("Allow","Deny")
|
||||||
"RealTimePosting" = @("Allow","Deny")
|
"RealTimePosting" = @("Allow","Deny")
|
||||||
"EditType" = @("InDialog","InList","BothWays")
|
"EditType" = @("InDialog","InList","BothWays")
|
||||||
"HierarchyType" = @("HierarchyFoldersAndItems","HierarchyItemsOnly")
|
"HierarchyType" = @("HierarchyFoldersAndItems","HierarchyOfItems")
|
||||||
"CodeType" = @("String","Number")
|
"CodeType" = @("String","Number")
|
||||||
"CodeAllowedLength" = @("Variable","Fixed")
|
"CodeAllowedLength" = @("Variable","Fixed")
|
||||||
"NumberType" = @("String","Number")
|
"NumberType" = @("String","Number")
|
||||||
@@ -167,6 +170,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -203,10 +216,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -607,6 +623,8 @@ function Import-Fragment([string]$xmlString) {
|
|||||||
xmlns:v8="http://v8.1c.ru/8.1/data/core"
|
xmlns:v8="http://v8.1c.ru/8.1/data/core"
|
||||||
xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"
|
xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"
|
||||||
xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"
|
xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"
|
||||||
|
xmlns:app="http://v8.1c.ru/8.2/managed-application/core"
|
||||||
|
xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"
|
||||||
xmlns:xs="http://www.w3.org/2001/XMLSchema">$xmlString</_W>
|
xmlns:xs="http://www.w3.org/2001/XMLSchema">$xmlString</_W>
|
||||||
"@
|
"@
|
||||||
$frag = New-Object System.Xml.XmlDocument
|
$frag = New-Object System.Xml.XmlDocument
|
||||||
@@ -890,20 +908,33 @@ $script:reservedAttrNames = @{
|
|||||||
"Account"="Счет"; "ValueType"="ТипЗначения"; "ActionPeriodIsBasic"="ПериодДействияБазовый"
|
"Account"="Счет"; "ValueType"="ТипЗначения"; "ActionPeriodIsBasic"="ПериодДействияБазовый"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Стандартные реквизиты по типу объекта (ключи из reservedAttrNames). Имя реквизита, совпадающее
|
||||||
|
# с ними (англ. ИЛИ рус.), платформа не позволит — жёсткий отказ. Контексты вне карты → предупреждение.
|
||||||
|
$script:reservedByContext = @{
|
||||||
|
"catalog" = @("Ref","DeletionMark","Predefined","PredefinedDataName","Code","Description","Owner","Parent","IsFolder")
|
||||||
|
"document" = @("Ref","DeletionMark","Date","Number","Posted")
|
||||||
|
}
|
||||||
|
|
||||||
function Build-AttributeFragment {
|
function Build-AttributeFragment {
|
||||||
param($parsed, [string]$context, [string]$indent)
|
param($parsed, [string]$context, [string]$indent)
|
||||||
|
|
||||||
if (-not $context) { $context = Get-AttributeContext }
|
if (-not $context) { $context = Get-AttributeContext }
|
||||||
|
|
||||||
# Check reserved attribute names
|
# Check reserved attribute names (типозависимо: catalog/document — жёсткий отказ; прочее — предупреждение)
|
||||||
$attrName = $parsed.name
|
$attrName = $parsed.name
|
||||||
if ($script:reservedAttrNames.ContainsKey($attrName)) {
|
$ctxReserved = $script:reservedByContext[$context]
|
||||||
|
if ($ctxReserved) {
|
||||||
|
foreach ($en in $ctxReserved) {
|
||||||
|
$ru = $script:reservedAttrNames[$en]
|
||||||
|
if (($attrName -ieq $en) -or ($ru -and $attrName -ieq $ru)) {
|
||||||
|
Write-Error "Имя реквизита '$attrName' зарезервировано стандартным реквизитом ($en/$ru) объекта '$context'. Выберите другое имя."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($context -notin @("tabular", "processor-tabular") -and
|
||||||
|
($script:reservedAttrNames.ContainsKey($attrName) -or ($script:reservedAttrNames.Values -contains $attrName))) {
|
||||||
Write-Warning "Attribute '$attrName' conflicts with a standard attribute name. This may cause errors when loading into 1C."
|
Write-Warning "Attribute '$attrName' conflicts with a standard attribute name. This may cause errors when loading into 1C."
|
||||||
}
|
}
|
||||||
$ruValues = $script:reservedAttrNames.Values
|
|
||||||
if ($ruValues -contains $attrName) {
|
|
||||||
Write-Warning "Attribute '$attrName' conflicts with a standard attribute name (Russian). This may cause errors when loading into 1C."
|
|
||||||
}
|
|
||||||
|
|
||||||
$uuid = New-Guid-String
|
$uuid = New-Guid-String
|
||||||
$sb = New-Object System.Text.StringBuilder
|
$sb = New-Object System.Text.StringBuilder
|
||||||
@@ -1475,6 +1506,8 @@ function Convert-InlineToDefinition([string]$operation, [string]$value) {
|
|||||||
"registerRecord" = "RegisterRecords"; "registerRecords" = "RegisterRecords"
|
"registerRecord" = "RegisterRecords"; "registerRecords" = "RegisterRecords"
|
||||||
"basedOn" = "BasedOn"
|
"basedOn" = "BasedOn"
|
||||||
"inputByString" = "InputByString"
|
"inputByString" = "InputByString"
|
||||||
|
"dataLockField" = "DataLockFields"; "dataLockFields" = "DataLockFields"
|
||||||
|
"registeredDocument" = "RegisteredDocuments"; "registeredDocuments" = "RegisteredDocuments"
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($complexTargetMap.ContainsKey($target)) {
|
if ($complexTargetMap.ContainsKey($target)) {
|
||||||
@@ -1499,6 +1532,16 @@ function Convert-InlineToDefinition([string]$operation, [string]$value) {
|
|||||||
return $def
|
return $def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Предопределённые (Ext/Predefined.xml) — отдельный файл; строим { <op>: { predefined: [...] } }.
|
||||||
|
if ($target -eq 'predefined') {
|
||||||
|
$items = @($value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
$inner = New-Object PSCustomObject
|
||||||
|
$inner | Add-Member -NotePropertyName 'predefined' -NotePropertyValue $items
|
||||||
|
$def = New-Object PSCustomObject
|
||||||
|
$def | Add-Member -NotePropertyName $op -NotePropertyValue $inner
|
||||||
|
return $def
|
||||||
|
}
|
||||||
|
|
||||||
# TS attribute operations: dot notation "TSName.AttrDef"
|
# TS attribute operations: dot notation "TSName.AttrDef"
|
||||||
if ($target -eq "ts-attribute") {
|
if ($target -eq "ts-attribute") {
|
||||||
$items = @($value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
$items = @($value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
@@ -1741,6 +1784,10 @@ function Process-Add($addDef) {
|
|||||||
$addDef.PSObject.Properties | ForEach-Object {
|
$addDef.PSObject.Properties | ForEach-Object {
|
||||||
$rawKey = $_.Name
|
$rawKey = $_.Name
|
||||||
$items = $_.Value
|
$items = $_.Value
|
||||||
|
if ($rawKey -in @('predefined','предопределенные','предопределённые')) {
|
||||||
|
Add-PredefinedItems $items
|
||||||
|
return
|
||||||
|
}
|
||||||
$childType = Resolve-ChildTypeKey $rawKey
|
$childType = Resolve-ChildTypeKey $rawKey
|
||||||
|
|
||||||
if (-not $childType) {
|
if (-not $childType) {
|
||||||
@@ -1961,8 +2008,19 @@ function Modify-Properties($propsDef) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $propEl) {
|
if (-not $propEl) {
|
||||||
Warn "Property '$propName' not found in Properties"
|
# create-if-missing: известное свойство создаём (порядок 1С терпит — append); неизвестное → ошибка (опечатка)
|
||||||
return
|
if ($script:knownObjectProps -notcontains $propName) {
|
||||||
|
Write-Error "modify-property: неизвестное свойство '$propName' — нет такого свойства объекта (опечатка?)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$newNodes = Import-Fragment "<$propName/>"
|
||||||
|
if ($newNodes.Count -gt 0) {
|
||||||
|
Insert-PropertyInOrder $script:propertiesEl $newNodes[0] $null $propName
|
||||||
|
$propEl = $newNodes[0]
|
||||||
|
} else {
|
||||||
|
Warn "Property '$propName': could not create element"
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Complex property: Owners, RegisterRecords, BasedOn, InputByString
|
# Complex property: Owners, RegisterRecords, BasedOn, InputByString
|
||||||
@@ -2216,6 +2274,56 @@ function Modify-ChildElements($modifyDef, [string]$childType) {
|
|||||||
Info "Changed synonym of $xmlTag '$elemName': $changeValue"
|
Info "Changed synonym of $xmlTag '$elemName': $changeValue"
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
}
|
}
|
||||||
|
"Format" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "Format" (Build-MLTextXml (Get-ChildIndent $propsEl) "Format" "$changeValue")) {
|
||||||
|
Info "Set $xmlTag '$elemName'.Format"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"EditFormat" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "EditFormat" (Build-MLTextXml (Get-ChildIndent $propsEl) "EditFormat" "$changeValue")) {
|
||||||
|
Info "Set $xmlTag '$elemName'.EditFormat"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ToolTip" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "ToolTip" (Build-MLTextXml (Get-ChildIndent $propsEl) "ToolTip" "$changeValue")) {
|
||||||
|
Info "Set $xmlTag '$elemName'.ToolTip"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ChoiceForm" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "ChoiceForm" "<ChoiceForm>$(Esc-Xml "$changeValue")</ChoiceForm>") {
|
||||||
|
Info "Set $xmlTag '$elemName'.ChoiceForm"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"MinValue" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "MinValue" (Build-MinMaxValueXml "MinValue" $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.MinValue"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"MaxValue" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "MaxValue" (Build-MinMaxValueXml "MaxValue" $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.MaxValue"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"LinkByType" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "LinkByType" (Build-LinkByTypeXml (Get-ChildIndent $propsEl) $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.LinkByType"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ChoiceParameterLinks" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "ChoiceParameterLinks" (Build-ChoiceParameterLinksXml (Get-ChildIndent $propsEl) $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.ChoiceParameterLinks"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ChoiceParameters" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "ChoiceParameters" (Build-ChoiceParametersXml (Get-ChildIndent $propsEl) $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.ChoiceParameters"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"FillValue" {
|
||||||
|
if (Set-AttrPropertyElement $propsEl "FillValue" (Build-FillValueExplicitXml (Get-AttrTypeStrFromXml $propsEl) $changeValue)) {
|
||||||
|
Info "Set $xmlTag '$elemName'.FillValue"; $script:modifyCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
default {
|
default {
|
||||||
# Scalar property change (Indexing, FillChecking, Use, etc.)
|
# Scalar property change (Indexing, FillChecking, Use, etc.)
|
||||||
$scalarEl = $null
|
$scalarEl = $null
|
||||||
@@ -2235,7 +2343,23 @@ function Modify-ChildElements($modifyDef, [string]$childType) {
|
|||||||
Info "Modified $xmlTag '$elemName'.$changeProp = $valueStr"
|
Info "Modified $xmlTag '$elemName'.$changeProp = $valueStr"
|
||||||
$script:modifyCount++
|
$script:modifyCount++
|
||||||
} else {
|
} else {
|
||||||
Warn "$xmlTag '$elemName': property '$changeProp' not found"
|
# create-if-missing: известное свойство создаём в позиции; неизвестное → ошибка (опечатка)
|
||||||
|
if ($script:knownChildProps -notcontains $changeProp) {
|
||||||
|
Write-Error "modify: неизвестное свойство '$changeProp' у $xmlTag '$elemName' (опечатка?)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$valueStr = "$changeValue"
|
||||||
|
if ($changeValue -is [bool]) {
|
||||||
|
$valueStr = if ($changeValue) { "true" } else { "false" }
|
||||||
|
} else {
|
||||||
|
$valueStr = Normalize-EnumValue $changeProp $valueStr
|
||||||
|
}
|
||||||
|
$newNodes = Import-Fragment "<$changeProp>$(Esc-Xml $valueStr)</$changeProp>"
|
||||||
|
if ($newNodes.Count -gt 0) {
|
||||||
|
Insert-PropertyInOrder $propsEl $newNodes[0] $script:attrPropOrder $changeProp
|
||||||
|
Info "Created $xmlTag '$elemName'.$changeProp = $valueStr"
|
||||||
|
$script:modifyCount++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2271,6 +2395,433 @@ $script:complexPropertyMap = @{
|
|||||||
"RegisterRecords" = @{ tag = "xr:Item"; attr = 'xsi:type="xr:MDObjectRef"' }
|
"RegisterRecords" = @{ tag = "xr:Item"; attr = 'xsi:type="xr:MDObjectRef"' }
|
||||||
"BasedOn" = @{ tag = "xr:Item"; attr = 'xsi:type="xr:MDObjectRef"' }
|
"BasedOn" = @{ tag = "xr:Item"; attr = 'xsi:type="xr:MDObjectRef"' }
|
||||||
"InputByString" = @{ tag = "xr:Field"; attr = $null }
|
"InputByString" = @{ tag = "xr:Field"; attr = $null }
|
||||||
|
"DataLockFields" = @{ tag = "xr:Field"; attr = $null; expand = $true }
|
||||||
|
"RegisteredDocuments" = @{ tag = "xr:Item"; attr = 'xsi:type="xr:MDObjectRef"' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Известные свойства объекта (union по корпусу acc+erp 8.3.24) — allowlist для modify-property.
|
||||||
|
# Известное отсутствующее свойство create-if-missing создаётся; неизвестное (опечатка) → ошибка.
|
||||||
|
$script:knownObjectProps = @(
|
||||||
|
'ActionPeriod','ActionPeriodUse','Addressing','AutoOrderByCode','Autonumbering','AuxiliaryChoiceForm',
|
||||||
|
'AuxiliaryFolderChoiceForm','AuxiliaryFolderForm','AuxiliaryForm','AuxiliaryListForm','AuxiliaryObjectForm',
|
||||||
|
'AuxiliaryRecordForm','AuxiliarySettingsForm','BaseCalculationTypes','BasePeriod','BasedOn',
|
||||||
|
'CharacteristicExtValues','Characteristics','ChartOfAccounts','ChartOfCalculationTypes','CheckUnique',
|
||||||
|
'ChoiceDataGetModeOnInputByString','ChoiceFoldersAndItems','ChoiceForm','ChoiceHistoryOnInput','ChoiceMode',
|
||||||
|
'ChoiceParameterLinks','ChoiceParameters','CodeAllowedLength','CodeLength','CodeMask','CodeSeries','CodeType',
|
||||||
|
'Comment','Correspondence','CreateOnInput','CreateTaskInPrivilegedMode','CurrentPerformer','DataHistory',
|
||||||
|
'DataLockControlMode','DataLockFields','DefaultChoiceForm','DefaultFolderChoiceForm','DefaultFolderForm',
|
||||||
|
'DefaultForm','DefaultListForm','DefaultObjectForm','DefaultPresentation','DefaultRecordForm','DefaultSettingsForm',
|
||||||
|
'DefaultVariantForm','DependenceOnCalculationTypes','DescriptionLength','DistributedInfoBase','EditFormat',
|
||||||
|
'EditType','EnableTotalsSliceFirst','EnableTotalsSliceLast','EnableTotalsSplitting',
|
||||||
|
'ExecuteAfterWriteDataHistoryVersionProcessing','Explanation','ExtDimensionTypes','ExtendedEdit',
|
||||||
|
'ExtendedListPresentation','ExtendedObjectPresentation','ExtendedPresentation','ExtendedRecordPresentation',
|
||||||
|
'FillChecking','FoldersOnTop','Format','FullTextSearch','FullTextSearchOnInputByString','Hierarchical',
|
||||||
|
'HierarchyType','IncludeConfigurationExtensions','IncludeHelpInContents','InformationRegisterPeriodicity',
|
||||||
|
'InputByString','LevelCount','LimitLevelCount','LinkByType','ListPresentation','MainAddressingAttribute',
|
||||||
|
'MainDataCompositionSchema','MainFilterOnPeriod','MarkNegatives','Mask','MaxExtDimensionCount','MaxValue',
|
||||||
|
'MinValue','MultiLine','Name','NumberAllowedLength','NumberLength','NumberPeriodicity','NumberType','Numerator',
|
||||||
|
'ObjectPresentation','OrderLength','Owners','PasswordMode','PeriodAdjustmentLength','Periodicity',
|
||||||
|
'PostInPrivilegedMode','Posting','PredefinedDataUpdate','QuickChoice','RealTimePosting','RecordPresentation',
|
||||||
|
'RegisterRecords','RegisterRecordsDeletion','RegisterRecordsWritingOnPost','RegisterType','RegisteredDocuments',
|
||||||
|
'Schedule','ScheduleDate','ScheduleValue','SearchStringModeOnInputByString','SequenceFilling','SettingsStorage',
|
||||||
|
'StandardAttributes','StandardTabularSections','SubordinationUse','Synonym','Task','TaskNumberAutoPrefix',
|
||||||
|
'ToolTip','Type','UnpostInPrivilegedMode','UpdateDataHistoryImmediatelyAfterWrite','UseStandardCommands',
|
||||||
|
'VariantsStorage','WriteMode'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Известные свойства дочерних элементов (union Attribute/Dimension/Resource по корпусу) — allowlist default-ветки
|
||||||
|
# modify-attribute/-dimension/-resource.
|
||||||
|
$script:knownChildProps = @(
|
||||||
|
'AccountingFlag','Balance','BaseDimension','ChoiceFoldersAndItems','ChoiceForm','ChoiceHistoryOnInput',
|
||||||
|
'ChoiceParameterLinks','ChoiceParameters','Comment','CreateOnInput','DataHistory','DenyIncompleteValues',
|
||||||
|
'DocumentMap','EditFormat','ExtDimensionAccountingFlag','ExtendedEdit','FillChecking','FillFromFillingValue',
|
||||||
|
'FillValue','Format','FullTextSearch','Indexing','LinkByType','MainFilter','MarkNegatives','Mask','Master',
|
||||||
|
'MaxValue','MinValue','MultiLine','Name','PasswordMode','QuickChoice','RegisterRecordsMap','ScheduleLink',
|
||||||
|
'Synonym','ToolTip','Type','Use','UseInTotals'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Канонический порядок свойств реквизита (последовательность Build-AttributeFragment) — для вставки в позицию.
|
||||||
|
# Порядок 1С терпит (cert), но держим канонический для консистентности с meta-compile.
|
||||||
|
$script:attrPropOrder = @(
|
||||||
|
'Name','Synonym','Comment','Type','PasswordMode','Format','EditFormat','ToolTip','MarkNegatives','Mask',
|
||||||
|
'MultiLine','ExtendedEdit','MinValue','MaxValue','FillFromFillingValue','FillValue','FillChecking',
|
||||||
|
'ChoiceFoldersAndItems','ChoiceParameterLinks','ChoiceParameters','QuickChoice','CreateOnInput','ChoiceForm',
|
||||||
|
'LinkByType','ChoiceHistoryOnInput','Use','Indexing','FullTextSearch','DataHistory'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Вставить новый элемент свойства в Properties в канонической позиции (по orderArray); если свойства нет в
|
||||||
|
# orderArray (или orderArray пуст) — append. Порядок 1С терпит, канонический — для консистентности/снапшотов.
|
||||||
|
function Insert-PropertyInOrder($propsEl, $newNode, $orderArray, $propName) {
|
||||||
|
$childIndent = "$(Get-ChildIndent $propsEl)"
|
||||||
|
$refNode = $null
|
||||||
|
$idx = if ($orderArray) { [array]::IndexOf($orderArray, $propName) } else { -1 }
|
||||||
|
if ($idx -ge 0) {
|
||||||
|
foreach ($ch in $propsEl.ChildNodes) {
|
||||||
|
if ($ch.NodeType -eq 'Element') {
|
||||||
|
$ci = [array]::IndexOf($orderArray, $ch.LocalName)
|
||||||
|
if ($ci -gt $idx) { $refNode = $ch; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Insert-BeforeElement $propsEl $newNode $refNode $childIndent
|
||||||
|
}
|
||||||
|
|
||||||
|
# Заменить существующий элемент свойства реквизита новым фрагментом (по образцу ветки type),
|
||||||
|
# либо создать в канонической позиции, если его нет. Возвращает $true при успехе.
|
||||||
|
function Set-AttrPropertyElement($propsEl, $propName, $fragmentXml) {
|
||||||
|
$newNodes = Import-Fragment $fragmentXml
|
||||||
|
if ($newNodes.Count -eq 0) { return $false }
|
||||||
|
$existing = $null
|
||||||
|
foreach ($ch in $propsEl.ChildNodes) {
|
||||||
|
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq $propName) { $existing = $ch; break }
|
||||||
|
}
|
||||||
|
if ($existing) {
|
||||||
|
# InsertBefore+RemoveChild сохраняет ведущий/хвостовой whitespace позиции existing
|
||||||
|
# (InsertAfter+Remove-NodeWithWhitespace склеил бы: удаляет ведущий ws как отдельный узел).
|
||||||
|
$propsEl.InsertBefore($newNodes[0], $existing) | Out-Null
|
||||||
|
$propsEl.RemoveChild($existing) | Out-Null
|
||||||
|
} else {
|
||||||
|
Insert-PropertyInOrder $propsEl $newNodes[0] $script:attrPropOrder $propName
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# MinValue/MaxValue — типизированное значение (порт Emit-MinMaxValue): nil / xs:string / xs:decimal.
|
||||||
|
function Build-MinMaxValueXml([string]$tag, $val) {
|
||||||
|
if ($null -eq $val -or "$val" -eq '') { return "<$tag xsi:nil=`"true`"/>" }
|
||||||
|
$t = if ($val -is [string]) { 'xs:string' } else { 'xs:decimal' }
|
||||||
|
return "<$tag xsi:type=`"$t`">$(Esc-Xml "$val")</$tag>"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: развёртка путей данных + связи выбора / тип по ссылке (structural modify) ---
|
||||||
|
|
||||||
|
# Свойство из dict/PSCustomObject по списку синонимов (первый найденный, иначе $null).
|
||||||
|
function Get-ChElProp($obj, [string[]]$names) {
|
||||||
|
if ($null -eq $obj) { return $null }
|
||||||
|
foreach ($n in $names) {
|
||||||
|
if ($obj -is [System.Collections.IDictionary]) { if ($obj.Contains($n)) { return $obj[$n] } }
|
||||||
|
elseif ($obj.PSObject -and $obj.PSObject.Properties[$n]) { return $obj.PSObject.Properties[$n].Value }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Стандартный реквизит рус/англ → английский (для Catalog/Document); использует существующие reserved-карты.
|
||||||
|
function Resolve-StdAttrEn([string]$name) {
|
||||||
|
$ctx = switch ("$script:objType") { 'Catalog' { 'catalog' } 'Document' { 'document' } default { $null } }
|
||||||
|
if (-not $ctx) { return $null }
|
||||||
|
$stdSet = $script:reservedByContext[$ctx]
|
||||||
|
foreach ($en in $stdSet) {
|
||||||
|
$ru = $script:reservedAttrNames[$en]
|
||||||
|
if (($name -ieq $en) -or ($ru -and $name -ieq $ru)) { return $en }
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Прощающий ввод пути данных: короткое имя реквизита → полный путь объекта (порт Expand-DataPath).
|
||||||
|
function Expand-DataPath([string]$dp) {
|
||||||
|
if (-not $dp) { return $dp }
|
||||||
|
$s = "$dp"
|
||||||
|
if ($s -match '[:/]') { return $s }
|
||||||
|
if ($s -match '^-?\d+$') { return $s }
|
||||||
|
if ($s -match '^(StandardAttribute|Attribute)\.') { return "$($script:objType).$($script:objName).$s" }
|
||||||
|
if (-not $s.Contains('.')) {
|
||||||
|
$en = Resolve-StdAttrEn $s
|
||||||
|
if ($en) { return "$($script:objType).$($script:objName).StandardAttribute.$en" }
|
||||||
|
return "$($script:objType).$($script:objName).Attribute.$s"
|
||||||
|
}
|
||||||
|
return $s
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shorthand "name=path" | "name=path:Clear|DontChange" → {name, dataPath, valueChange?}.
|
||||||
|
function ConvertFrom-ChLinkShorthand([string]$s) {
|
||||||
|
$eq = $s.IndexOf('=')
|
||||||
|
if ($eq -lt 0) { return @{ name = $s.Trim() } }
|
||||||
|
$o = @{ name = $s.Substring(0, $eq).Trim() }; $rest = $s.Substring($eq + 1).Trim()
|
||||||
|
if ($rest -match '^(.*):(?i:(Clear|DontChange|очистить|неизменять))$') { $o['dataPath'] = $matches[1].Trim(); $o['valueChange'] = $matches[2] }
|
||||||
|
else { $o['dataPath'] = $rest }
|
||||||
|
return $o
|
||||||
|
}
|
||||||
|
|
||||||
|
# LinkByType — {dataPath, linkItem?} (порт Emit-LinkByType). Строка → dataPath, linkItem=0.
|
||||||
|
function Build-LinkByTypeXml([string]$indent, $spec) {
|
||||||
|
if (-not $spec) { return "$indent<LinkByType/>" }
|
||||||
|
if ($spec -is [string]) { $dp = "$spec"; $li = 0 }
|
||||||
|
else {
|
||||||
|
$dp = "$(Get-ChElProp $spec @('dataPath','path','путь'))"
|
||||||
|
$liRaw = Get-ChElProp $spec @('linkItem','элементСвязи')
|
||||||
|
$li = if ($null -ne $liRaw) { $liRaw } else { 0 }
|
||||||
|
}
|
||||||
|
if (-not $dp) { return "$indent<LinkByType/>" }
|
||||||
|
$dp = Expand-DataPath $dp
|
||||||
|
$lines = @(
|
||||||
|
"$indent<LinkByType>"
|
||||||
|
"$indent`t<xr:DataPath>$(Esc-Xml "$dp")</xr:DataPath>"
|
||||||
|
"$indent`t<xr:LinkItem>$li</xr:LinkItem>"
|
||||||
|
"$indent</LinkByType>"
|
||||||
|
)
|
||||||
|
return $lines -join "`r`n"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ChoiceParameterLinks — [{name, dataPath, valueChange?}] (порт Emit-ChoiceParameterLinks). valueChange дефолт Clear.
|
||||||
|
function Build-ChoiceParameterLinksXml([string]$indent, $cpl) {
|
||||||
|
if (-not $cpl -or @($cpl).Count -eq 0) { return "$indent<ChoiceParameterLinks/>" }
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
$sb.Append("$indent<ChoiceParameterLinks>") | Out-Null
|
||||||
|
foreach ($lk in @($cpl)) {
|
||||||
|
if ($lk -is [string]) { $lk = ConvertFrom-ChLinkShorthand $lk }
|
||||||
|
$name = Get-ChElProp $lk @('name','имя')
|
||||||
|
$dp = Expand-DataPath (Get-ChElProp $lk @('dataPath','path','путь'))
|
||||||
|
$vcRaw = Get-ChElProp $lk @('valueChange','режимИзменения')
|
||||||
|
$vc = 'Clear'
|
||||||
|
if ($vcRaw) {
|
||||||
|
$vc = switch -Regex ("$vcRaw".ToLower()) {
|
||||||
|
'^(clear|очистить|очистка)$' { 'Clear'; break }
|
||||||
|
'^(dontchange|неизменять|неменять|нет)$' { 'DontChange'; break }
|
||||||
|
default { "$vcRaw" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sb.Append("`r`n$indent`t<xr:Link>") | Out-Null
|
||||||
|
$sb.Append("`r`n$indent`t`t<xr:Name>$(Esc-Xml "$name")</xr:Name>") | Out-Null
|
||||||
|
$sb.Append("`r`n$indent`t`t<xr:DataPath xsi:type=`"xs:string`">$(Esc-Xml "$dp")</xr:DataPath>") | Out-Null
|
||||||
|
$sb.Append("`r`n$indent`t`t<xr:ValueChange>$vc</xr:ValueChange>") | Out-Null
|
||||||
|
$sb.Append("`r`n$indent`t</xr:Link>") | Out-Null
|
||||||
|
}
|
||||||
|
$sb.Append("`r`n$indent</ChoiceParameterLinks>") | Out-Null
|
||||||
|
return $sb.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: значения параметров выбора (ChoiceParameters) ---
|
||||||
|
|
||||||
|
$script:fillRefRoots = @{
|
||||||
|
'перечисление'='Enum'; 'справочник'='Catalog'; 'документ'='Document';
|
||||||
|
'плансчетов'='ChartOfAccounts'; 'планвидовхарактеристик'='ChartOfCharacteristicTypes';
|
||||||
|
'планвидоврасчета'='ChartOfCalculationTypes'; 'планвидоврасчёта'='ChartOfCalculationTypes';
|
||||||
|
'планобмена'='ExchangePlan'; 'бизнеспроцесс'='BusinessProcess'; 'задача'='Task';
|
||||||
|
'enum'='Enum'; 'catalog'='Catalog'; 'document'='Document'; 'chartofaccounts'='ChartOfAccounts';
|
||||||
|
'chartofcharacteristictypes'='ChartOfCharacteristicTypes'; 'chartofcalculationtypes'='ChartOfCalculationTypes';
|
||||||
|
'exchangeplan'='ExchangePlan'; 'businessprocess'='BusinessProcess'; 'task'='Task'
|
||||||
|
}
|
||||||
|
$script:fillEmptyRefWords = @('emptyref','пустаяссылка')
|
||||||
|
$script:fillEnumValWords = @('enumvalue','значениеперечисления')
|
||||||
|
$script:accountTypeValues = @('Active','Passive','ActivePassive')
|
||||||
|
$script:fillRefKindRoot = @{
|
||||||
|
'catalogref'='Catalog'; 'documentref'='Document'; 'enumref'='Enum';
|
||||||
|
'chartofaccountsref'='ChartOfAccounts'; 'chartofcharacteristictypesref'='ChartOfCharacteristicTypes';
|
||||||
|
'chartofcalculationtypesref'='ChartOfCalculationTypes'; 'exchangeplanref'='ExchangePlan';
|
||||||
|
'businessprocessref'='BusinessProcess'; 'taskref'='Task'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-ChScalar([string]$s) {
|
||||||
|
$t = "$s".Trim()
|
||||||
|
if ($t -match '^(?i:true|истина)$') { return $true }
|
||||||
|
if ($t -match '^(?i:false|ложь)$') { return $false }
|
||||||
|
if ($t -match '^-?\d+$') { return [int]$t }
|
||||||
|
if ($t -match '^-?\d+\.\d+$') { return [double]::Parse($t, [System.Globalization.CultureInfo]::InvariantCulture) }
|
||||||
|
return $t
|
||||||
|
}
|
||||||
|
|
||||||
|
function Format-FillNum($n) {
|
||||||
|
if ($n -is [double] -or $n -is [decimal]) { return $n.ToString([System.Globalization.CultureInfo]::InvariantCulture) }
|
||||||
|
return "$n"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-FillRef([string]$s) {
|
||||||
|
if ([string]::IsNullOrEmpty($s)) { return $null }
|
||||||
|
if ($s -match '^[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}\.[0-9a-fA-F-]+$') { return $s }
|
||||||
|
$parts = $s -split '\.'
|
||||||
|
if ($parts.Count -lt 2) { return $null }
|
||||||
|
$root = $script:fillRefRoots[$parts[0].ToLower()]
|
||||||
|
if (-not $root) { return $null }
|
||||||
|
$typeName = $parts[1]
|
||||||
|
if ($root -eq 'Enum') {
|
||||||
|
if ($parts.Count -eq 2) { return $null }
|
||||||
|
if ($parts.Count -eq 3) {
|
||||||
|
if ($script:fillEmptyRefWords -contains $parts[2].ToLower()) { return "Enum.$typeName.EmptyRef" }
|
||||||
|
return "Enum.$typeName.EnumValue.$($parts[2])"
|
||||||
|
}
|
||||||
|
$member = $parts[2]
|
||||||
|
if ($script:fillEnumValWords -contains $member.ToLower()) { $rest = $parts[3..($parts.Count-1)] -join '.' }
|
||||||
|
else { $rest = $parts[2..($parts.Count-1)] -join '.' }
|
||||||
|
return "Enum.$typeName.EnumValue.$rest"
|
||||||
|
}
|
||||||
|
$tail = @($parts[1..($parts.Count-1)])
|
||||||
|
for ($i = 0; $i -lt $tail.Count; $i++) {
|
||||||
|
if ($script:fillEmptyRefWords -contains $tail[$i].ToLower()) { $tail[$i] = 'EmptyRef' }
|
||||||
|
}
|
||||||
|
return "$root." + ($tail -join '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Expand-ChoiceRefValue([string]$value, [string]$typeStr) {
|
||||||
|
if (-not $typeStr) { return $null }
|
||||||
|
$t = Resolve-TypeStr $typeStr
|
||||||
|
$root = $null; $tn = $null
|
||||||
|
if ($t -match '^(\w+Ref)\.(.+)$') { $root = $script:fillRefKindRoot[$Matches[1].ToLower()]; $tn = $Matches[2] }
|
||||||
|
elseif ($t -match '^([^.]+)\.(.+)$') { $root = $script:fillRefRoots[$Matches[1].ToLower()]; $tn = $Matches[2] }
|
||||||
|
if (-not $root) { return $null }
|
||||||
|
if ($script:fillEmptyRefWords -contains "$value".ToLower()) { return "$root.$tn.EmptyRef" }
|
||||||
|
if ($root -eq 'Enum') { return "Enum.$tn.EnumValue.$value" }
|
||||||
|
return "$root.$tn.$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-ChoiceValue($value) {
|
||||||
|
if ($value -is [bool]) { return @{ XsiType='xs:boolean'; Text=$(if ($value) { 'true' } else { 'false' }) } }
|
||||||
|
if ($value -is [int] -or $value -is [long] -or $value -is [double] -or $value -is [decimal]) {
|
||||||
|
return @{ XsiType='xs:decimal'; Text=(Format-FillNum $value) }
|
||||||
|
}
|
||||||
|
$s = "$value"
|
||||||
|
if ($s -eq '') { return @{ XsiType='xs:string'; Text='' } }
|
||||||
|
if ($s -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$') { return @{ XsiType='xs:dateTime'; Text=$s } }
|
||||||
|
$ref = Normalize-FillRef $s
|
||||||
|
if ($ref) { return @{ XsiType='xr:DesignTimeRef'; Text=$ref } }
|
||||||
|
if ($script:accountTypeValues -contains $s) { return @{ XsiType='ent:AccountType'; Text=$s } }
|
||||||
|
return @{ XsiType='xs:string'; Text=$s }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Normalize-ChoiceValueT($value, [string]$typeStr) {
|
||||||
|
if ($typeStr -and ($value -is [string]) -and (-not "$value".Contains('.'))) {
|
||||||
|
$ex = Expand-ChoiceRefValue "$value" $typeStr
|
||||||
|
if ($ex) { return @{ XsiType='xr:DesignTimeRef'; Text=$ex } }
|
||||||
|
}
|
||||||
|
return Normalize-ChoiceValue $value
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertFrom-ChParamShorthand([string]$s) {
|
||||||
|
$eq = $s.IndexOf('=')
|
||||||
|
if ($eq -lt 0) { return @{ name = $s.Trim() } }
|
||||||
|
$name = $s.Substring(0, $eq).Trim(); $rest = $s.Substring($eq + 1)
|
||||||
|
if ($rest -match ',') {
|
||||||
|
$vals = @(); foreach ($p in ($rest -split ',')) { $vals += ,(ConvertTo-ChScalar $p) }
|
||||||
|
return @{ name = $name; value = $vals }
|
||||||
|
}
|
||||||
|
return @{ name = $name; value = (ConvertTo-ChScalar $rest) }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ChoiceParameters — [{name, type?, value?}] (порт Emit-ChoiceParameters). Значение на app:value (xsi:type=тип);
|
||||||
|
# массив → v8:FixedArray с v8:Value; без value → nil. Требует xmlns:app в Import-Fragment.
|
||||||
|
function Build-ChoiceParametersXml([string]$indent, $cp) {
|
||||||
|
if (-not $cp -or @($cp).Count -eq 0) { return "$indent<ChoiceParameters/>" }
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
$sb.Append("$indent<ChoiceParameters>") | Out-Null
|
||||||
|
foreach ($item in @($cp)) {
|
||||||
|
if ($item -is [string]) { $item = ConvertFrom-ChParamShorthand $item }
|
||||||
|
$name = Get-ChElProp $item @('name','имя')
|
||||||
|
$ptype = Get-ChElProp $item @('type','тип')
|
||||||
|
$hasVal = $false; $val = $null
|
||||||
|
if ($item -is [System.Collections.IDictionary]) {
|
||||||
|
if ($item.Contains('value')) { $hasVal = $true; $val = $item['value'] }
|
||||||
|
elseif ($item.Contains('значение')) { $hasVal = $true; $val = $item['значение'] }
|
||||||
|
} elseif ($item.PSObject) {
|
||||||
|
if ($item.PSObject.Properties['value']) { $hasVal = $true; $val = $item.PSObject.Properties['value'].Value }
|
||||||
|
elseif ($item.PSObject.Properties['значение']) { $hasVal = $true; $val = $item.PSObject.Properties['значение'].Value }
|
||||||
|
}
|
||||||
|
$valIsArray = ($val -is [System.Array]) -or ($val -is [System.Collections.IList] -and $val -isnot [string])
|
||||||
|
$sb.Append("`r`n$indent`t<app:item name=`"$(Esc-Xml "$name")`">") | Out-Null
|
||||||
|
if (-not $hasVal) {
|
||||||
|
$sb.Append("`r`n$indent`t`t<app:value xsi:nil=`"true`"/>") | Out-Null
|
||||||
|
} elseif ($valIsArray) {
|
||||||
|
$sb.Append("`r`n$indent`t`t<app:value xsi:type=`"v8:FixedArray`">") | Out-Null
|
||||||
|
foreach ($v in $val) {
|
||||||
|
$norm = Normalize-ChoiceValueT $v $ptype
|
||||||
|
if ([string]::IsNullOrEmpty($norm.Text)) { $sb.Append("`r`n$indent`t`t`t<v8:Value xsi:type=`"$($norm.XsiType)`"/>") | Out-Null }
|
||||||
|
else { $sb.Append("`r`n$indent`t`t`t<v8:Value xsi:type=`"$($norm.XsiType)`">$(Esc-Xml $norm.Text)</v8:Value>") | Out-Null }
|
||||||
|
}
|
||||||
|
$sb.Append("`r`n$indent`t`t</app:value>") | Out-Null
|
||||||
|
} else {
|
||||||
|
$norm = Normalize-ChoiceValueT $val $ptype
|
||||||
|
if ([string]::IsNullOrEmpty($norm.Text)) { $sb.Append("`r`n$indent`t`t<app:value xsi:type=`"$($norm.XsiType)`"/>") | Out-Null }
|
||||||
|
else { $sb.Append("`r`n$indent`t`t<app:value xsi:type=`"$($norm.XsiType)`">$(Esc-Xml $norm.Text)</app:value>") | Out-Null }
|
||||||
|
}
|
||||||
|
$sb.Append("`r`n$indent`t</app:item>") | Out-Null
|
||||||
|
}
|
||||||
|
$sb.Append("`r`n$indent</ChoiceParameters>") | Out-Null
|
||||||
|
return $sb.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: явное значение заполнения (FillValue) ---
|
||||||
|
|
||||||
|
$script:fillBoolTrue = @('true','истина','да')
|
||||||
|
$script:fillBoolFalse = @('false','ложь','нет')
|
||||||
|
|
||||||
|
function Esc-XmlText([string]$s) { return $s.Replace('&','&').Replace('<','<').Replace('>','>') }
|
||||||
|
|
||||||
|
function Get-FillTypeCategory([string]$typeStr) {
|
||||||
|
if (-not $typeStr) { return 'String' }
|
||||||
|
if ($typeStr -match '\+') { return 'Other' }
|
||||||
|
$t = Resolve-TypeStr $typeStr
|
||||||
|
if ($t -match '^Boolean$') { return 'Boolean' }
|
||||||
|
if ($t -match '^String(\(|$)') { return 'String' }
|
||||||
|
if ($t -match '^Number(\(|$)') { return 'Number' }
|
||||||
|
if ($t -match '^(Date|DateTime)$') { return 'Date' }
|
||||||
|
return 'Other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Expand-FillShortRef([string]$s, [string]$typeStr) {
|
||||||
|
if (-not $typeStr) { return $null }
|
||||||
|
if ($typeStr -match '\+') { return $null }
|
||||||
|
$t = Resolve-TypeStr $typeStr
|
||||||
|
if ($t -notmatch '^(\w+Ref)\.(.+)$') { return $null }
|
||||||
|
$root = $script:fillRefKindRoot[$Matches[1].ToLower()]
|
||||||
|
if (-not $root) { return $null }
|
||||||
|
$typeName = $Matches[2]
|
||||||
|
if ($script:fillEmptyRefWords -contains $s.ToLower()) { return "$root.$typeName.EmptyRef" }
|
||||||
|
if ($root -eq 'Enum') { return "Enum.$typeName.EnumValue.$s" }
|
||||||
|
return "$root.$typeName.$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-FillValueSpec([string]$s, [string]$typeStr) {
|
||||||
|
$cat = Get-FillTypeCategory $typeStr
|
||||||
|
if ($s -eq '') { return @{ XsiType='xs:string'; Text='' } }
|
||||||
|
if ($cat -eq 'String') { return @{ XsiType='xs:string'; Text=$s } }
|
||||||
|
if ($cat -eq 'Boolean' -or ($script:fillBoolTrue -contains $s.ToLower()) -or ($script:fillBoolFalse -contains $s.ToLower())) {
|
||||||
|
if ($script:fillBoolTrue -contains $s.ToLower()) { return @{ XsiType='xs:boolean'; Text='true' } }
|
||||||
|
if ($script:fillBoolFalse -contains $s.ToLower()) { return @{ XsiType='xs:boolean'; Text='false' } }
|
||||||
|
}
|
||||||
|
if ($cat -eq 'Number') { return @{ XsiType='xs:decimal'; Text=$s } }
|
||||||
|
if ($cat -eq 'Date' -or $s -match '^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$') {
|
||||||
|
if ($s -match '^\d{4}-\d{2}-\d{2}$') { $s = "${s}T00:00:00" }
|
||||||
|
return @{ XsiType='xs:dateTime'; Text=$s }
|
||||||
|
}
|
||||||
|
$ref = Normalize-FillRef $s
|
||||||
|
if ($ref) { return @{ XsiType='xr:DesignTimeRef'; Text=$ref } }
|
||||||
|
$short = Expand-FillShortRef $s $typeStr
|
||||||
|
if ($short) { return @{ XsiType='xr:DesignTimeRef'; Text=$short } }
|
||||||
|
return @{ XsiType='xs:string'; Text=$s }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Извлечь тип реквизита из XML (<Type>/<v8:Type>) → DSL-typeStr для категоризации FillValue.
|
||||||
|
function Get-AttrTypeStrFromXml($propsEl) {
|
||||||
|
$typeEl = $null
|
||||||
|
foreach ($ch in $propsEl.ChildNodes) { if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'Type') { $typeEl = $ch; break } }
|
||||||
|
if (-not $typeEl) { return "" }
|
||||||
|
$mapped = @()
|
||||||
|
foreach ($ch in $typeEl.ChildNodes) {
|
||||||
|
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'Type') {
|
||||||
|
$t = $ch.InnerText.Trim()
|
||||||
|
$colon = $t.IndexOf(':'); if ($colon -ge 0) { $t = $t.Substring($colon + 1) }
|
||||||
|
switch -Regex ($t) {
|
||||||
|
'^string$' { $mapped += 'String'; break }
|
||||||
|
'^decimal$' { $mapped += 'Number'; break }
|
||||||
|
'^boolean$' { $mapped += 'Boolean'; break }
|
||||||
|
'^dateTime$' { $mapped += 'Date'; break }
|
||||||
|
default { $mapped += $t }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($mapped.Count -eq 0) { return "" }
|
||||||
|
if ($mapped.Count -gt 1) { return ($mapped -join ' + ') }
|
||||||
|
return $mapped[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
# FillValue — явное значение (порт Emit-FillValue, ветка hasSpec). Маркеры {nil}/{emptyRef}; иначе по типу.
|
||||||
|
function Build-FillValueExplicitXml([string]$typeStr, $spec) {
|
||||||
|
if ($null -eq $spec) { return "<FillValue xsi:nil=`"true`"/>" }
|
||||||
|
if ($spec -is [bool]) { return "<FillValue xsi:type=`"xs:boolean`">$(if ($spec) { 'true' } else { 'false' })</FillValue>" }
|
||||||
|
if ($spec -is [int] -or $spec -is [long] -or $spec -is [double] -or $spec -is [decimal]) { return "<FillValue xsi:type=`"xs:decimal`">$(Format-FillNum $spec)</FillValue>" }
|
||||||
|
if ((Get-ChElProp $spec @('nil')) -eq $true) { return "<FillValue xsi:nil=`"true`"/>" }
|
||||||
|
if ((Get-ChElProp $spec @('emptyRef','пустаяссылка')) -eq $true) { return "<FillValue xsi:type=`"xr:DesignTimeRef`"/>" }
|
||||||
|
$r = Resolve-FillValueSpec "$spec" $typeStr
|
||||||
|
if ($r.Text -eq '' -and $r.XsiType -eq 'xs:string') { return "<FillValue xsi:type=`"xs:string`"/>" }
|
||||||
|
return "<FillValue xsi:type=`"$($r.XsiType)`">$(Esc-XmlText $r.Text)</FillValue>"
|
||||||
}
|
}
|
||||||
|
|
||||||
function Find-PropertyElement([string]$propName) {
|
function Find-PropertyElement([string]$propName) {
|
||||||
@@ -2295,6 +2846,7 @@ function Get-ComplexPropertyValues([System.Xml.XmlElement]$propEl) {
|
|||||||
function Add-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
function Add-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
||||||
$mapEntry = $script:complexPropertyMap[$propertyName]
|
$mapEntry = $script:complexPropertyMap[$propertyName]
|
||||||
if (-not $mapEntry) { Warn "Unknown complex property: $propertyName"; return }
|
if (-not $mapEntry) { Warn "Unknown complex property: $propertyName"; return }
|
||||||
|
if ($mapEntry.expand) { $values = @($values | ForEach-Object { Expand-DataPath "$_" }) }
|
||||||
|
|
||||||
$propEl = Find-PropertyElement $propertyName
|
$propEl = Find-PropertyElement $propertyName
|
||||||
if (-not $propEl) {
|
if (-not $propEl) {
|
||||||
@@ -2342,6 +2894,8 @@ function Add-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Remove-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
function Remove-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
||||||
|
$mapEntry = $script:complexPropertyMap[$propertyName]
|
||||||
|
if ($mapEntry -and $mapEntry.expand) { $values = @($values | ForEach-Object { Expand-DataPath "$_" }) }
|
||||||
$propEl = Find-PropertyElement $propertyName
|
$propEl = Find-PropertyElement $propertyName
|
||||||
if (-not $propEl) {
|
if (-not $propEl) {
|
||||||
Warn "Property element '$propertyName' not found in Properties"
|
Warn "Property element '$propertyName' not found in Properties"
|
||||||
@@ -2379,6 +2933,7 @@ function Remove-ComplexPropertyItem([string]$propertyName, [string[]]$values) {
|
|||||||
function Set-ComplexProperty([string]$propertyName, [string[]]$values) {
|
function Set-ComplexProperty([string]$propertyName, [string[]]$values) {
|
||||||
$mapEntry = $script:complexPropertyMap[$propertyName]
|
$mapEntry = $script:complexPropertyMap[$propertyName]
|
||||||
if (-not $mapEntry) { Warn "Unknown complex property: $propertyName"; return }
|
if (-not $mapEntry) { Warn "Unknown complex property: $propertyName"; return }
|
||||||
|
if ($mapEntry.expand) { $values = @($values | ForEach-Object { Expand-DataPath "$_" }) }
|
||||||
|
|
||||||
$propEl = Find-PropertyElement $propertyName
|
$propEl = Find-PropertyElement $propertyName
|
||||||
if (-not $propEl) {
|
if (-not $propEl) {
|
||||||
@@ -2428,6 +2983,95 @@ function Set-ComplexProperty([string]$propertyName, [string[]]$values) {
|
|||||||
# Section 13: Main processing
|
# Section 13: Main processing
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Predefined data (Ext/Predefined.xml) — add предопределённых (Catalog/ChartOfCharacteristicTypes).
|
||||||
|
# Существующие <Item id=GUID> сохраняются побайтово (текстовый append), новые получают свежий GUID —
|
||||||
|
# инвариант «не менять id существующей сущности».
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
$script:predefXsiTypeByObj = @{
|
||||||
|
'Catalog' = 'CatalogPredefinedItems'
|
||||||
|
'ChartOfCharacteristicTypes' = 'PlanOfCharacteristicKindPredefinedItems'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PredefinedPath {
|
||||||
|
$objDir = Join-Path (Split-Path $resolvedPath) $script:objName
|
||||||
|
return (Join-Path (Join-Path $objDir "Ext") "Predefined.xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ObjectCodeType {
|
||||||
|
foreach ($ch in $script:propertiesEl.ChildNodes) {
|
||||||
|
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'CodeType') { return $ch.InnerText.Trim() }
|
||||||
|
}
|
||||||
|
return 'String'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Элемент DSL: строка "(Код) Имя [Наименование]" ЛИБО объект {name,code,description,isFolder,childItems}.
|
||||||
|
function Resolve-PredefItem($val) {
|
||||||
|
if ($val -is [string]) {
|
||||||
|
$s = "$val"; $descRaw = $null; $hasDesc = $false
|
||||||
|
if ($s -match '\[(.*)\]') { $descRaw = $Matches[1]; $hasDesc = $true; $s = $s -replace '\s*\[.*\]', '' }
|
||||||
|
$m = [regex]::Match($s.Trim(), '^\s*(?:\(([^)]*)\)\s*)?(\S+)\s*$')
|
||||||
|
$name = $m.Groups[2].Value
|
||||||
|
$code = if ($m.Groups[1].Success) { $m.Groups[1].Value } else { '' }
|
||||||
|
$desc = if ($hasDesc) { $descRaw } else { Split-CamelCase $name }
|
||||||
|
return @{ name = $name; code = $code; desc = $desc; isFolder = $false; children = @() }
|
||||||
|
}
|
||||||
|
$gv = { param($o, [string[]]$keys) foreach ($k in $keys) { if ($o.PSObject.Properties[$k]) { return $o.$k } } return $null }
|
||||||
|
$name = "$(& $gv $val @('name','имя'))"
|
||||||
|
$codeV = & $gv $val @('code','код'); $code = if ($null -ne $codeV) { "$codeV" } else { '' }
|
||||||
|
$hasDesc = $val.PSObject.Properties['description'] -or $val.PSObject.Properties['наименование']
|
||||||
|
$descV = & $gv $val @('description','наименование')
|
||||||
|
$desc = if ($hasDesc) { "$descV" } else { Split-CamelCase $name }
|
||||||
|
$isFolder = ((& $gv $val @('isFolder','группа')) -eq $true)
|
||||||
|
$subs = & $gv $val @('childItems','подчиненные')
|
||||||
|
return @{ name = $name; code = $code; desc = $desc; isFolder = $isFolder; children = @(if ($subs) { @($subs) } else { @() }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-PredefItemXml([string]$indent, $val, [string]$codeType) {
|
||||||
|
$r = Resolve-PredefItem $val
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
[void]$sb.Append("$indent<Item id=`"$(New-Guid-String)`">`r`n")
|
||||||
|
[void]$sb.Append("$indent`t<Name>$(Esc-XmlText $r.name)</Name>`r`n")
|
||||||
|
if (-not $r.code) { [void]$sb.Append("$indent`t<Code/>`r`n") }
|
||||||
|
elseif ($codeType -eq 'Number') { [void]$sb.Append("$indent`t<Code xsi:type=`"xs:decimal`">$(Esc-XmlText $r.code)</Code>`r`n") }
|
||||||
|
else { [void]$sb.Append("$indent`t<Code>$(Esc-XmlText $r.code)</Code>`r`n") }
|
||||||
|
if ($r.desc -eq '') { [void]$sb.Append("$indent`t<Description/>`r`n") }
|
||||||
|
else { [void]$sb.Append("$indent`t<Description>$(Esc-XmlText $r.desc)</Description>`r`n") }
|
||||||
|
[void]$sb.Append("$indent`t<IsFolder>$(if ($r.isFolder) { 'true' } else { 'false' })</IsFolder>`r`n")
|
||||||
|
if ($r.children.Count -gt 0) {
|
||||||
|
[void]$sb.Append("$indent`t<ChildItems>`r`n")
|
||||||
|
foreach ($c in $r.children) { [void]$sb.Append((Build-PredefItemXml "$indent`t`t" $c $codeType)) }
|
||||||
|
[void]$sb.Append("$indent`t</ChildItems>`r`n")
|
||||||
|
}
|
||||||
|
[void]$sb.Append("$indent</Item>`r`n")
|
||||||
|
return $sb.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-PredefinedItems($items) {
|
||||||
|
$xsiType = $script:predefXsiTypeByObj[$script:objType]
|
||||||
|
if (-not $xsiType) { Write-Error "add-predefined: тип объекта '$($script:objType)' не поддержан (только Catalog, ChartOfCharacteristicTypes)"; exit 1 }
|
||||||
|
$codeType = Get-ObjectCodeType
|
||||||
|
$version = $script:xmlDoc.DocumentElement.GetAttribute("version")
|
||||||
|
$path = Get-PredefinedPath
|
||||||
|
$itemsXml = ""
|
||||||
|
foreach ($it in @($items)) { $itemsXml += (Build-PredefItemXml "`t" $it $codeType) }
|
||||||
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
if (Test-Path $path) {
|
||||||
|
$text = [System.IO.File]::ReadAllText($path, $utf8Bom)
|
||||||
|
$text = $text.Replace("</PredefinedData>", "$itemsXml</PredefinedData>")
|
||||||
|
} else {
|
||||||
|
$extDir = Split-Path $path
|
||||||
|
if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
|
||||||
|
$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)
|
||||||
|
$n = @($items).Count
|
||||||
|
Info "Added $n predefined item(s) → $path"
|
||||||
|
$script:addCount += $n
|
||||||
|
}
|
||||||
|
|
||||||
# --- Inline mode conversion ---
|
# --- Inline mode conversion ---
|
||||||
if ($Operation) {
|
if ($Operation) {
|
||||||
$def = Convert-InlineToDefinition $Operation $Value
|
$def = Convert-InlineToDefinition $Operation $Value
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# meta-edit v1.9 — Edit existing 1C metadata object XML (inline mode + complex properties + TS attribute ops + modify-ts)
|
# meta-edit v1.20 — Edit existing 1C metadata object XML (+add-predefined предопределённые Ext/Predefined.xml)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -32,6 +32,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -71,6 +83,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -78,6 +93,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
@@ -203,6 +220,7 @@ md_ns = ""
|
|||||||
properties_el = None
|
properties_el = None
|
||||||
child_objects_el = None
|
child_objects_el = None
|
||||||
obj_name = ""
|
obj_name = ""
|
||||||
|
resolved_path = ""
|
||||||
|
|
||||||
add_count = 0
|
add_count = 0
|
||||||
remove_count = 0
|
remove_count = 0
|
||||||
@@ -285,7 +303,7 @@ valid_enum_values = {
|
|||||||
'Posting': ['Allow', 'Deny'],
|
'Posting': ['Allow', 'Deny'],
|
||||||
'RealTimePosting': ['Allow', 'Deny'],
|
'RealTimePosting': ['Allow', 'Deny'],
|
||||||
'EditType': ['InDialog', 'InList', 'BothWays'],
|
'EditType': ['InDialog', 'InList', 'BothWays'],
|
||||||
'HierarchyType': ['HierarchyFoldersAndItems', 'HierarchyItemsOnly'],
|
'HierarchyType': ['HierarchyFoldersAndItems', 'HierarchyOfItems'],
|
||||||
'CodeType': ['String', 'Number'],
|
'CodeType': ['String', 'Number'],
|
||||||
'CodeAllowedLength': ['Variable', 'Fixed'],
|
'CodeAllowedLength': ['Variable', 'Fixed'],
|
||||||
'NumberType': ['String', 'Number'],
|
'NumberType': ['String', 'Number'],
|
||||||
@@ -567,6 +585,8 @@ def import_fragment(xml_string):
|
|||||||
f' xmlns:v8="{V8_NS}"'
|
f' xmlns:v8="{V8_NS}"'
|
||||||
f' xmlns:xr="{XR_NS}"'
|
f' xmlns:xr="{XR_NS}"'
|
||||||
f' xmlns:cfg="{CFG_NS}"'
|
f' xmlns:cfg="{CFG_NS}"'
|
||||||
|
f' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
|
||||||
|
f' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
|
||||||
f' xmlns:xs="{XS_NS}">'
|
f' xmlns:xs="{XS_NS}">'
|
||||||
f"{xml_string}</_W>"
|
f"{xml_string}</_W>"
|
||||||
)
|
)
|
||||||
@@ -861,14 +881,43 @@ RESERVED_ATTR_NAMES_RU = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Стандартные реквизиты по типу объекта (EN + RU). Совпадение имени реквизита с ними платформа
|
||||||
|
# не позволит — жёсткий отказ. Контексты вне карты → предупреждение по плоскому списку.
|
||||||
|
RESERVED_BY_CONTEXT = {
|
||||||
|
'catalog': {
|
||||||
|
'ref', 'ссылка',
|
||||||
|
'deletionmark', 'пометкаудаления',
|
||||||
|
'predefined', 'предопределенный',
|
||||||
|
'predefineddataname', 'имяпредопределенныхданных',
|
||||||
|
'code', 'код',
|
||||||
|
'description', 'наименование',
|
||||||
|
'owner', 'владелец',
|
||||||
|
'parent', 'родитель',
|
||||||
|
'isfolder', 'этогруппа',
|
||||||
|
},
|
||||||
|
'document': {
|
||||||
|
'ref', 'ссылка',
|
||||||
|
'deletionmark', 'пометкаудаления',
|
||||||
|
'date', 'дата',
|
||||||
|
'number', 'номер',
|
||||||
|
'posted', 'проведен',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_attribute_fragment(parsed, context, indent):
|
def build_attribute_fragment(parsed, context, indent):
|
||||||
"""Build XML fragment string for an Attribute element."""
|
"""Build XML fragment string for an Attribute element."""
|
||||||
if not context:
|
if not context:
|
||||||
context = get_attribute_context()
|
context = get_attribute_context()
|
||||||
|
|
||||||
# Check reserved attribute names
|
# Check reserved attribute names (типозависимо: catalog/document — отказ; прочее — предупреждение)
|
||||||
attr_name = parsed['name']
|
attr_name = parsed['name']
|
||||||
if attr_name in RESERVED_ATTR_NAMES or attr_name in RESERVED_ATTR_NAMES_RU:
|
ctx_reserved = RESERVED_BY_CONTEXT.get(context)
|
||||||
|
if ctx_reserved is not None:
|
||||||
|
if attr_name.lower() in ctx_reserved:
|
||||||
|
print(f"meta-edit: имя реквизита '{attr_name}' зарезервировано стандартным реквизитом объекта '{context}'. Выберите другое имя.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
elif context not in ('tabular', 'processor-tabular') and (attr_name in RESERVED_ATTR_NAMES or attr_name in RESERVED_ATTR_NAMES_RU):
|
||||||
print(f"WARNING: Attribute '{attr_name}' conflicts with a standard attribute name. This may cause errors when loading into 1C.", file=sys.stderr)
|
print(f"WARNING: Attribute '{attr_name}' conflicts with a standard attribute name. This may cause errors when loading into 1C.", file=sys.stderr)
|
||||||
|
|
||||||
uid = new_uuid()
|
uid = new_uuid()
|
||||||
@@ -1399,6 +1448,8 @@ def convert_inline_to_definition(operation, value):
|
|||||||
"registerRecord": "RegisterRecords", "registerRecords": "RegisterRecords",
|
"registerRecord": "RegisterRecords", "registerRecords": "RegisterRecords",
|
||||||
"basedOn": "BasedOn",
|
"basedOn": "BasedOn",
|
||||||
"inputByString": "InputByString",
|
"inputByString": "InputByString",
|
||||||
|
"dataLockField": "DataLockFields", "dataLockFields": "DataLockFields",
|
||||||
|
"registeredDocument": "RegisteredDocuments", "registeredDocuments": "RegisteredDocuments",
|
||||||
}
|
}
|
||||||
|
|
||||||
if target in complex_target_map:
|
if target in complex_target_map:
|
||||||
@@ -1425,6 +1476,11 @@ def convert_inline_to_definition(operation, value):
|
|||||||
complex_action = "set" if op == "set" else op
|
complex_action = "set" if op == "set" else op
|
||||||
return {"_complex": [{"action": complex_action, "property": prop_name, "values": values}]}
|
return {"_complex": [{"action": complex_action, "property": prop_name, "values": values}]}
|
||||||
|
|
||||||
|
# Предопределённые (Ext/Predefined.xml) — отдельный файл; строим { <op>: { predefined: [...] } }.
|
||||||
|
if target == "predefined":
|
||||||
|
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||||||
|
return {op: {"predefined": items}}
|
||||||
|
|
||||||
# TS attribute operations: dot notation "TSName.AttrDef"
|
# TS attribute operations: dot notation "TSName.AttrDef"
|
||||||
if target == "ts-attribute":
|
if target == "ts-attribute":
|
||||||
items = [v.strip() for v in value.split(";;") if v.strip()]
|
items = [v.strip() for v in value.split(";;") if v.strip()]
|
||||||
@@ -1634,6 +1690,9 @@ def process_add(add_def):
|
|||||||
global add_count
|
global add_count
|
||||||
|
|
||||||
for raw_key, items in add_def.items():
|
for raw_key, items in add_def.items():
|
||||||
|
if raw_key in ('predefined', 'предопределенные', 'предопределённые'):
|
||||||
|
add_predefined_items(items)
|
||||||
|
continue
|
||||||
child_type = resolve_child_type_key(raw_key)
|
child_type = resolve_child_type_key(raw_key)
|
||||||
|
|
||||||
if not child_type:
|
if not child_type:
|
||||||
@@ -1829,8 +1888,17 @@ def modify_properties(props_def):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if prop_el is None:
|
if prop_el is None:
|
||||||
warn(f"Property '{prop_name}' not found in Properties")
|
# create-if-missing: известное свойство создаём (порядок 1С терпит); неизвестное → ошибка (опечатка)
|
||||||
continue
|
if prop_name not in known_object_props:
|
||||||
|
print(f"meta-edit: modify-property: неизвестное свойство '{prop_name}' — нет такого свойства объекта (опечатка?)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
new_nodes = import_fragment(f"<{prop_name}/>")
|
||||||
|
if new_nodes:
|
||||||
|
insert_property_in_order(properties_el, new_nodes[0], None, prop_name)
|
||||||
|
prop_el = new_nodes[0]
|
||||||
|
else:
|
||||||
|
warn(f"Property '{prop_name}': could not create element")
|
||||||
|
continue
|
||||||
|
|
||||||
# Complex property: Owners, RegisterRecords, BasedOn, InputByString
|
# Complex property: Owners, RegisterRecords, BasedOn, InputByString
|
||||||
if prop_name in complex_property_map:
|
if prop_name in complex_property_map:
|
||||||
@@ -2051,6 +2119,47 @@ def modify_child_elements(modify_def, child_type):
|
|||||||
info(f"Changed synonym of {xml_tag} '{elem_name}': {change_value}")
|
info(f"Changed synonym of {xml_tag} '{elem_name}': {change_value}")
|
||||||
modify_count += 1
|
modify_count += 1
|
||||||
|
|
||||||
|
elif change_prop == "Format":
|
||||||
|
if set_attr_property_element(props_el, "Format", build_mltext_xml(get_child_indent(props_el), "Format", str(change_value))):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.Format")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "EditFormat":
|
||||||
|
if set_attr_property_element(props_el, "EditFormat", build_mltext_xml(get_child_indent(props_el), "EditFormat", str(change_value))):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.EditFormat")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "ToolTip":
|
||||||
|
if set_attr_property_element(props_el, "ToolTip", build_mltext_xml(get_child_indent(props_el), "ToolTip", str(change_value))):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.ToolTip")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "ChoiceForm":
|
||||||
|
if set_attr_property_element(props_el, "ChoiceForm", f"<ChoiceForm>{esc_xml(str(change_value))}</ChoiceForm>"):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.ChoiceForm")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "MinValue":
|
||||||
|
if set_attr_property_element(props_el, "MinValue", build_min_max_value_xml("MinValue", change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.MinValue")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "MaxValue":
|
||||||
|
if set_attr_property_element(props_el, "MaxValue", build_min_max_value_xml("MaxValue", change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.MaxValue")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "LinkByType":
|
||||||
|
if set_attr_property_element(props_el, "LinkByType", build_link_by_type_xml(get_child_indent(props_el), change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.LinkByType")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "ChoiceParameterLinks":
|
||||||
|
if set_attr_property_element(props_el, "ChoiceParameterLinks", build_choice_parameter_links_xml(get_child_indent(props_el), change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.ChoiceParameterLinks")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "ChoiceParameters":
|
||||||
|
if set_attr_property_element(props_el, "ChoiceParameters", build_choice_parameters_xml(get_child_indent(props_el), change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.ChoiceParameters")
|
||||||
|
modify_count += 1
|
||||||
|
elif change_prop == "FillValue":
|
||||||
|
if set_attr_property_element(props_el, "FillValue", build_fill_value_explicit_xml(get_attr_type_str_from_xml(props_el), change_value)):
|
||||||
|
info(f"Set {xml_tag} '{elem_name}'.FillValue")
|
||||||
|
modify_count += 1
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Scalar property change (Indexing, FillChecking, Use, etc.)
|
# Scalar property change (Indexing, FillChecking, Use, etc.)
|
||||||
scalar_el = None
|
scalar_el = None
|
||||||
@@ -2071,7 +2180,20 @@ def modify_child_elements(modify_def, child_type):
|
|||||||
info(f"Modified {xml_tag} '{elem_name}'.{change_prop} = {value_str}")
|
info(f"Modified {xml_tag} '{elem_name}'.{change_prop} = {value_str}")
|
||||||
modify_count += 1
|
modify_count += 1
|
||||||
else:
|
else:
|
||||||
warn(f"{xml_tag} '{elem_name}': property '{change_prop}' not found")
|
# create-if-missing: известное свойство создаём в позиции; неизвестное → ошибка (опечатка)
|
||||||
|
if change_prop not in known_child_props:
|
||||||
|
print(f"meta-edit: modify: неизвестное свойство '{change_prop}' у {xml_tag} '{elem_name}' (опечатка?)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
value_str = str(change_value)
|
||||||
|
if isinstance(change_value, bool):
|
||||||
|
value_str = "true" if change_value else "false"
|
||||||
|
else:
|
||||||
|
value_str = normalize_enum_value(change_prop, value_str)
|
||||||
|
new_nodes = import_fragment(f"<{change_prop}>{esc_xml(value_str)}</{change_prop}>")
|
||||||
|
if new_nodes:
|
||||||
|
insert_property_in_order(props_el, new_nodes[0], attr_prop_order, change_prop)
|
||||||
|
info(f"Created {xml_tag} '{elem_name}'.{change_prop} = {value_str}")
|
||||||
|
modify_count += 1
|
||||||
|
|
||||||
|
|
||||||
def process_modify(modify_def):
|
def process_modify(modify_def):
|
||||||
@@ -2097,8 +2219,519 @@ complex_property_map = {
|
|||||||
"RegisterRecords": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
"RegisterRecords": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||||||
"BasedOn": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
"BasedOn": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||||||
"InputByString": {"tag": "xr:Field", "attr": None},
|
"InputByString": {"tag": "xr:Field", "attr": None},
|
||||||
|
"DataLockFields": {"tag": "xr:Field", "attr": None, "expand": True},
|
||||||
|
"RegisteredDocuments": {"tag": "xr:Item", "attr": 'xsi:type="xr:MDObjectRef"'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Известные свойства объекта (union по корпусу acc+erp 8.3.24) — allowlist для modify-property.
|
||||||
|
# Известное отсутствующее свойство create-if-missing создаётся; неизвестное (опечатка) → ошибка.
|
||||||
|
known_object_props = {
|
||||||
|
'ActionPeriod', 'ActionPeriodUse', 'Addressing', 'AutoOrderByCode', 'Autonumbering', 'AuxiliaryChoiceForm',
|
||||||
|
'AuxiliaryFolderChoiceForm', 'AuxiliaryFolderForm', 'AuxiliaryForm', 'AuxiliaryListForm', 'AuxiliaryObjectForm',
|
||||||
|
'AuxiliaryRecordForm', 'AuxiliarySettingsForm', 'BaseCalculationTypes', 'BasePeriod', 'BasedOn',
|
||||||
|
'CharacteristicExtValues', 'Characteristics', 'ChartOfAccounts', 'ChartOfCalculationTypes', 'CheckUnique',
|
||||||
|
'ChoiceDataGetModeOnInputByString', 'ChoiceFoldersAndItems', 'ChoiceForm', 'ChoiceHistoryOnInput', 'ChoiceMode',
|
||||||
|
'ChoiceParameterLinks', 'ChoiceParameters', 'CodeAllowedLength', 'CodeLength', 'CodeMask', 'CodeSeries', 'CodeType',
|
||||||
|
'Comment', 'Correspondence', 'CreateOnInput', 'CreateTaskInPrivilegedMode', 'CurrentPerformer', 'DataHistory',
|
||||||
|
'DataLockControlMode', 'DataLockFields', 'DefaultChoiceForm', 'DefaultFolderChoiceForm', 'DefaultFolderForm',
|
||||||
|
'DefaultForm', 'DefaultListForm', 'DefaultObjectForm', 'DefaultPresentation', 'DefaultRecordForm', 'DefaultSettingsForm',
|
||||||
|
'DefaultVariantForm', 'DependenceOnCalculationTypes', 'DescriptionLength', 'DistributedInfoBase', 'EditFormat',
|
||||||
|
'EditType', 'EnableTotalsSliceFirst', 'EnableTotalsSliceLast', 'EnableTotalsSplitting',
|
||||||
|
'ExecuteAfterWriteDataHistoryVersionProcessing', 'Explanation', 'ExtDimensionTypes', 'ExtendedEdit',
|
||||||
|
'ExtendedListPresentation', 'ExtendedObjectPresentation', 'ExtendedPresentation', 'ExtendedRecordPresentation',
|
||||||
|
'FillChecking', 'FoldersOnTop', 'Format', 'FullTextSearch', 'FullTextSearchOnInputByString', 'Hierarchical',
|
||||||
|
'HierarchyType', 'IncludeConfigurationExtensions', 'IncludeHelpInContents', 'InformationRegisterPeriodicity',
|
||||||
|
'InputByString', 'LevelCount', 'LimitLevelCount', 'LinkByType', 'ListPresentation', 'MainAddressingAttribute',
|
||||||
|
'MainDataCompositionSchema', 'MainFilterOnPeriod', 'MarkNegatives', 'Mask', 'MaxExtDimensionCount', 'MaxValue',
|
||||||
|
'MinValue', 'MultiLine', 'Name', 'NumberAllowedLength', 'NumberLength', 'NumberPeriodicity', 'NumberType', 'Numerator',
|
||||||
|
'ObjectPresentation', 'OrderLength', 'Owners', 'PasswordMode', 'PeriodAdjustmentLength', 'Periodicity',
|
||||||
|
'PostInPrivilegedMode', 'Posting', 'PredefinedDataUpdate', 'QuickChoice', 'RealTimePosting', 'RecordPresentation',
|
||||||
|
'RegisterRecords', 'RegisterRecordsDeletion', 'RegisterRecordsWritingOnPost', 'RegisterType', 'RegisteredDocuments',
|
||||||
|
'Schedule', 'ScheduleDate', 'ScheduleValue', 'SearchStringModeOnInputByString', 'SequenceFilling', 'SettingsStorage',
|
||||||
|
'StandardAttributes', 'StandardTabularSections', 'SubordinationUse', 'Synonym', 'Task', 'TaskNumberAutoPrefix',
|
||||||
|
'ToolTip', 'Type', 'UnpostInPrivilegedMode', 'UpdateDataHistoryImmediatelyAfterWrite', 'UseStandardCommands',
|
||||||
|
'VariantsStorage', 'WriteMode',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Известные свойства дочерних элементов (union Attribute/Dimension/Resource) — allowlist default-ветки modify-child.
|
||||||
|
known_child_props = {
|
||||||
|
'AccountingFlag', 'Balance', 'BaseDimension', 'ChoiceFoldersAndItems', 'ChoiceForm', 'ChoiceHistoryOnInput',
|
||||||
|
'ChoiceParameterLinks', 'ChoiceParameters', 'Comment', 'CreateOnInput', 'DataHistory', 'DenyIncompleteValues',
|
||||||
|
'DocumentMap', 'EditFormat', 'ExtDimensionAccountingFlag', 'ExtendedEdit', 'FillChecking', 'FillFromFillingValue',
|
||||||
|
'FillValue', 'Format', 'FullTextSearch', 'Indexing', 'LinkByType', 'MainFilter', 'MarkNegatives', 'Mask', 'Master',
|
||||||
|
'MaxValue', 'MinValue', 'MultiLine', 'Name', 'PasswordMode', 'QuickChoice', 'RegisterRecordsMap', 'ScheduleLink',
|
||||||
|
'Synonym', 'ToolTip', 'Type', 'Use', 'UseInTotals',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Канонический порядок свойств реквизита (последовательность build_attribute_fragment) — для вставки в позицию.
|
||||||
|
attr_prop_order = [
|
||||||
|
'Name', 'Synonym', 'Comment', 'Type', 'PasswordMode', 'Format', 'EditFormat', 'ToolTip', 'MarkNegatives', 'Mask',
|
||||||
|
'MultiLine', 'ExtendedEdit', 'MinValue', 'MaxValue', 'FillFromFillingValue', 'FillValue', 'FillChecking',
|
||||||
|
'ChoiceFoldersAndItems', 'ChoiceParameterLinks', 'ChoiceParameters', 'QuickChoice', 'CreateOnInput', 'ChoiceForm',
|
||||||
|
'LinkByType', 'ChoiceHistoryOnInput', 'Use', 'Indexing', 'FullTextSearch', 'DataHistory',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def insert_property_in_order(props_el, new_node, order_array, prop_name):
|
||||||
|
"""Вставить новое свойство в Properties в канонической позиции (по order_array); иначе append."""
|
||||||
|
child_indent = get_child_indent(props_el)
|
||||||
|
ref_node = None
|
||||||
|
idx = order_array.index(prop_name) if (order_array and prop_name in order_array) else -1
|
||||||
|
if idx >= 0:
|
||||||
|
for ch in props_el:
|
||||||
|
cl = localname(ch)
|
||||||
|
if cl in order_array and order_array.index(cl) > idx:
|
||||||
|
ref_node = ch
|
||||||
|
break
|
||||||
|
insert_before_element(props_el, new_node, ref_node, child_indent)
|
||||||
|
|
||||||
|
|
||||||
|
def set_attr_property_element(props_el, prop_name, fragment_xml):
|
||||||
|
"""Заменить существующий элемент свойства реквизита новым фрагментом, либо создать в позиции."""
|
||||||
|
new_nodes = import_fragment(fragment_xml)
|
||||||
|
if not new_nodes:
|
||||||
|
return False
|
||||||
|
existing = None
|
||||||
|
for ch in props_el:
|
||||||
|
if localname(ch) == prop_name:
|
||||||
|
existing = ch
|
||||||
|
break
|
||||||
|
if existing is not None:
|
||||||
|
idx = list(props_el).index(existing)
|
||||||
|
new_nodes[0].tail = existing.tail
|
||||||
|
props_el.insert(idx + 1, new_nodes[0])
|
||||||
|
remove_node_with_whitespace(existing)
|
||||||
|
else:
|
||||||
|
insert_property_in_order(props_el, new_nodes[0], attr_prop_order, prop_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_min_max_value_xml(tag, val):
|
||||||
|
"""MinValue/MaxValue — типизированное значение (порт Emit-MinMaxValue): nil / xs:string / xs:decimal."""
|
||||||
|
if val is None or str(val) == '':
|
||||||
|
return f'<{tag} xsi:nil="true"/>'
|
||||||
|
t = 'xs:string' if isinstance(val, str) else 'xs:decimal'
|
||||||
|
return f'<{tag} xsi:type="{t}">{esc_xml(str(val))}</{tag}>'
|
||||||
|
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: развёртка путей данных + связи выбора / тип по ссылке (structural modify) ---
|
||||||
|
|
||||||
|
def get_ch_el_prop(obj, names):
|
||||||
|
"""Свойство из dict по списку синонимов (первый найденный, иначе None)."""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for n in names:
|
||||||
|
if n in obj:
|
||||||
|
return obj[n]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Пары EN↔RU стандартных реквизитов Catalog/Document (для resolve_std_attr_en; py-карты плоские, восстанавливаем).
|
||||||
|
_STD_ATTR_BY_CTX = {
|
||||||
|
'Catalog': [('Ref', 'Ссылка'), ('DeletionMark', 'ПометкаУдаления'), ('Predefined', 'Предопределенный'),
|
||||||
|
('PredefinedDataName', 'ИмяПредопределенныхДанных'), ('Code', 'Код'), ('Description', 'Наименование'),
|
||||||
|
('Owner', 'Владелец'), ('Parent', 'Родитель'), ('IsFolder', 'ЭтоГруппа')],
|
||||||
|
'Document': [('Ref', 'Ссылка'), ('DeletionMark', 'ПометкаУдаления'), ('Date', 'Дата'),
|
||||||
|
('Number', 'Номер'), ('Posted', 'Проведен')],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_std_attr_en(name):
|
||||||
|
"""Стандартный реквизит рус/англ → английский (Catalog/Document)."""
|
||||||
|
pairs = _STD_ATTR_BY_CTX.get(obj_type)
|
||||||
|
if not pairs:
|
||||||
|
return None
|
||||||
|
nl = str(name).lower()
|
||||||
|
for en, ru in pairs:
|
||||||
|
if nl == en.lower() or nl == ru.lower():
|
||||||
|
return en
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def expand_data_path(dp):
|
||||||
|
"""Прощающий ввод пути данных: короткое имя реквизита → полный путь объекта (порт Expand-DataPath)."""
|
||||||
|
if not dp:
|
||||||
|
return dp
|
||||||
|
s = str(dp)
|
||||||
|
if re.search(r'[:/]', s):
|
||||||
|
return s
|
||||||
|
if re.match(r'^-?\d+$', s):
|
||||||
|
return s
|
||||||
|
if re.match(r'^(StandardAttribute|Attribute)\.', s):
|
||||||
|
return f"{obj_type}.{obj_name}.{s}"
|
||||||
|
if '.' not in s:
|
||||||
|
en = resolve_std_attr_en(s)
|
||||||
|
if en:
|
||||||
|
return f"{obj_type}.{obj_name}.StandardAttribute.{en}"
|
||||||
|
return f"{obj_type}.{obj_name}.Attribute.{s}"
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def convert_from_ch_link_shorthand(s):
|
||||||
|
"""Shorthand "name=path" | "name=path:Clear|DontChange" → {name, dataPath, valueChange?}."""
|
||||||
|
eq = s.find('=')
|
||||||
|
if eq < 0:
|
||||||
|
return {'name': s.strip()}
|
||||||
|
o = {'name': s[:eq].strip()}
|
||||||
|
rest = s[eq + 1:].strip()
|
||||||
|
m = re.match(r'^(.*):(Clear|DontChange|очистить|неизменять)$', rest, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
o['dataPath'] = m.group(1).strip()
|
||||||
|
o['valueChange'] = m.group(2)
|
||||||
|
else:
|
||||||
|
o['dataPath'] = rest
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def build_link_by_type_xml(indent, spec):
|
||||||
|
"""LinkByType — {dataPath, linkItem?} (порт Emit-LinkByType)."""
|
||||||
|
if not spec:
|
||||||
|
return f"{indent}<LinkByType/>"
|
||||||
|
if isinstance(spec, str):
|
||||||
|
dp = spec
|
||||||
|
li = 0
|
||||||
|
else:
|
||||||
|
dp = str(get_ch_el_prop(spec, ['dataPath', 'path', 'путь']) or '')
|
||||||
|
li_raw = get_ch_el_prop(spec, ['linkItem', 'элементСвязи'])
|
||||||
|
li = li_raw if li_raw is not None else 0
|
||||||
|
if not dp:
|
||||||
|
return f"{indent}<LinkByType/>"
|
||||||
|
dp = expand_data_path(dp)
|
||||||
|
return "\r\n".join([
|
||||||
|
f"{indent}<LinkByType>",
|
||||||
|
f"{indent}\t<xr:DataPath>{esc_xml(str(dp))}</xr:DataPath>",
|
||||||
|
f"{indent}\t<xr:LinkItem>{li}</xr:LinkItem>",
|
||||||
|
f"{indent}</LinkByType>",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def build_choice_parameter_links_xml(indent, cpl):
|
||||||
|
"""ChoiceParameterLinks — [{name, dataPath, valueChange?}] (порт Emit-ChoiceParameterLinks)."""
|
||||||
|
items = cpl if isinstance(cpl, list) else ([cpl] if cpl else [])
|
||||||
|
if not items:
|
||||||
|
return f"{indent}<ChoiceParameterLinks/>"
|
||||||
|
parts = [f"{indent}<ChoiceParameterLinks>"]
|
||||||
|
for lk in items:
|
||||||
|
if isinstance(lk, str):
|
||||||
|
lk = convert_from_ch_link_shorthand(lk)
|
||||||
|
name = get_ch_el_prop(lk, ['name', 'имя'])
|
||||||
|
dp = expand_data_path(get_ch_el_prop(lk, ['dataPath', 'path', 'путь']))
|
||||||
|
vc_raw = get_ch_el_prop(lk, ['valueChange', 'режимИзменения'])
|
||||||
|
vc = 'Clear'
|
||||||
|
if vc_raw:
|
||||||
|
low = str(vc_raw).lower()
|
||||||
|
if re.match(r'^(clear|очистить|очистка)$', low):
|
||||||
|
vc = 'Clear'
|
||||||
|
elif re.match(r'^(dontchange|неизменять|неменять|нет)$', low):
|
||||||
|
vc = 'DontChange'
|
||||||
|
else:
|
||||||
|
vc = str(vc_raw)
|
||||||
|
parts.append(f"{indent}\t<xr:Link>")
|
||||||
|
parts.append(f"{indent}\t\t<xr:Name>{esc_xml(str(name) if name is not None else '')}</xr:Name>")
|
||||||
|
parts.append(f'{indent}\t\t<xr:DataPath xsi:type="xs:string">{esc_xml(str(dp) if dp is not None else "")}</xr:DataPath>')
|
||||||
|
parts.append(f"{indent}\t\t<xr:ValueChange>{vc}</xr:ValueChange>")
|
||||||
|
parts.append(f"{indent}\t</xr:Link>")
|
||||||
|
parts.append(f"{indent}</ChoiceParameterLinks>")
|
||||||
|
return "\r\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: значения параметров выбора (ChoiceParameters) ---
|
||||||
|
|
||||||
|
fill_ref_roots = {
|
||||||
|
'перечисление': 'Enum', 'справочник': 'Catalog', 'документ': 'Document',
|
||||||
|
'плансчетов': 'ChartOfAccounts', 'планвидовхарактеристик': 'ChartOfCharacteristicTypes',
|
||||||
|
'планвидоврасчета': 'ChartOfCalculationTypes', 'планвидоврасчёта': 'ChartOfCalculationTypes',
|
||||||
|
'планобмена': 'ExchangePlan', 'бизнеспроцесс': 'BusinessProcess', 'задача': 'Task',
|
||||||
|
'enum': 'Enum', 'catalog': 'Catalog', 'document': 'Document', 'chartofaccounts': 'ChartOfAccounts',
|
||||||
|
'chartofcharacteristictypes': 'ChartOfCharacteristicTypes', 'chartofcalculationtypes': 'ChartOfCalculationTypes',
|
||||||
|
'exchangeplan': 'ExchangePlan', 'businessprocess': 'BusinessProcess', 'task': 'Task',
|
||||||
|
}
|
||||||
|
fill_empty_ref_words = {'emptyref', 'пустаяссылка'}
|
||||||
|
fill_enum_val_words = {'enumvalue', 'значениеперечисления'}
|
||||||
|
account_type_values = ('Active', 'Passive', 'ActivePassive')
|
||||||
|
fill_ref_kind_root = {
|
||||||
|
'catalogref': 'Catalog', 'documentref': 'Document', 'enumref': 'Enum',
|
||||||
|
'chartofaccountsref': 'ChartOfAccounts', 'chartofcharacteristictypesref': 'ChartOfCharacteristicTypes',
|
||||||
|
'chartofcalculationtypesref': 'ChartOfCalculationTypes', 'exchangeplanref': 'ExchangePlan',
|
||||||
|
'businessprocessref': 'BusinessProcess', 'taskref': 'Task',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_ch_scalar(s):
|
||||||
|
t = str(s).strip()
|
||||||
|
if re.match(r'^(true|истина)$', t, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
if re.match(r'^(false|ложь)$', t, re.IGNORECASE):
|
||||||
|
return False
|
||||||
|
if re.match(r'^-?\d+$', t):
|
||||||
|
return int(t)
|
||||||
|
if re.match(r'^-?\d+\.\d+$', t):
|
||||||
|
return float(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def format_fill_num(n):
|
||||||
|
if isinstance(n, float):
|
||||||
|
return str(int(n)) if n == int(n) else repr(n)
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_fill_ref(s):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if re.match(r'^[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}\.[0-9a-fA-F-]+$', s):
|
||||||
|
return s
|
||||||
|
parts = s.split('.')
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
root = fill_ref_roots.get(parts[0].lower())
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
type_name = parts[1]
|
||||||
|
if root == 'Enum':
|
||||||
|
if len(parts) == 2:
|
||||||
|
return None
|
||||||
|
if len(parts) == 3:
|
||||||
|
if parts[2].lower() in fill_empty_ref_words:
|
||||||
|
return f"Enum.{type_name}.EmptyRef"
|
||||||
|
return f"Enum.{type_name}.EnumValue.{parts[2]}"
|
||||||
|
member = parts[2]
|
||||||
|
rest = '.'.join(parts[3:]) if member.lower() in fill_enum_val_words else '.'.join(parts[2:])
|
||||||
|
return f"Enum.{type_name}.EnumValue.{rest}"
|
||||||
|
tail = list(parts[1:])
|
||||||
|
for i in range(len(tail)):
|
||||||
|
if tail[i].lower() in fill_empty_ref_words:
|
||||||
|
tail[i] = 'EmptyRef'
|
||||||
|
return f"{root}." + '.'.join(tail)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_choice_ref_value(value, type_str):
|
||||||
|
if not type_str:
|
||||||
|
return None
|
||||||
|
t = resolve_type_str(type_str)
|
||||||
|
root = None
|
||||||
|
tn = None
|
||||||
|
m = re.match(r'^(\w+Ref)\.(.+)$', t)
|
||||||
|
if m:
|
||||||
|
root = fill_ref_kind_root.get(m.group(1).lower())
|
||||||
|
tn = m.group(2)
|
||||||
|
else:
|
||||||
|
m2 = re.match(r'^([^.]+)\.(.+)$', t)
|
||||||
|
if m2:
|
||||||
|
root = fill_ref_roots.get(m2.group(1).lower())
|
||||||
|
tn = m2.group(2)
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
if str(value).lower() in fill_empty_ref_words:
|
||||||
|
return f"{root}.{tn}.EmptyRef"
|
||||||
|
if root == 'Enum':
|
||||||
|
return f"Enum.{tn}.EnumValue.{value}"
|
||||||
|
return f"{root}.{tn}.{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_choice_value(value):
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return {'XsiType': 'xs:boolean', 'Text': 'true' if value else 'false'}
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return {'XsiType': 'xs:decimal', 'Text': format_fill_num(value)}
|
||||||
|
s = str(value)
|
||||||
|
if s == '':
|
||||||
|
return {'XsiType': 'xs:string', 'Text': ''}
|
||||||
|
if re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$', s):
|
||||||
|
return {'XsiType': 'xs:dateTime', 'Text': s}
|
||||||
|
ref = normalize_fill_ref(s)
|
||||||
|
if ref:
|
||||||
|
return {'XsiType': 'xr:DesignTimeRef', 'Text': ref}
|
||||||
|
if s in account_type_values:
|
||||||
|
return {'XsiType': 'ent:AccountType', 'Text': s}
|
||||||
|
return {'XsiType': 'xs:string', 'Text': s}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_choice_value_t(value, type_str):
|
||||||
|
if type_str and isinstance(value, str) and '.' not in str(value):
|
||||||
|
ex = expand_choice_ref_value(value, type_str)
|
||||||
|
if ex:
|
||||||
|
return {'XsiType': 'xr:DesignTimeRef', 'Text': ex}
|
||||||
|
return normalize_choice_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_from_ch_param_shorthand(s):
|
||||||
|
eq = s.find('=')
|
||||||
|
if eq < 0:
|
||||||
|
return {'name': s.strip()}
|
||||||
|
name = s[:eq].strip()
|
||||||
|
rest = s[eq + 1:]
|
||||||
|
if ',' in rest:
|
||||||
|
return {'name': name, 'value': [convert_to_ch_scalar(p) for p in rest.split(',')]}
|
||||||
|
return {'name': name, 'value': convert_to_ch_scalar(rest)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_choice_parameters_xml(indent, cp):
|
||||||
|
"""ChoiceParameters — [{name, type?, value?}] (порт Emit-ChoiceParameters). Требует xmlns:app в import_fragment."""
|
||||||
|
items = cp if isinstance(cp, list) else ([cp] if cp else [])
|
||||||
|
if not items:
|
||||||
|
return f"{indent}<ChoiceParameters/>"
|
||||||
|
parts = [f"{indent}<ChoiceParameters>"]
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, str):
|
||||||
|
item = convert_from_ch_param_shorthand(item)
|
||||||
|
name = get_ch_el_prop(item, ['name', 'имя'])
|
||||||
|
ptype = get_ch_el_prop(item, ['type', 'тип'])
|
||||||
|
has_val = False
|
||||||
|
val = None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
if 'value' in item:
|
||||||
|
has_val = True
|
||||||
|
val = item['value']
|
||||||
|
elif 'значение' in item:
|
||||||
|
has_val = True
|
||||||
|
val = item['значение']
|
||||||
|
val_is_array = isinstance(val, list)
|
||||||
|
parts.append(f'{indent}\t<app:item name="{esc_xml(str(name) if name is not None else "")}">')
|
||||||
|
if not has_val:
|
||||||
|
parts.append(f'{indent}\t\t<app:value xsi:nil="true"/>')
|
||||||
|
elif val_is_array:
|
||||||
|
parts.append(f'{indent}\t\t<app:value xsi:type="v8:FixedArray">')
|
||||||
|
for v in val:
|
||||||
|
norm = normalize_choice_value_t(v, ptype)
|
||||||
|
if not norm['Text']:
|
||||||
|
parts.append(f'{indent}\t\t\t<v8:Value xsi:type="{norm["XsiType"]}"/>')
|
||||||
|
else:
|
||||||
|
parts.append(f'{indent}\t\t\t<v8:Value xsi:type="{norm["XsiType"]}">{esc_xml(norm["Text"])}</v8:Value>')
|
||||||
|
parts.append(f'{indent}\t\t</app:value>')
|
||||||
|
else:
|
||||||
|
norm = normalize_choice_value_t(val, ptype)
|
||||||
|
if not norm['Text']:
|
||||||
|
parts.append(f'{indent}\t\t<app:value xsi:type="{norm["XsiType"]}"/>')
|
||||||
|
else:
|
||||||
|
parts.append(f'{indent}\t\t<app:value xsi:type="{norm["XsiType"]}">{esc_xml(norm["Text"])}</app:value>')
|
||||||
|
parts.append(f'{indent}\t</app:item>')
|
||||||
|
parts.append(f"{indent}</ChoiceParameters>")
|
||||||
|
return "\r\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Порт из meta-compile: явное значение заполнения (FillValue) ---
|
||||||
|
|
||||||
|
fill_bool_true = {'true', 'истина', 'да'}
|
||||||
|
fill_bool_false = {'false', 'ложь', 'нет'}
|
||||||
|
|
||||||
|
|
||||||
|
def esc_xml_text(s):
|
||||||
|
return s.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
|
||||||
|
|
||||||
|
def get_fill_type_category(type_str):
|
||||||
|
if not type_str:
|
||||||
|
return 'String'
|
||||||
|
if '+' in type_str:
|
||||||
|
return 'Other'
|
||||||
|
t = resolve_type_str(type_str)
|
||||||
|
if re.match(r'^Boolean$', t):
|
||||||
|
return 'Boolean'
|
||||||
|
if re.match(r'^String(\(|$)', t):
|
||||||
|
return 'String'
|
||||||
|
if re.match(r'^Number(\(|$)', t):
|
||||||
|
return 'Number'
|
||||||
|
if re.match(r'^(Date|DateTime)$', t):
|
||||||
|
return 'Date'
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
def expand_fill_short_ref(s, type_str):
|
||||||
|
if not type_str or '+' in type_str:
|
||||||
|
return None
|
||||||
|
t = resolve_type_str(type_str)
|
||||||
|
m = re.match(r'^(\w+Ref)\.(.+)$', t)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
root = fill_ref_kind_root.get(m.group(1).lower())
|
||||||
|
if not root:
|
||||||
|
return None
|
||||||
|
type_name = m.group(2)
|
||||||
|
if s.lower() in fill_empty_ref_words:
|
||||||
|
return f"{root}.{type_name}.EmptyRef"
|
||||||
|
if root == 'Enum':
|
||||||
|
return f"Enum.{type_name}.EnumValue.{s}"
|
||||||
|
return f"{root}.{type_name}.{s}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_fill_value_spec(s, type_str):
|
||||||
|
cat = get_fill_type_category(type_str)
|
||||||
|
if s == '':
|
||||||
|
return {'XsiType': 'xs:string', 'Text': ''}
|
||||||
|
if cat == 'String':
|
||||||
|
return {'XsiType': 'xs:string', 'Text': s}
|
||||||
|
if cat == 'Boolean' or s.lower() in fill_bool_true or s.lower() in fill_bool_false:
|
||||||
|
if s.lower() in fill_bool_true:
|
||||||
|
return {'XsiType': 'xs:boolean', 'Text': 'true'}
|
||||||
|
if s.lower() in fill_bool_false:
|
||||||
|
return {'XsiType': 'xs:boolean', 'Text': 'false'}
|
||||||
|
if cat == 'Number':
|
||||||
|
return {'XsiType': 'xs:decimal', 'Text': s}
|
||||||
|
if cat == 'Date' or re.match(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?$', s):
|
||||||
|
if re.match(r'^\d{4}-\d{2}-\d{2}$', s):
|
||||||
|
s = s + 'T00:00:00'
|
||||||
|
return {'XsiType': 'xs:dateTime', 'Text': s}
|
||||||
|
ref = normalize_fill_ref(s)
|
||||||
|
if ref:
|
||||||
|
return {'XsiType': 'xr:DesignTimeRef', 'Text': ref}
|
||||||
|
short = expand_fill_short_ref(s, type_str)
|
||||||
|
if short:
|
||||||
|
return {'XsiType': 'xr:DesignTimeRef', 'Text': short}
|
||||||
|
return {'XsiType': 'xs:string', 'Text': s}
|
||||||
|
|
||||||
|
|
||||||
|
def get_attr_type_str_from_xml(props_el):
|
||||||
|
"""Извлечь тип реквизита из XML (<Type>/<v8:Type>) → DSL-typeStr для категоризации FillValue."""
|
||||||
|
type_el = None
|
||||||
|
for ch in props_el:
|
||||||
|
if localname(ch) == 'Type':
|
||||||
|
type_el = ch
|
||||||
|
break
|
||||||
|
if type_el is None:
|
||||||
|
return ""
|
||||||
|
mapped = []
|
||||||
|
for ch in type_el:
|
||||||
|
if localname(ch) == 'Type':
|
||||||
|
t = (ch.text or '').strip()
|
||||||
|
colon = t.find(':')
|
||||||
|
if colon >= 0:
|
||||||
|
t = t[colon + 1:]
|
||||||
|
if t == 'string':
|
||||||
|
mapped.append('String')
|
||||||
|
elif t == 'decimal':
|
||||||
|
mapped.append('Number')
|
||||||
|
elif t == 'boolean':
|
||||||
|
mapped.append('Boolean')
|
||||||
|
elif t == 'dateTime':
|
||||||
|
mapped.append('Date')
|
||||||
|
else:
|
||||||
|
mapped.append(t)
|
||||||
|
if not mapped:
|
||||||
|
return ""
|
||||||
|
if len(mapped) > 1:
|
||||||
|
return ' + '.join(mapped)
|
||||||
|
return mapped[0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_fill_value_explicit_xml(type_str, spec):
|
||||||
|
"""FillValue — явное значение (порт Emit-FillValue, ветка hasSpec)."""
|
||||||
|
if spec is None:
|
||||||
|
return '<FillValue xsi:nil="true"/>'
|
||||||
|
if isinstance(spec, bool):
|
||||||
|
return f'<FillValue xsi:type="xs:boolean">{"true" if spec else "false"}</FillValue>'
|
||||||
|
if isinstance(spec, (int, float)):
|
||||||
|
return f'<FillValue xsi:type="xs:decimal">{format_fill_num(spec)}</FillValue>'
|
||||||
|
if get_ch_el_prop(spec, ['nil']) is True:
|
||||||
|
return '<FillValue xsi:nil="true"/>'
|
||||||
|
if get_ch_el_prop(spec, ['emptyRef', 'пустаяссылка']) is True:
|
||||||
|
return '<FillValue xsi:type="xr:DesignTimeRef"/>'
|
||||||
|
r = resolve_fill_value_spec(str(spec), type_str)
|
||||||
|
if r['Text'] == '' and r['XsiType'] == 'xs:string':
|
||||||
|
return '<FillValue xsi:type="xs:string"/>'
|
||||||
|
return f'<FillValue xsi:type="{r["XsiType"]}">{esc_xml_text(r["Text"])}</FillValue>'
|
||||||
|
|
||||||
|
|
||||||
def find_property_element(prop_name):
|
def find_property_element(prop_name):
|
||||||
for child in properties_el:
|
for child in properties_el:
|
||||||
@@ -2121,6 +2754,8 @@ def add_complex_property_item(property_name, values):
|
|||||||
if not map_entry:
|
if not map_entry:
|
||||||
warn(f"Unknown complex property: {property_name}")
|
warn(f"Unknown complex property: {property_name}")
|
||||||
return
|
return
|
||||||
|
if map_entry.get("expand"):
|
||||||
|
values = [expand_data_path(str(v)) for v in values]
|
||||||
|
|
||||||
prop_el = find_property_element(property_name)
|
prop_el = find_property_element(property_name)
|
||||||
if prop_el is None:
|
if prop_el is None:
|
||||||
@@ -2160,6 +2795,9 @@ def add_complex_property_item(property_name, values):
|
|||||||
def remove_complex_property_item(property_name, values):
|
def remove_complex_property_item(property_name, values):
|
||||||
global remove_count
|
global remove_count
|
||||||
|
|
||||||
|
map_entry = complex_property_map.get(property_name)
|
||||||
|
if map_entry and map_entry.get("expand"):
|
||||||
|
values = [expand_data_path(str(v)) for v in values]
|
||||||
prop_el = find_property_element(property_name)
|
prop_el = find_property_element(property_name)
|
||||||
if prop_el is None:
|
if prop_el is None:
|
||||||
warn(f"Property element '{property_name}' not found in Properties")
|
warn(f"Property element '{property_name}' not found in Properties")
|
||||||
@@ -2190,6 +2828,8 @@ def set_complex_property(property_name, values):
|
|||||||
if not map_entry:
|
if not map_entry:
|
||||||
warn(f"Unknown complex property: {property_name}")
|
warn(f"Unknown complex property: {property_name}")
|
||||||
return
|
return
|
||||||
|
if map_entry.get("expand"):
|
||||||
|
values = [expand_data_path(str(v)) for v in values]
|
||||||
|
|
||||||
prop_el = find_property_element(property_name)
|
prop_el = find_property_element(property_name)
|
||||||
if prop_el is None:
|
if prop_el is None:
|
||||||
@@ -2258,25 +2898,136 @@ def save_xml(tree, path):
|
|||||||
# Main
|
# Main
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Predefined data (Ext/Predefined.xml) — add предопределённых (Catalog/ChartOfCharacteristicTypes).
|
||||||
|
# Существующие <Item id=GUID> сохраняются побайтово (текстовый append), новые получают свежий GUID.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
predef_xsi_type_by_obj = {
|
||||||
|
'Catalog': 'CatalogPredefinedItems',
|
||||||
|
'ChartOfCharacteristicTypes': 'PlanOfCharacteristicKindPredefinedItems',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_predefined_path():
|
||||||
|
return os.path.join(os.path.dirname(resolved_path), obj_name, "Ext", "Predefined.xml")
|
||||||
|
|
||||||
|
|
||||||
|
def get_object_code_type():
|
||||||
|
for ch in properties_el:
|
||||||
|
if localname(ch) == 'CodeType':
|
||||||
|
return (ch.text or '').strip()
|
||||||
|
return 'String'
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_predef_item(val):
|
||||||
|
if isinstance(val, str):
|
||||||
|
s = val
|
||||||
|
desc_raw = None
|
||||||
|
has_desc = False
|
||||||
|
m = re.search(r'\[(.*)\]', s)
|
||||||
|
if m:
|
||||||
|
desc_raw = m.group(1)
|
||||||
|
has_desc = True
|
||||||
|
s = re.sub(r'\s*\[.*\]', '', s)
|
||||||
|
mm = re.match(r'^\s*(?:\(([^)]*)\)\s*)?(\S+)\s*$', s.strip())
|
||||||
|
name = mm.group(2) if mm else s.strip()
|
||||||
|
code = mm.group(1) if (mm and mm.group(1) is not None) else ''
|
||||||
|
desc = desc_raw if has_desc else split_camel_case(name)
|
||||||
|
return {'name': name, 'code': code, 'desc': desc, 'isFolder': False, 'children': []}
|
||||||
|
|
||||||
|
def gv(o, keys):
|
||||||
|
for k in keys:
|
||||||
|
if isinstance(o, dict) and k in o:
|
||||||
|
return o[k]
|
||||||
|
return None
|
||||||
|
name = str(gv(val, ['name', 'имя']) or '')
|
||||||
|
code_v = gv(val, ['code', 'код'])
|
||||||
|
code = str(code_v) if code_v is not None else ''
|
||||||
|
has_desc = isinstance(val, dict) and ('description' in val or 'наименование' in val)
|
||||||
|
desc_v = gv(val, ['description', 'наименование'])
|
||||||
|
desc = str(desc_v) if has_desc else split_camel_case(name)
|
||||||
|
is_folder = gv(val, ['isFolder', 'группа']) is True
|
||||||
|
subs = gv(val, ['childItems', 'подчиненные'])
|
||||||
|
return {'name': name, 'code': code, 'desc': desc if desc is not None else '',
|
||||||
|
'isFolder': is_folder, 'children': list(subs) if subs else []}
|
||||||
|
|
||||||
|
|
||||||
|
def build_predef_item_xml(indent, val, code_type):
|
||||||
|
r = resolve_predef_item(val)
|
||||||
|
parts = [f'{indent}<Item id="{new_uuid()}">']
|
||||||
|
parts.append(f'{indent}\t<Name>{esc_xml_text(r["name"])}</Name>')
|
||||||
|
if not r['code']:
|
||||||
|
parts.append(f'{indent}\t<Code/>')
|
||||||
|
elif code_type == 'Number':
|
||||||
|
parts.append(f'{indent}\t<Code xsi:type="xs:decimal">{esc_xml_text(r["code"])}</Code>')
|
||||||
|
else:
|
||||||
|
parts.append(f'{indent}\t<Code>{esc_xml_text(r["code"])}</Code>')
|
||||||
|
if r['desc'] == '':
|
||||||
|
parts.append(f'{indent}\t<Description/>')
|
||||||
|
else:
|
||||||
|
parts.append(f'{indent}\t<Description>{esc_xml_text(r["desc"])}</Description>')
|
||||||
|
parts.append(f'{indent}\t<IsFolder>{"true" if r["isFolder"] else "false"}</IsFolder>')
|
||||||
|
if r['children']:
|
||||||
|
parts.append(f'{indent}\t<ChildItems>')
|
||||||
|
for c in r['children']:
|
||||||
|
parts.append(build_predef_item_xml(indent + '\t\t', c, code_type).rstrip('\r\n'))
|
||||||
|
parts.append(f'{indent}\t</ChildItems>')
|
||||||
|
parts.append(f'{indent}</Item>')
|
||||||
|
return '\r\n'.join(parts) + '\r\n'
|
||||||
|
|
||||||
|
|
||||||
|
def add_predefined_items(items):
|
||||||
|
global add_count
|
||||||
|
xsi_type = predef_xsi_type_by_obj.get(obj_type)
|
||||||
|
if not xsi_type:
|
||||||
|
print(f"add-predefined: тип объекта '{obj_type}' не поддержан (только Catalog, ChartOfCharacteristicTypes)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
code_type = get_object_code_type()
|
||||||
|
version = xml_root.get("version")
|
||||||
|
path = get_predefined_path()
|
||||||
|
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:
|
||||||
|
text = f.read()
|
||||||
|
text = text.replace('</PredefinedData>', items_xml + '</PredefinedData>')
|
||||||
|
else:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
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" '
|
||||||
|
f'xsi:type="{xsi_type}" version="{version}">\r\n')
|
||||||
|
text = hdr + items_xml + '</PredefinedData>\r\n'
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
f.write(b'\xef\xbb\xbf')
|
||||||
|
f.write(text.encode('utf-8'))
|
||||||
|
info(f"Added {len(item_list)} predefined item(s) -> {path}")
|
||||||
|
add_count += len(item_list)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
global xml_tree, xml_root, obj_element, obj_type, md_ns
|
global xml_tree, xml_root, obj_element, obj_type, md_ns
|
||||||
global properties_el, child_objects_el, obj_name
|
global properties_el, child_objects_el, obj_name, resolved_path
|
||||||
global add_count, remove_count, modify_count, warn_count
|
global add_count, remove_count, modify_count, warn_count
|
||||||
|
|
||||||
valid_operations = [
|
valid_operations = [
|
||||||
"add-attribute", "add-ts", "add-dimension", "add-resource",
|
"add-attribute", "add-ts", "add-dimension", "add-resource",
|
||||||
"add-enumValue", "add-column", "add-form", "add-template", "add-command",
|
"add-enumValue", "add-column", "add-form", "add-template", "add-command",
|
||||||
"add-owner", "add-registerRecord", "add-basedOn", "add-inputByString",
|
"add-owner", "add-registerRecord", "add-basedOn", "add-inputByString",
|
||||||
|
"add-dataLockField", "add-registeredDocument", "add-predefined",
|
||||||
"remove-attribute", "remove-ts", "remove-dimension", "remove-resource",
|
"remove-attribute", "remove-ts", "remove-dimension", "remove-resource",
|
||||||
"remove-enumValue", "remove-column", "remove-form", "remove-template", "remove-command",
|
"remove-enumValue", "remove-column", "remove-form", "remove-template", "remove-command",
|
||||||
"remove-owner", "remove-registerRecord", "remove-basedOn", "remove-inputByString",
|
"remove-owner", "remove-registerRecord", "remove-basedOn", "remove-inputByString",
|
||||||
|
"remove-dataLockField", "remove-registeredDocument",
|
||||||
"add-ts-attribute", "remove-ts-attribute", "modify-ts-attribute", "modify-ts",
|
"add-ts-attribute", "remove-ts-attribute", "modify-ts-attribute", "modify-ts",
|
||||||
"modify-attribute", "modify-dimension", "modify-resource",
|
"modify-attribute", "modify-dimension", "modify-resource",
|
||||||
"modify-enumValue", "modify-column",
|
"modify-enumValue", "modify-column",
|
||||||
"modify-property",
|
"modify-property",
|
||||||
"set-owners", "set-registerRecords", "set-basedOn", "set-inputByString",
|
"set-owners", "set-registerRecords", "set-basedOn", "set-inputByString",
|
||||||
|
"set-dataLockFields", "set-registeredDocuments",
|
||||||
]
|
]
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Edit existing 1C metadata object XML", allow_abbrev=False)
|
parser = argparse.ArgumentParser(description="Edit existing 1C metadata object XML", allow_abbrev=False)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-info v1.3 — Compact summary of 1C metadata object
|
# meta-info v1.4 — Compact summary of 1C metadata object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$ObjectPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$ObjectPath,
|
||||||
@@ -418,8 +418,19 @@ function Get-WSOperations($childObjs) {
|
|||||||
# --- Support status of this object (Ext/ParentConfigurations.bin) ---
|
# --- Support status of this object (Ext/ParentConfigurations.bin) ---
|
||||||
# See docs/1c-support-state-spec.md. Walks up to the config root, decodes the
|
# See docs/1c-support-state-spec.md. Walks up to the config root, decodes the
|
||||||
# object's support rule. Never throws — degrades to "не на поддержке".
|
# object's support rule. Never throws — degrades to "не на поддержке".
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-ObjectSupportStatus([string]$objUuid) {
|
function Get-ObjectSupportStatus([string]$objUuid) {
|
||||||
try {
|
try {
|
||||||
|
if (Test-ExternalObjectRoot $ObjectPath) { return $null }
|
||||||
# Walk up to the config root (dir with Configuration.xml or Ext/ParentConfigurations.bin).
|
# Walk up to the config root (dir with Configuration.xml or Ext/ParentConfigurations.bin).
|
||||||
$d = [System.IO.Path]::GetDirectoryName($ObjectPath)
|
$d = [System.IO.Path]::GetDirectoryName($ObjectPath)
|
||||||
$binPath = $null
|
$binPath = $null
|
||||||
@@ -653,7 +664,8 @@ if (-not $drillDone) {
|
|||||||
if ($synonym -and $synonym -ne $objName) { $header += " — `"$synonym`"" }
|
if ($synonym -and $synonym -ne $objName) { $header += " — `"$synonym`"" }
|
||||||
$header += " ==="
|
$header += " ==="
|
||||||
Out $header
|
Out $header
|
||||||
Out "Поддержка: $(Get-ObjectSupportStatus $typeNode.GetAttribute('uuid'))"
|
$support = Get-ObjectSupportStatus $typeNode.GetAttribute('uuid')
|
||||||
|
if ($null -ne $support) { Out "Поддержка: $support" }
|
||||||
|
|
||||||
# --- Type presentation (ref objects) ---
|
# --- Type presentation (ref objects) ---
|
||||||
if ($isRefObject) {
|
if ($isRefObject) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-info v1.3 — Compact summary of 1C metadata object (Python port)
|
# meta-info v1.4 — Compact summary of 1C metadata object (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -472,8 +472,23 @@ def get_ws_operations(child_objs):
|
|||||||
# ── Support status of this object (Ext/ParentConfigurations.bin) ──
|
# ── Support status of this object (Ext/ParentConfigurations.bin) ──
|
||||||
# See docs/1c-support-state-spec.md. Walks up to the config root, decodes the
|
# See docs/1c-support-state-spec.md. Walks up to the config root, decodes the
|
||||||
# object's support rule. Never throws — degrades to "не на поддержке".
|
# object's support rule. Never throws — degrades to "не на поддержке".
|
||||||
|
def _meta_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_object_support_status(obj_uuid):
|
def get_object_support_status(obj_uuid):
|
||||||
try:
|
try:
|
||||||
|
if _meta_is_external_root(object_path):
|
||||||
|
return None
|
||||||
d = os.path.dirname(object_path)
|
d = os.path.dirname(object_path)
|
||||||
bin_path = None
|
bin_path = None
|
||||||
for _ in range(8):
|
for _ in range(8):
|
||||||
@@ -703,7 +718,9 @@ if not drill_done:
|
|||||||
header += f' \u2014 "{synonym}"'
|
header += f' \u2014 "{synonym}"'
|
||||||
header += " ==="
|
header += " ==="
|
||||||
out(header)
|
out(header)
|
||||||
out(f"Поддержка: {get_object_support_status(type_node.get('uuid', ''))}")
|
_support = get_object_support_status(type_node.get('uuid', ''))
|
||||||
|
if _support is not None:
|
||||||
|
out(f"Поддержка: {_support}")
|
||||||
|
|
||||||
# Type presentation (ref objects)
|
# Type presentation (ref objects)
|
||||||
if is_ref_object:
|
if is_ref_object:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-remove v1.3 — Remove metadata object from 1C configuration dump
|
# meta-remove v1.4 — Remove metadata object from 1C configuration dump
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -93,6 +93,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -129,10 +139,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# meta-remove v1.3 — Remove metadata object from 1C configuration dump
|
# meta-remove v1.4 — Remove metadata object from 1C configuration dump
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-validate v1.4 — Validate 1C metadata object structure
|
# meta-validate v1.9 — Validate 1C metadata object structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -65,6 +65,12 @@ if (Test-Path $ObjectPath -PathType Container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# File not found — прощающий ввод для плоских объектов (SessionParameter, CommonAttribute,
|
||||||
|
# DefinedType, WSReference, … — один .xml без папки): дописать .xml к голому имени
|
||||||
|
if (-not (Test-Path $ObjectPath) -and -not [System.IO.Path]::HasExtension($ObjectPath)) {
|
||||||
|
if (Test-Path "$ObjectPath.xml") { $ObjectPath = "$ObjectPath.xml" }
|
||||||
|
}
|
||||||
|
|
||||||
# File not found — check Dir/Name/Name.xml → Dir/Name.xml
|
# File not found — check Dir/Name/Name.xml → Dir/Name.xml
|
||||||
if (-not (Test-Path $ObjectPath)) {
|
if (-not (Test-Path $ObjectPath)) {
|
||||||
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
|
$fileName = [System.IO.Path]::GetFileNameWithoutExtension($ObjectPath)
|
||||||
@@ -162,6 +168,15 @@ $validTypes = @(
|
|||||||
"HTTPService","WebService","DefinedType"
|
"HTTPService","WebService","DefinedType"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Валидные типы метаданных без глубоких правил валидации — раньше падали как "Unrecognized"
|
||||||
|
# (ложная ошибка на валидном объекте). Для них выполняется базовая структурная проверка (root/uuid/Name).
|
||||||
|
$structuralOnlyTypes = @(
|
||||||
|
"Subsystem","Role","CommonForm","CommonCommand","CommandGroup","CommonAttribute",
|
||||||
|
"CommonTemplate","CommonPicture","SessionParameter","SettingsStorage","FilterCriterion",
|
||||||
|
"FunctionalOption","FunctionalOptionsParameter","Language","Style","StyleItem",
|
||||||
|
"WSReference","XDTOPackage","DocumentNumerator","Sequence"
|
||||||
|
)
|
||||||
|
|
||||||
# GeneratedType categories by type
|
# GeneratedType categories by type
|
||||||
$generatedTypeCategories = @{
|
$generatedTypeCategories = @{
|
||||||
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
"Catalog" = @("Object","Ref","Selection","List","Manager")
|
||||||
@@ -240,6 +255,11 @@ $childObjectRules = @{
|
|||||||
"EventSubscription" = @()
|
"EventSubscription" = @()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Группы командного интерфейса (зеркало meta-compile): раздела — без commandParameterType; формы — с параметром.
|
||||||
|
$sectionCommandGroups = @("NavigationPanelImportant","NavigationPanelOrdinary","NavigationPanelSeeAlso","ActionsPanelCreate","ActionsPanelReports","ActionsPanelTools")
|
||||||
|
$formCommandGroups = @("FormCommandBarImportant","FormCommandBarCreateBasedOn","FormNavigationPanelImportant","FormNavigationPanelGoTo","FormNavigationPanelSeeAlso")
|
||||||
|
$validCommandGroups = $sectionCommandGroups + $formCommandGroups
|
||||||
|
|
||||||
# Valid enum property values
|
# Valid enum property values
|
||||||
$validPropertyValues = @{
|
$validPropertyValues = @{
|
||||||
"CodeType" = @("String","Number")
|
"CodeType" = @("String","Number")
|
||||||
@@ -253,7 +273,7 @@ $validPropertyValues = @{
|
|||||||
"DataLockControlMode" = @("Automatic","Managed")
|
"DataLockControlMode" = @("Automatic","Managed")
|
||||||
"FullTextSearch" = @("Use","DontUse")
|
"FullTextSearch" = @("Use","DontUse")
|
||||||
"DefaultPresentation" = @("AsDescription","AsCode")
|
"DefaultPresentation" = @("AsDescription","AsCode")
|
||||||
"HierarchyType" = @("HierarchyFoldersAndItems","HierarchyItemsOnly")
|
"HierarchyType" = @("HierarchyFoldersAndItems","HierarchyOfItems")
|
||||||
"EditType" = @("InDialog","InList","BothWays")
|
"EditType" = @("InDialog","InList","BothWays")
|
||||||
"WriteMode" = @("Independent","RecorderSubordinate")
|
"WriteMode" = @("Independent","RecorderSubordinate")
|
||||||
"InformationRegisterPeriodicity" = @("Nonperiodical","Second","Day","Month","Quarter","Year","RecorderPosition")
|
"InformationRegisterPeriodicity" = @("Nonperiodical","Second","Day","Month","Quarter","Year","RecorderPosition")
|
||||||
@@ -350,7 +370,7 @@ if ($childElements.Count -eq 0) {
|
|||||||
$typeNode = $childElements[0]
|
$typeNode = $childElements[0]
|
||||||
$mdType = $typeNode.LocalName
|
$mdType = $typeNode.LocalName
|
||||||
|
|
||||||
if ($validTypes -notcontains $mdType) {
|
if (($validTypes -notcontains $mdType) -and ($structuralOnlyTypes -notcontains $mdType)) {
|
||||||
Report-Error "1. Unrecognized metadata type: $mdType"
|
Report-Error "1. Unrecognized metadata type: $mdType"
|
||||||
& $finalize
|
& $finalize
|
||||||
exit 1
|
exit 1
|
||||||
@@ -378,6 +398,20 @@ if ($check1Ok) {
|
|||||||
Report-OK "1. Root structure: MetaDataObject/$mdType, version $version"
|
Report-OK "1. Root structure: MetaDataObject/$mdType, version $version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Structural-only types: базовая проверка (Name), без type-specific правил ---
|
||||||
|
if ($structuralOnlyTypes -contains $mdType) {
|
||||||
|
if ($objName -eq "(unknown)") {
|
||||||
|
Report-Error "3. Properties: missing or empty Name"
|
||||||
|
} elseif ($objName -notmatch $identPattern) {
|
||||||
|
Report-Error "3. Properties: Name '$objName' is not a valid 1C identifier"
|
||||||
|
} else {
|
||||||
|
Report-OK "3. Properties: Name=`"$objName`" (базовая структурная проверка для $mdType)"
|
||||||
|
}
|
||||||
|
& $finalize
|
||||||
|
if ($script:errors -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
if ($script:stopped) { & $finalize; exit 1 }
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
# --- Check 2: InternalInfo ---
|
# --- Check 2: InternalInfo ---
|
||||||
@@ -699,17 +733,31 @@ if ($childObjNode) {
|
|||||||
|
|
||||||
if ($script:stopped) { & $finalize; exit 1 }
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
# --- Check 7b: Reserved attribute names ---
|
# --- Check 7b: Reserved attribute names (типозависимо: стандартные реквизиты ДАННОГО типа, EN+RU) ---
|
||||||
|
# Совпадение имени собственного реквизита со стандартным (англ. или рус.) платформа не примет → ошибка.
|
||||||
|
|
||||||
$reservedAttrNames = @(
|
$reservedEnRu = @{
|
||||||
"Ref","DeletionMark","Code","Description","Date","Number","Posted","Parent","Owner",
|
"Ref"="Ссылка"; "DeletionMark"="ПометкаУдаления"; "Code"="Код"; "Description"="Наименование"
|
||||||
"IsFolder","Predefined","PredefinedDataName","Recorder","Period","LineNumber","Active",
|
"Date"="Дата"; "Number"="Номер"; "Posted"="Проведен"; "Parent"="Родитель"; "Owner"="Владелец"
|
||||||
"Order","Type","OffBalance","Started","Completed","HeadTask","Executed","RoutePoint",
|
"IsFolder"="ЭтоГруппа"; "Predefined"="Предопределенный"; "PredefinedDataName"="ИмяПредопределенныхДанных"
|
||||||
"BusinessProcess","ThisNode","SentNo","ReceivedNo","CalculationType","RegistrationPeriod",
|
"Recorder"="Регистратор"; "Period"="Период"; "LineNumber"="НомерСтроки"; "Active"="Активность"
|
||||||
"ReversingEntry","Account","ValueType","ActionPeriodIsBasic"
|
"Order"="Порядок"; "Type"="Тип"; "OffBalance"="Забалансовый"; "RecordType"="ВидДвижения"
|
||||||
)
|
"Started"="Стартован"; "Completed"="Завершен"; "HeadTask"="ВедущаяЗадача"
|
||||||
|
"Executed"="Выполнена"; "RoutePoint"="ТочкаМаршрута"; "BusinessProcess"="БизнесПроцесс"
|
||||||
|
"ThisNode"="ЭтотУзел"; "SentNo"="НомерОтправленного"; "ReceivedNo"="НомерПринятого"
|
||||||
|
"CalculationType"="ВидРасчета"; "RegistrationPeriod"="ПериодРегистрации"; "ReversingEntry"="СторноЗапись"
|
||||||
|
"Account"="Счет"; "ValueType"="ТипЗначения"; "ActionPeriodIsBasic"="ПериодДействияБазовый"
|
||||||
|
}
|
||||||
|
|
||||||
if ($childObjNode) {
|
$stdForType = $standardAttributesByType[$mdType]
|
||||||
|
if ($childObjNode -and $stdForType) {
|
||||||
|
# Множество зарезервированных имён (EN + RU) в нижнем регистре.
|
||||||
|
$reservedSet = @{}
|
||||||
|
foreach ($en in $stdForType) {
|
||||||
|
$reservedSet[$en.ToLower()] = $true
|
||||||
|
$ru = $reservedEnRu[$en]
|
||||||
|
if ($ru) { $reservedSet[$ru.ToLower()] = $true }
|
||||||
|
}
|
||||||
$check7bOk = $true
|
$check7bOk = $true
|
||||||
$attrNodes = $childObjNode.SelectNodes("md:Attribute", $ns)
|
$attrNodes = $childObjNode.SelectNodes("md:Attribute", $ns)
|
||||||
foreach ($attrNode in $attrNodes) {
|
foreach ($attrNode in $attrNodes) {
|
||||||
@@ -718,8 +766,8 @@ if ($childObjNode) {
|
|||||||
$attrNameNode = $attrProps.SelectSingleNode("md:Name", $ns)
|
$attrNameNode = $attrProps.SelectSingleNode("md:Name", $ns)
|
||||||
if ($attrNameNode -and $attrNameNode.InnerText) {
|
if ($attrNameNode -and $attrNameNode.InnerText) {
|
||||||
$an = $attrNameNode.InnerText
|
$an = $attrNameNode.InnerText
|
||||||
if ($reservedAttrNames -contains $an) {
|
if ($reservedSet.ContainsKey($an.ToLower())) {
|
||||||
Report-Warn "7b. Attribute '$an' conflicts with a standard attribute name"
|
Report-Error "7b. Attribute '$an' conflicts with a standard attribute of $mdType"
|
||||||
$check7bOk = $false
|
$check7bOk = $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -728,6 +776,8 @@ if ($childObjNode) {
|
|||||||
if ($check7bOk) {
|
if ($check7bOk) {
|
||||||
Report-OK "7b. Reserved attribute names: no conflicts"
|
Report-OK "7b. Reserved attribute names: no conflicts"
|
||||||
}
|
}
|
||||||
|
} elseif ($childObjNode) {
|
||||||
|
Report-OK "7b. Reserved attribute names: no conflicts (no standard set for $mdType)"
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($script:stopped) { & $finalize; exit 1 }
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
@@ -1330,6 +1380,99 @@ if ($mdType -eq "DocumentJournal" -and $childObjNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($script:stopped) { & $finalize; exit 1 }
|
||||||
|
|
||||||
|
# --- Check 15: Commands — Group обязателен/валиден; секц.группа несовместима с CommandParameterType ---
|
||||||
|
|
||||||
|
if ($childObjNode) {
|
||||||
|
$commands = $childObjNode.SelectNodes("md:Command", $ns)
|
||||||
|
$check15Ok = $true
|
||||||
|
$cmdCount = 0
|
||||||
|
foreach ($cmd in $commands) {
|
||||||
|
if ($script:stopped) { break }
|
||||||
|
$cmdCount++
|
||||||
|
$cuuid = $cmd.GetAttribute("uuid")
|
||||||
|
$cmdProps = $cmd.SelectSingleNode("md:Properties", $ns)
|
||||||
|
$cmdNameNode = if ($cmdProps) { $cmdProps.SelectSingleNode("md:Name", $ns) } else { $null }
|
||||||
|
$cmdName = if ($cmdNameNode -and $cmdNameNode.InnerText) { $cmdNameNode.InnerText } else { "(unnamed)" }
|
||||||
|
if (-not $cuuid -or $cuuid -notmatch $guidPattern) {
|
||||||
|
Report-Error "15. Command '$cmdName': missing or invalid uuid"; $check15Ok = $false
|
||||||
|
}
|
||||||
|
if ($cmdName -eq "(unnamed)") {
|
||||||
|
Report-Error "15. Command (uuid=$cuuid): missing or empty Name"; $check15Ok = $false
|
||||||
|
}
|
||||||
|
$groupNode = if ($cmdProps) { $cmdProps.SelectSingleNode("md:Group", $ns) } else { $null }
|
||||||
|
$groupVal = if ($groupNode) { $groupNode.InnerText.Trim() } else { "" }
|
||||||
|
if (-not $groupVal) {
|
||||||
|
Report-Error "15. Command '$cmdName': не задана группа (Group) — 1С отвергает при загрузке"; $check15Ok = $false
|
||||||
|
} elseif (($validCommandGroups -notcontains $groupVal) -and ($groupVal -notmatch '^CommandGroup\.')) {
|
||||||
|
Report-Error "15. Command '$cmdName': неизвестная группа '$groupVal'. Валидные: $($validCommandGroups -join ', '); либо CommandGroup.<Имя>"; $check15Ok = $false
|
||||||
|
} elseif ($sectionCommandGroups -contains $groupVal) {
|
||||||
|
$cptNode = if ($cmdProps) { $cmdProps.SelectSingleNode("md:CommandParameterType", $ns) } else { $null }
|
||||||
|
$hasCpt = $cptNode -and (($cptNode.SelectNodes("v8:Type", $ns).Count -gt 0) -or ($cptNode.SelectNodes("v8:TypeSet", $ns).Count -gt 0))
|
||||||
|
if ($hasCpt) {
|
||||||
|
Report-Error "15. Command '$cmdName': тип параметра (CommandParameterType) недоступен для команд командного интерфейса раздела ('$groupVal')"; $check15Ok = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($check15Ok -and $cmdCount -gt 0) {
|
||||||
|
Report-OK "15. Commands: $cmdCount command(s), groups valid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Check 16: Reference type existence — типы вида CatalogRef.X должны разрешаться в объекты конфигурации ---
|
||||||
|
# WARN-уровень: ложное срабатывание на частичных выгрузках хуже пропуска. Расширения (CFE) пропускаем —
|
||||||
|
# их типы ссылаются на объекты базовой конфигурации, которых нет в выгрузке расширения.
|
||||||
|
|
||||||
|
if ($script:configDir) {
|
||||||
|
$isExtension = $false
|
||||||
|
$cfgXmlPath = Join-Path $script:configDir "Configuration.xml"
|
||||||
|
if (Test-Path $cfgXmlPath) {
|
||||||
|
$cfgContent = [System.IO.File]::ReadAllText($cfgXmlPath, [System.Text.Encoding]::UTF8)
|
||||||
|
if ($cfgContent.Contains("ConfigurationExtensionPurpose")) { $isExtension = $true }
|
||||||
|
}
|
||||||
|
if (-not $isExtension) {
|
||||||
|
$refDirMap = @{
|
||||||
|
"CatalogRef"="Catalogs"; "DocumentRef"="Documents"; "EnumRef"="Enums"
|
||||||
|
"ChartOfAccountsRef"="ChartsOfAccounts"; "ChartOfCharacteristicTypesRef"="ChartsOfCharacteristicTypes"
|
||||||
|
"ChartOfCalculationTypesRef"="ChartsOfCalculationTypes"; "BusinessProcessRef"="BusinessProcesses"
|
||||||
|
"ExchangePlanRef"="ExchangePlans"; "TaskRef"="Tasks"; "DefinedType"="DefinedTypes"
|
||||||
|
}
|
||||||
|
$typeNodes = $xmlDoc.SelectNodes("//v8:Type", $ns)
|
||||||
|
$checkedRefs = @{} # refKey -> $true если найден; для OK-условия
|
||||||
|
$missingRefs = @{} # refKey -> refDir
|
||||||
|
foreach ($tn in $typeNodes) {
|
||||||
|
$tv = $tn.InnerText.Trim()
|
||||||
|
if (-not $tv) { continue }
|
||||||
|
$colonIdx = $tv.IndexOf(':')
|
||||||
|
if ($colonIdx -ge 0) { $tv = $tv.Substring($colonIdx + 1) }
|
||||||
|
$dotIdx = $tv.IndexOf('.')
|
||||||
|
if ($dotIdx -lt 0) { continue }
|
||||||
|
$refCat = $tv.Substring(0, $dotIdx)
|
||||||
|
$refName = $tv.Substring($dotIdx + 1)
|
||||||
|
$refDir = $refDirMap[$refCat]
|
||||||
|
if (-not $refDir -or -not $refName) { continue }
|
||||||
|
$refKey = "$refCat.$refName"
|
||||||
|
if ($checkedRefs.ContainsKey($refKey)) { continue }
|
||||||
|
$refFolder = Join-Path $script:configDir (Join-Path $refDir $refName)
|
||||||
|
$refFile = Join-Path $script:configDir (Join-Path $refDir "$refName.xml")
|
||||||
|
if ((Test-Path $refFolder) -or (Test-Path $refFile)) {
|
||||||
|
$checkedRefs[$refKey] = $true
|
||||||
|
} else {
|
||||||
|
$checkedRefs[$refKey] = $false
|
||||||
|
$missingRefs[$refKey] = $refDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($missingRefs.Count -gt 0) {
|
||||||
|
foreach ($mk in ($missingRefs.Keys | Sort-Object)) {
|
||||||
|
Report-Warn "16. Ссылочный тип '$mk' не найден в конфигурации ($($missingRefs[$mk])/) — при загрузке будет ошибка неизвестного типа"
|
||||||
|
}
|
||||||
|
} elseif ($checkedRefs.Count -gt 0) {
|
||||||
|
Report-OK "16. Reference types: $($checkedRefs.Count) resolved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Final output ---
|
# --- Final output ---
|
||||||
|
|
||||||
& $finalize
|
& $finalize
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# meta-validate v1.4 — Validate 1C metadata object structure (Python port)
|
# meta-validate v1.9 — Validate 1C metadata object structure (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -70,6 +70,12 @@ if os.path.isdir(object_path):
|
|||||||
print(f"[ERROR] No XML file found in directory: {object_path}")
|
print(f"[ERROR] No XML file found in directory: {object_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# File not found -- прощающий ввод для плоских объектов (SessionParameter, CommonAttribute,
|
||||||
|
# DefinedType, WSReference, ... -- один .xml без папки): дописать .xml к голому имени
|
||||||
|
if not os.path.exists(object_path) and not os.path.splitext(object_path)[1]:
|
||||||
|
if os.path.exists(object_path + ".xml"):
|
||||||
|
object_path = object_path + ".xml"
|
||||||
|
|
||||||
# File not found -- check Dir/Name/Name.xml -> Dir/Name.xml
|
# File not found -- check Dir/Name/Name.xml -> Dir/Name.xml
|
||||||
if not os.path.exists(object_path):
|
if not os.path.exists(object_path):
|
||||||
file_name = os.path.splitext(os.path.basename(object_path))[0]
|
file_name = os.path.splitext(os.path.basename(object_path))[0]
|
||||||
@@ -162,6 +168,15 @@ valid_types = (
|
|||||||
"HTTPService", "WebService", "DefinedType",
|
"HTTPService", "WebService", "DefinedType",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Валидные типы метаданных без глубоких правил валидации — раньше падали как "Unrecognized"
|
||||||
|
# (ложная ошибка на валидном объекте). Для них выполняется базовая структурная проверка (root/uuid/Name).
|
||||||
|
structural_only_types = (
|
||||||
|
"Subsystem", "Role", "CommonForm", "CommonCommand", "CommandGroup", "CommonAttribute",
|
||||||
|
"CommonTemplate", "CommonPicture", "SessionParameter", "SettingsStorage", "FilterCriterion",
|
||||||
|
"FunctionalOption", "FunctionalOptionsParameter", "Language", "Style", "StyleItem",
|
||||||
|
"WSReference", "XDTOPackage", "DocumentNumerator", "Sequence",
|
||||||
|
)
|
||||||
|
|
||||||
# GeneratedType categories by type
|
# GeneratedType categories by type
|
||||||
generated_type_categories = {
|
generated_type_categories = {
|
||||||
"Catalog": ["Object", "Ref", "Selection", "List", "Manager"],
|
"Catalog": ["Object", "Ref", "Selection", "List", "Manager"],
|
||||||
@@ -240,6 +255,13 @@ child_object_rules = {
|
|||||||
"EventSubscription": [],
|
"EventSubscription": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Группы командного интерфейса (зеркало meta-compile): раздела — без commandParameterType; формы — с параметром.
|
||||||
|
SECTION_COMMAND_GROUPS = ["NavigationPanelImportant", "NavigationPanelOrdinary", "NavigationPanelSeeAlso",
|
||||||
|
"ActionsPanelCreate", "ActionsPanelReports", "ActionsPanelTools"]
|
||||||
|
FORM_COMMAND_GROUPS = ["FormCommandBarImportant", "FormCommandBarCreateBasedOn",
|
||||||
|
"FormNavigationPanelImportant", "FormNavigationPanelGoTo", "FormNavigationPanelSeeAlso"]
|
||||||
|
VALID_COMMAND_GROUPS = SECTION_COMMAND_GROUPS + FORM_COMMAND_GROUPS
|
||||||
|
|
||||||
# Valid enum property values
|
# Valid enum property values
|
||||||
valid_property_values = {
|
valid_property_values = {
|
||||||
"CodeType": ["String", "Number"],
|
"CodeType": ["String", "Number"],
|
||||||
@@ -253,7 +275,7 @@ valid_property_values = {
|
|||||||
"DataLockControlMode": ["Automatic", "Managed"],
|
"DataLockControlMode": ["Automatic", "Managed"],
|
||||||
"FullTextSearch": ["Use", "DontUse"],
|
"FullTextSearch": ["Use", "DontUse"],
|
||||||
"DefaultPresentation": ["AsDescription", "AsCode"],
|
"DefaultPresentation": ["AsDescription", "AsCode"],
|
||||||
"HierarchyType": ["HierarchyFoldersAndItems", "HierarchyItemsOnly"],
|
"HierarchyType": ["HierarchyFoldersAndItems", "HierarchyOfItems"],
|
||||||
"EditType": ["InDialog", "InList", "BothWays"],
|
"EditType": ["InDialog", "InList", "BothWays"],
|
||||||
"WriteMode": ["Independent", "RecorderSubordinate"],
|
"WriteMode": ["Independent", "RecorderSubordinate"],
|
||||||
"InformationRegisterPeriodicity": ["Nonperiodical", "Second", "Day", "Month", "Quarter", "Year", "RecorderPosition"],
|
"InformationRegisterPeriodicity": ["Nonperiodical", "Second", "Day", "Month", "Quarter", "Year", "RecorderPosition"],
|
||||||
@@ -372,7 +394,7 @@ elif len(child_elements) > 1:
|
|||||||
type_node = child_elements[0]
|
type_node = child_elements[0]
|
||||||
md_type = local_name(type_node)
|
md_type = local_name(type_node)
|
||||||
|
|
||||||
if md_type not in valid_types:
|
if md_type not in valid_types and md_type not in structural_only_types:
|
||||||
report_error(f"1. Unrecognized metadata type: {md_type}")
|
report_error(f"1. Unrecognized metadata type: {md_type}")
|
||||||
finalize()
|
finalize()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -397,6 +419,17 @@ output_lines.insert(0, f"=== Validation: {md_type}.{obj_name} ===")
|
|||||||
if check1_ok:
|
if check1_ok:
|
||||||
report_ok(f"1. Root structure: MetaDataObject/{md_type}, version {version}")
|
report_ok(f"1. Root structure: MetaDataObject/{md_type}, version {version}")
|
||||||
|
|
||||||
|
# ── Structural-only types: базовая проверка (Name), без type-specific правил ──
|
||||||
|
if md_type in structural_only_types:
|
||||||
|
if obj_name == "(unknown)":
|
||||||
|
report_error("3. Properties: missing or empty Name")
|
||||||
|
elif not ident_pattern.match(obj_name):
|
||||||
|
report_error(f"3. Properties: Name '{obj_name}' is not a valid 1C identifier")
|
||||||
|
else:
|
||||||
|
report_ok(f'3. Properties: Name="{obj_name}" (базовая структурная проверка для {md_type})')
|
||||||
|
finalize()
|
||||||
|
sys.exit(1 if errors > 0 else 0)
|
||||||
|
|
||||||
if stopped:
|
if stopped:
|
||||||
finalize()
|
finalize()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -676,18 +709,30 @@ if stopped:
|
|||||||
finalize()
|
finalize()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ── Check 7b: Reserved attribute names ───────────────────────
|
# ── Check 7b: Reserved attribute names (типозависимо: стандартные ДАННОГО типа, EN+RU) ───────
|
||||||
|
# Совпадение имени собственного реквизита со стандартным (англ. или рус.) платформа не примет → ошибка.
|
||||||
|
|
||||||
RESERVED_ATTR_NAMES = {
|
RESERVED_EN_RU = {
|
||||||
'Ref', 'DeletionMark', 'Code', 'Description', 'Date', 'Number', 'Posted',
|
'Ref': 'Ссылка', 'DeletionMark': 'ПометкаУдаления', 'Code': 'Код', 'Description': 'Наименование',
|
||||||
'Parent', 'Owner', 'IsFolder', 'Predefined', 'PredefinedDataName',
|
'Date': 'Дата', 'Number': 'Номер', 'Posted': 'Проведен', 'Parent': 'Родитель', 'Owner': 'Владелец',
|
||||||
'Recorder', 'Period', 'LineNumber', 'Active', 'Order', 'Type', 'OffBalance',
|
'IsFolder': 'ЭтоГруппа', 'Predefined': 'Предопределенный', 'PredefinedDataName': 'ИмяПредопределенныхДанных',
|
||||||
'Started', 'Completed', 'HeadTask', 'Executed', 'RoutePoint', 'BusinessProcess',
|
'Recorder': 'Регистратор', 'Period': 'Период', 'LineNumber': 'НомерСтроки', 'Active': 'Активность',
|
||||||
'ThisNode', 'SentNo', 'ReceivedNo', 'CalculationType', 'RegistrationPeriod',
|
'Order': 'Порядок', 'Type': 'Тип', 'OffBalance': 'Забалансовый', 'RecordType': 'ВидДвижения',
|
||||||
'ReversingEntry', 'Account', 'ValueType', 'ActionPeriodIsBasic',
|
'Started': 'Стартован', 'Completed': 'Завершен', 'HeadTask': 'ВедущаяЗадача',
|
||||||
|
'Executed': 'Выполнена', 'RoutePoint': 'ТочкаМаршрута', 'BusinessProcess': 'БизнесПроцесс',
|
||||||
|
'ThisNode': 'ЭтотУзел', 'SentNo': 'НомерОтправленного', 'ReceivedNo': 'НомерПринятого',
|
||||||
|
'CalculationType': 'ВидРасчета', 'RegistrationPeriod': 'ПериодРегистрации', 'ReversingEntry': 'СторноЗапись',
|
||||||
|
'Account': 'Счет', 'ValueType': 'ТипЗначения', 'ActionPeriodIsBasic': 'ПериодДействияБазовый',
|
||||||
}
|
}
|
||||||
|
|
||||||
if child_obj_node is not None:
|
std_for_type = standard_attributes_by_type.get(md_type)
|
||||||
|
if child_obj_node is not None and std_for_type:
|
||||||
|
reserved_set = set()
|
||||||
|
for en in std_for_type:
|
||||||
|
reserved_set.add(en.lower())
|
||||||
|
ru = RESERVED_EN_RU.get(en)
|
||||||
|
if ru:
|
||||||
|
reserved_set.add(ru.lower())
|
||||||
check7b_ok = True
|
check7b_ok = True
|
||||||
for attr_node in find_all(child_obj_node, 'md:Attribute'):
|
for attr_node in find_all(child_obj_node, 'md:Attribute'):
|
||||||
attr_props = find(attr_node, 'md:Properties')
|
attr_props = find(attr_node, 'md:Properties')
|
||||||
@@ -695,11 +740,13 @@ if child_obj_node is not None:
|
|||||||
attr_name_node = find(attr_props, 'md:Name')
|
attr_name_node = find(attr_props, 'md:Name')
|
||||||
if attr_name_node is not None and inner_text(attr_name_node):
|
if attr_name_node is not None and inner_text(attr_name_node):
|
||||||
an = inner_text(attr_name_node)
|
an = inner_text(attr_name_node)
|
||||||
if an in RESERVED_ATTR_NAMES:
|
if an.lower() in reserved_set:
|
||||||
report_warn(f"7b. Attribute '{an}' conflicts with a standard attribute name")
|
report_error(f"7b. Attribute '{an}' conflicts with a standard attribute of {md_type}")
|
||||||
check7b_ok = False
|
check7b_ok = False
|
||||||
if check7b_ok:
|
if check7b_ok:
|
||||||
report_ok("7b. Reserved attribute names: no conflicts")
|
report_ok("7b. Reserved attribute names: no conflicts")
|
||||||
|
elif child_obj_node is not None:
|
||||||
|
report_ok(f"7b. Reserved attribute names: no conflicts (no standard set for {md_type})")
|
||||||
|
|
||||||
if stopped:
|
if stopped:
|
||||||
finalize()
|
finalize()
|
||||||
@@ -1238,6 +1285,104 @@ if md_type == "DocumentJournal" and child_obj_node is not None:
|
|||||||
elif col_count == 0:
|
elif col_count == 0:
|
||||||
report_ok("14. DocumentJournal Columns: none")
|
report_ok("14. DocumentJournal Columns: none")
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
finalize()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ── Check 15: Commands — Group обязателен/валиден; секц.группа несовместима с CommandParameterType ──
|
||||||
|
|
||||||
|
if child_obj_node is not None:
|
||||||
|
commands = find_all(child_obj_node, "md:Command")
|
||||||
|
check15_ok = True
|
||||||
|
cmd_count = 0
|
||||||
|
for cmd in commands:
|
||||||
|
if stopped:
|
||||||
|
break
|
||||||
|
cmd_count += 1
|
||||||
|
cuuid = cmd.get("uuid", "")
|
||||||
|
cmd_props = find(cmd, "md:Properties")
|
||||||
|
cmd_name_node = find(cmd_props, "md:Name") if cmd_props is not None else None
|
||||||
|
cmd_name = inner_text(cmd_name_node) if (cmd_name_node is not None and inner_text(cmd_name_node)) else "(unnamed)"
|
||||||
|
if not cuuid or not guid_pattern.match(cuuid):
|
||||||
|
report_error(f"15. Command '{cmd_name}': missing or invalid uuid")
|
||||||
|
check15_ok = False
|
||||||
|
if cmd_name == "(unnamed)":
|
||||||
|
report_error(f"15. Command (uuid={cuuid}): missing or empty Name")
|
||||||
|
check15_ok = False
|
||||||
|
group_node = find(cmd_props, "md:Group") if cmd_props is not None else None
|
||||||
|
group_val = inner_text(group_node).strip() if group_node is not None else ""
|
||||||
|
if not group_val:
|
||||||
|
report_error(f"15. Command '{cmd_name}': не задана группа (Group) — 1С отвергает при загрузке")
|
||||||
|
check15_ok = False
|
||||||
|
elif group_val not in VALID_COMMAND_GROUPS and not group_val.startswith("CommandGroup."):
|
||||||
|
report_error(f"15. Command '{cmd_name}': неизвестная группа '{group_val}'. "
|
||||||
|
f"Валидные: {', '.join(VALID_COMMAND_GROUPS)}; либо CommandGroup.<Имя>")
|
||||||
|
check15_ok = False
|
||||||
|
elif group_val in SECTION_COMMAND_GROUPS:
|
||||||
|
cpt_node = find(cmd_props, "md:CommandParameterType") if cmd_props is not None else None
|
||||||
|
has_cpt = cpt_node is not None and (len(find_all(cpt_node, "v8:Type")) > 0 or len(find_all(cpt_node, "v8:TypeSet")) > 0)
|
||||||
|
if has_cpt:
|
||||||
|
report_error(f"15. Command '{cmd_name}': тип параметра (CommandParameterType) недоступен "
|
||||||
|
f"для команд командного интерфейса раздела ('{group_val}')")
|
||||||
|
check15_ok = False
|
||||||
|
if check15_ok and cmd_count > 0:
|
||||||
|
report_ok(f"15. Commands: {cmd_count} command(s), groups valid")
|
||||||
|
|
||||||
|
# ── Check 16: Reference type existence — типы вида CatalogRef.X должны разрешаться в объекты конфигурации ──
|
||||||
|
# WARN-уровень: ложное срабатывание на частичных выгрузках хуже пропуска. Расширения (CFE) пропускаем —
|
||||||
|
# их типы ссылаются на объекты базовой конфигурации, которых нет в выгрузке расширения.
|
||||||
|
|
||||||
|
if config_dir:
|
||||||
|
is_extension = False
|
||||||
|
cfg_xml_path = os.path.join(config_dir, "Configuration.xml")
|
||||||
|
if os.path.exists(cfg_xml_path):
|
||||||
|
try:
|
||||||
|
with open(cfg_xml_path, "r", encoding="utf-8") as f:
|
||||||
|
cfg_content = f.read()
|
||||||
|
if "ConfigurationExtensionPurpose" in cfg_content:
|
||||||
|
is_extension = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not is_extension:
|
||||||
|
ref_dir_map = {
|
||||||
|
"CatalogRef": "Catalogs", "DocumentRef": "Documents", "EnumRef": "Enums",
|
||||||
|
"ChartOfAccountsRef": "ChartsOfAccounts", "ChartOfCharacteristicTypesRef": "ChartsOfCharacteristicTypes",
|
||||||
|
"ChartOfCalculationTypesRef": "ChartsOfCalculationTypes", "BusinessProcessRef": "BusinessProcesses",
|
||||||
|
"ExchangePlanRef": "ExchangePlans", "TaskRef": "Tasks", "DefinedType": "DefinedTypes",
|
||||||
|
}
|
||||||
|
checked_refs = {} # ref_key -> найден ли; для условия OK
|
||||||
|
missing_refs = {} # ref_key -> ref_dir
|
||||||
|
for tn in find_all(root, ".//v8:Type"):
|
||||||
|
tv = inner_text(tn).strip()
|
||||||
|
if not tv:
|
||||||
|
continue
|
||||||
|
colon_idx = tv.find(":")
|
||||||
|
if colon_idx >= 0:
|
||||||
|
tv = tv[colon_idx + 1:]
|
||||||
|
dot_idx = tv.find(".")
|
||||||
|
if dot_idx < 0:
|
||||||
|
continue
|
||||||
|
ref_cat = tv[:dot_idx]
|
||||||
|
ref_name = tv[dot_idx + 1:]
|
||||||
|
ref_dir = ref_dir_map.get(ref_cat)
|
||||||
|
if not ref_dir or not ref_name:
|
||||||
|
continue
|
||||||
|
ref_key = f"{ref_cat}.{ref_name}"
|
||||||
|
if ref_key in checked_refs:
|
||||||
|
continue
|
||||||
|
ref_folder = os.path.join(config_dir, ref_dir, ref_name)
|
||||||
|
ref_file = os.path.join(config_dir, ref_dir, ref_name + ".xml")
|
||||||
|
if os.path.exists(ref_folder) or os.path.exists(ref_file):
|
||||||
|
checked_refs[ref_key] = True
|
||||||
|
else:
|
||||||
|
checked_refs[ref_key] = False
|
||||||
|
missing_refs[ref_key] = ref_dir
|
||||||
|
if missing_refs:
|
||||||
|
for mk in sorted(missing_refs):
|
||||||
|
report_warn(f"16. Ссылочный тип '{mk}' не найден в конфигурации ({missing_refs[mk]}/) — при загрузке будет ошибка неизвестного типа")
|
||||||
|
elif checked_refs:
|
||||||
|
report_ok(f"16. Reference types: {len(checked_refs)} resolved")
|
||||||
|
|
||||||
# ── Final output ──────────────────────────────────────────────
|
# ── Final output ──────────────────────────────────────────────
|
||||||
|
|
||||||
finalize()
|
finalize()
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/mxl-compile.ps1" -J
|
|||||||
|
|
||||||
## Рабочий процесс
|
## Рабочий процесс
|
||||||
|
|
||||||
1. Claude пишет JSON-определение (Write tool) → файл `.json`
|
1. Написать JSON-определение (Write tool) → файл `.json`
|
||||||
2. Claude вызывает `/mxl-compile` для генерации Template.xml
|
2. Вызвать `/mxl-compile` для генерации Template.xml
|
||||||
3. Claude вызывает `/mxl-validate` для проверки корректности
|
3. Вызвать `/mxl-validate` для проверки корректности
|
||||||
4. Claude вызывает `/mxl-info` для верификации структуры
|
4. Вызвать `/mxl-info` для верификации структуры
|
||||||
|
|
||||||
**Если макет создаётся по изображению** (скриншот, скан печатной формы) — сначала вызвать `/img-grid` для наложения сетки, по ней определить границы колонок и пропорции, затем использовать `"Nx"` ширины + `"page"` для автоматического расчёта размеров.
|
**Если макет создаётся по изображению** (скриншот, скан печатной формы) — сначала вызвать `/img-grid` для наложения сетки, по ней определить границы колонок и пропорции, затем использовать `"Nx"` ширины + `"page"` для автоматического расчёта размеров.
|
||||||
|
|
||||||
## JSON-схема DSL
|
## JSON-схема DSL
|
||||||
|
|
||||||
Полная спецификация формата: **`docs/mxl-dsl-spec.md`** (прочитать через Read tool перед написанием JSON).
|
Ниже — компактная структура и ключевые правила, достаточные для типового макета. Полные таблицы полей (все свойства шрифтов, стилей, ячеек), развёрнутый пример и ограничения формата — в **`reference/dsl-spec.md`**; нужны не всегда, читать по необходимости.
|
||||||
|
|
||||||
Краткая структура:
|
Краткая структура:
|
||||||
|
|
||||||
@@ -63,3 +63,4 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/mxl-compile.ps1" -J
|
|||||||
- `rowStyle` — автозаполнение пустот стилем (рамки по всей ширине)
|
- `rowStyle` — автозаполнение пустот стилем (рамки по всей ширине)
|
||||||
- Тип заполнения определяется автоматически: `param` → Parameter, `text` → Text, `template` → Template
|
- Тип заполнения определяется автоматически: `param` → Parameter, `text` → Text, `template` → Template
|
||||||
- `rowspan` — объединение строк вниз (rowStyle учитывает занятые ячейки)
|
- `rowspan` — объединение строк вниз (rowStyle учитывает занятые ячейки)
|
||||||
|
- `empty` в строке — шорткат для N подряд пустых строк (`{ "empty": 3 }` = три `{}`)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Спецификация MXL DSL — JSON-формат описания табличного документа
|
||||||
|
|
||||||
|
Компактный JSON-формат для описания макетов табличных документов 1С (SpreadsheetDocument). Используется навыком `/mxl-compile` (JSON → XML).
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"columns": 10,
|
||||||
|
"defaultWidth": 30,
|
||||||
|
"columnWidths": { "1": 15, "2-8": 40, "9-10": 50 },
|
||||||
|
|
||||||
|
"fonts": {
|
||||||
|
"default": { "face": "Arial", "size": 10 },
|
||||||
|
"bold": { "face": "Arial", "size": 10, "bold": true },
|
||||||
|
"header": { "face": "Arial", "size": 14, "bold": true }
|
||||||
|
},
|
||||||
|
|
||||||
|
"styles": {
|
||||||
|
"default": {},
|
||||||
|
"header": { "font": "header", "align": "center" },
|
||||||
|
"label": { "font": "bold" },
|
||||||
|
"bordered": { "border": "all" },
|
||||||
|
"bordered-right": { "border": "all", "align": "right" },
|
||||||
|
"total-right": { "font": "bold", "border": "top", "align": "right" }
|
||||||
|
},
|
||||||
|
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"name": "Заголовок",
|
||||||
|
"rows": [
|
||||||
|
{ "height": 20, "cells": [
|
||||||
|
{ "col": 1, "span": 10, "style": "header", "param": "ТекстЗаголовка" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ШапкаТаблицы",
|
||||||
|
"rows": [
|
||||||
|
{ "rowStyle": "bordered", "cells": [
|
||||||
|
{ "col": 1, "text": "№" },
|
||||||
|
{ "col": 2, "span": 6, "text": "Наименование" },
|
||||||
|
{ "col": 9, "text": "Кол-во" },
|
||||||
|
{ "col": 10, "text": "Сумма" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Строка",
|
||||||
|
"rows": [
|
||||||
|
{ "rowStyle": "bordered", "cells": [
|
||||||
|
{ "col": 1, "param": "НомерСтроки" },
|
||||||
|
{ "col": 2, "span": 6, "param": "Товар", "detail": "Номенклатура" },
|
||||||
|
{ "col": 9, "style": "bordered-right", "param": "Количество" },
|
||||||
|
{ "col": 10, "style": "bordered-right", "param": "Сумма" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Итого",
|
||||||
|
"rows": [
|
||||||
|
{ "cells": [
|
||||||
|
{ "col": 8, "span": 2, "style": "total-right", "text": "Итого:" },
|
||||||
|
{ "col": 10, "style": "total-right", "param": "Всего" }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Верхний уровень
|
||||||
|
|
||||||
|
| Поле | Обяз. | По умолч. | Описание |
|
||||||
|
|------|:-----:|-----------|----------|
|
||||||
|
| `columns` | да | — | Количество колонок |
|
||||||
|
| `page` | нет | — | Формат страницы: `"A4-landscape"` (780), `"A4-portrait"` (540) или число. Автоматически вычисляет `defaultWidth` из суммы пропорций `"Nx"` |
|
||||||
|
| `defaultWidth` | нет | 10 | Ширина колонок по умолчанию. Игнорируется если задан `page` и все колонки используют `"Nx"` |
|
||||||
|
| `columnWidths` | нет | `{}` | Ширины колонок. Ключи 1-based: `"1"`, `"3-14"`, `"5,7,9"`. Значения: число (абсолют) или `"Nx"` (множитель от defaultWidth, напр. `"2x"`, `"0.5x"`) |
|
||||||
|
| `fonts` | нет | — | Именованные шрифты (если не задано, создаётся Arial 10) |
|
||||||
|
| `styles` | нет | `{}` | Именованные стили |
|
||||||
|
| `areas` | да | — | Массив именованных областей (порядок = порядок в документе) |
|
||||||
|
|
||||||
|
## Шрифты (`fonts.<name>`)
|
||||||
|
|
||||||
|
| Поле | По умолч. | Описание |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `face` | `"Arial"` | Имя шрифта |
|
||||||
|
| `size` | `10` | Размер |
|
||||||
|
| `bold` | `false` | Жирный |
|
||||||
|
| `italic` | `false` | Курсив |
|
||||||
|
| `underline` | `false` | Подчёркнутый |
|
||||||
|
| `strikeout` | `false` | Зачёркнутый |
|
||||||
|
|
||||||
|
Шрифт `"default"` используется когда стиль не указывает шрифт явно. Если не определён, создаётся автоматически (Arial 10).
|
||||||
|
|
||||||
|
## Стили (`styles.<name>`)
|
||||||
|
|
||||||
|
| Поле | По умолч. | Описание |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `font` | `"default"` | Ссылка на имя шрифта |
|
||||||
|
| `align` | — | `left`, `center`, `right` |
|
||||||
|
| `valign` | — | `top`, `center` |
|
||||||
|
| `border` | — | Стороны рамки: `all`, `top`, `bottom`, `left`, `right`, `none`. Через запятую: `"top,bottom"` |
|
||||||
|
| `borderWidth` | `"thin"` | Толщина рамки: `thin` (1px) или `thick` (2px) |
|
||||||
|
| `wrap` | `false` | Перенос текста |
|
||||||
|
| `format` | — | Формат данных 1С: `"ЧЦ=15; ЧДЦ=2"`, `"ДФ=dd.MM.yyyy"` и т.д. |
|
||||||
|
|
||||||
|
## Области (`areas[]`)
|
||||||
|
|
||||||
|
| Поле | Обяз. | Описание |
|
||||||
|
|------|:-----:|----------|
|
||||||
|
| `name` | да | Имя области для `Макет.ПолучитьОбласть("Имя")` |
|
||||||
|
| `rows` | да | Массив строк |
|
||||||
|
|
||||||
|
## Строки (`rows[]`)
|
||||||
|
|
||||||
|
| Поле | По умолч. | Описание |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `height` | — | Высота строки (если не задана, используется авто) |
|
||||||
|
| `rowStyle` | — | Стиль для ВСЕХ колонок (заполняет пустоты рамками) |
|
||||||
|
| `cells` | `[]` | Массив ячеек |
|
||||||
|
| `empty` | — | Количество подряд идущих пустых строк (заменяет N отдельных `{}`) |
|
||||||
|
|
||||||
|
Строка без `cells` и `rowStyle` → пустая строка. `{ "empty": 3 }` эквивалентно трём `{}`.
|
||||||
|
|
||||||
|
## Ячейки (`cells[]`)
|
||||||
|
|
||||||
|
| Поле | Обяз. | По умолч. | Описание |
|
||||||
|
|------|:-----:|-----------|----------|
|
||||||
|
| `col` | да | — | Позиция колонки (1-based) |
|
||||||
|
| `span` | нет | `1` | Объединение по горизонтали (количество колонок) |
|
||||||
|
| `rowspan` | нет | `1` | Объединение по вертикали (количество строк) |
|
||||||
|
| `style` | нет | rowStyle | Стиль ячейки (переопределяет rowStyle) |
|
||||||
|
| `param` | нет | — | Параметр заполнения |
|
||||||
|
| `detail` | нет | — | Параметр расшифровки (только с `param`) |
|
||||||
|
| `text` | нет | — | Статический текст |
|
||||||
|
| `template` | нет | — | Шаблонный текст с `[Параметр]` |
|
||||||
|
|
||||||
|
### Тип заполнения
|
||||||
|
|
||||||
|
Определяется автоматически по содержимому ячейки:
|
||||||
|
- `param` → fillType=Parameter
|
||||||
|
- `template` → fillType=Template
|
||||||
|
- `text` → fillType=Text
|
||||||
|
- ничего → без fillType (пустая ячейка или рамка)
|
||||||
|
|
||||||
|
## `rowStyle` — автозаполнение
|
||||||
|
|
||||||
|
Когда задан `rowStyle`, компилятор создаёт ячейки для ВСЕХ колонок строки. Позиции без явных ячеек заполняются пустыми ячейками с указанным стилем. Это обеспечивает сплошные рамки в табличных строках.
|
||||||
|
|
||||||
|
Если в предыдущих строках той же области есть ячейки с `rowspan`, их колонки при автозаполнении пропускаются.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
Текущая версия не поддерживает:
|
||||||
|
- Множественные наборы колонок (`columnsID`)
|
||||||
|
- Области типа Columns / Rectangle
|
||||||
|
- Рисунки (штрихкоды, картинки)
|
||||||
|
- Фон ячеек
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# mxl-compile v1.3 — Compile 1C spreadsheet from JSON
|
# mxl-compile v1.4 — Compile 1C spreadsheet from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -26,6 +26,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -62,10 +72,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# mxl-compile v1.3 — Compile 1C spreadsheet from JSON
|
# mxl-compile v1.4 — Compile 1C spreadsheet from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -36,22 +36,9 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/mxl-decompile.ps1"
|
|||||||
|
|
||||||
Декомпиляция существующего макета для анализа или доработки:
|
Декомпиляция существующего макета для анализа или доработки:
|
||||||
|
|
||||||
1. Claude вызывает `/mxl-decompile` для получения JSON из Template.xml
|
1. Вызвать `/mxl-decompile` для получения JSON из Template.xml
|
||||||
2. Claude анализирует или модифицирует JSON (добавляет области, меняет стили)
|
2. Проанализировать или изменить JSON (добавить области, поменять стили)
|
||||||
3. Claude вызывает `/mxl-compile` для генерации нового Template.xml
|
3. Вызвать `/mxl-compile` для генерации нового Template.xml
|
||||||
4. Claude вызывает `/mxl-validate` для проверки
|
4. Вызвать `/mxl-validate` для проверки
|
||||||
|
|
||||||
## JSON-схема DSL
|
Формат JSON на выходе — тот же DSL, что принимает `/mxl-compile`; его полное описание живёт в навыке `/mxl-compile`.
|
||||||
|
|
||||||
Полная спецификация формата: **`docs/mxl-dsl-spec.md`** (прочитать через Read tool).
|
|
||||||
|
|
||||||
## Генерация имён
|
|
||||||
|
|
||||||
Скрипт автоматически генерирует осмысленные имена:
|
|
||||||
|
|
||||||
- **Шрифты**: `default`, `bold`, `header`, `small`, `italic` — или описательные имена по свойствам
|
|
||||||
- **Стили**: `bordered`, `bordered-center`, `bold-right`, `border-top` и т.д. — по комбинации свойств
|
|
||||||
|
|
||||||
## Детектирование `rowStyle`
|
|
||||||
|
|
||||||
Если в строке есть пустые ячейки (без параметров/текста) и все они имеют одинаковый формат — этот формат распознаётся как `rowStyle`, а пустые ячейки исключаются из вывода.
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# mxl-info v1.1 — Analyze 1C spreadsheet structure
|
# mxl-info v1.2 — Analyze 1C spreadsheet structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Alias('Path')]
|
[Alias('Path')]
|
||||||
@@ -321,6 +321,16 @@ if ($Format -eq "json") {
|
|||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -339,8 +349,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -385,7 +397,8 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
$lines = @()
|
$lines = @()
|
||||||
|
|
||||||
$lines += "=== $templateName ==="
|
$lines += "=== $templateName ==="
|
||||||
$lines += "Поддержка: $(Get-SupportStatusForPath $TemplatePath)"
|
$support = Get-SupportStatusForPath $TemplatePath
|
||||||
|
if ($null -ne $support) { $lines += "Поддержка: $support" }
|
||||||
$lines += " Rows: $docHeight, Columns: $defaultColCount"
|
$lines += " Rows: $docHeight, Columns: $defaultColCount"
|
||||||
|
|
||||||
if ($columnSets.Count -eq 0) {
|
if ($columnSets.Count -eq 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# mxl-info v1.1 — Analyze 1C spreadsheet structure
|
# mxl-info v1.2 — Analyze 1C spreadsheet structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -321,14 +321,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -380,7 +395,9 @@ def get_support_status_for_path(target_path):
|
|||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
lines.append(f"=== {template_name} ===")
|
lines.append(f"=== {template_name} ===")
|
||||||
lines.append(f"Поддержка: {get_support_status_for_path(template_path)}")
|
_support = get_support_status_for_path(template_path)
|
||||||
|
if _support is not None:
|
||||||
|
lines.append(f"Поддержка: {_support}")
|
||||||
lines.append(f" Rows: {doc_height}, Columns: {default_col_count}")
|
lines.append(f" Rows: {doc_height}, Columns: {default_col_count}")
|
||||||
|
|
||||||
if len(column_sets) == 0:
|
if len(column_sets) == 0:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# role-compile v1.7 — Compile 1C role from JSON
|
# role-compile v1.8 — Compile 1C role from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -26,6 +26,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -62,10 +72,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# role-compile v1.7 — Compile 1C role from JSON
|
# role-compile v1.8 — Compile 1C role from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# role-info v1.1 — Analyze 1C role rights
|
# role-info v1.2 — Analyze 1C role rights
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$RightsPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$RightsPath,
|
||||||
@@ -145,6 +145,16 @@ foreach ($tpl in $tplNodes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -163,8 +173,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -209,7 +221,8 @@ $header = "=== Role: $roleName"
|
|||||||
if ($roleSynonym) { $header += " --- `"$roleSynonym`"" }
|
if ($roleSynonym) { $header += " --- `"$roleSynonym`"" }
|
||||||
$header += " ==="
|
$header += " ==="
|
||||||
Out $header
|
Out $header
|
||||||
Out "Поддержка: $(Get-SupportStatusForPath $RightsPath)"
|
$support = Get-SupportStatusForPath $RightsPath
|
||||||
|
if ($null -ne $support) { Out "Поддержка: $support" }
|
||||||
Out ""
|
Out ""
|
||||||
|
|
||||||
Out "Properties: setForNewObjects=$setForNew, setForAttributesByDefault=$setForAttrs, independentRightsOfChildObjects=$independentChild"
|
Out "Properties: setForNewObjects=$setForNew, setForAttributesByDefault=$setForAttrs, independentRightsOfChildObjects=$independentChild"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# role-info v1.1 — Analyze 1C role rights
|
# role-info v1.2 — Analyze 1C role rights
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -161,14 +161,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -222,7 +237,9 @@ if role_synonym:
|
|||||||
header += f' --- "{role_synonym}"'
|
header += f' --- "{role_synonym}"'
|
||||||
header += " ==="
|
header += " ==="
|
||||||
out(header)
|
out(header)
|
||||||
out(f"Поддержка: {get_support_status_for_path(rights_path)}")
|
_support = get_support_status_for_path(rights_path)
|
||||||
|
if _support is not None:
|
||||||
|
out(f"Поддержка: {_support}")
|
||||||
out()
|
out()
|
||||||
|
|
||||||
out(f"Properties: setForNewObjects={set_for_new}, setForAttributesByDefault={set_for_attrs}, independentRightsOfChildObjects={independent_child}")
|
out(f"Properties: setForNewObjects={set_for_new}, setForAttributesByDefault={set_for_attrs}, independentRightsOfChildObjects={independent_child}")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-compile v1.107 — Compile 1C DCS from JSON
|
# skd-compile v1.108 — Compile 1C DCS from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[string]$DefinitionFile,
|
[string]$DefinitionFile,
|
||||||
@@ -25,6 +25,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -61,10 +71,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# skd-compile v1.107 — Compile 1C DCS from JSON
|
# skd-compile v1.108 — Compile 1C DCS from JSON
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -31,6 +31,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -70,6 +82,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -77,6 +92,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-edit v1.28 — Atomic 1C DCS editor
|
# skd-edit v1.29 — Atomic 1C DCS editor
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
# NB: парный .py собирает выражения автодат вне f-string ради совместимости с python 3.9 (PEP 701).
|
# NB: парный .py собирает выражения автодат вне f-string ради совместимости с python 3.9 (PEP 701).
|
||||||
param(
|
param(
|
||||||
@@ -64,6 +64,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -100,10 +110,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-edit v1.28 — Atomic 1C DCS editor (Python port)
|
# skd-edit v1.29 — Atomic 1C DCS editor (Python port)
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -141,6 +141,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -180,6 +192,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -187,6 +202,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# skd-info v1.7 — Analyze 1C DCS structure
|
# skd-info v1.8 — Analyze 1C DCS structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -334,6 +334,16 @@ for ($i = $pathParts.Count - 1; $i -ge 0; $i--) {
|
|||||||
|
|
||||||
$totalXmlLines = (Get-Content $resolvedPath).Count
|
$totalXmlLines = (Get-Content $resolvedPath).Count
|
||||||
|
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -352,8 +362,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -395,7 +407,8 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
|
|
||||||
function Show-Overview {
|
function Show-Overview {
|
||||||
$lines.Add("=== DCS: $templateName ($totalXmlLines lines) ===")
|
$lines.Add("=== DCS: $templateName ($totalXmlLines lines) ===")
|
||||||
$lines.Add("Поддержка: $(Get-SupportStatusForPath $TemplatePath)")
|
$support = Get-SupportStatusForPath $TemplatePath
|
||||||
|
if ($null -ne $support) { $lines.Add("Поддержка: $support") }
|
||||||
$lines.Add("")
|
$lines.Add("")
|
||||||
|
|
||||||
# Sources
|
# Sources
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# skd-info v1.7 — Analyze 1C DCS structure
|
# skd-info v1.8 — Analyze 1C DCS structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -278,14 +278,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -424,7 +439,9 @@ def main():
|
|||||||
|
|
||||||
def show_overview():
|
def show_overview():
|
||||||
lines.append(f"=== DCS: {template_name} ({total_xml_lines} lines) ===")
|
lines.append(f"=== DCS: {template_name} ({total_xml_lines} lines) ===")
|
||||||
lines.append(f"Поддержка: {get_support_status_for_path(template_path)}")
|
_support = get_support_status_for_path(template_path)
|
||||||
|
if _support is not None:
|
||||||
|
lines.append(f"Поддержка: {_support}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Sources
|
# Sources
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# subsystem-compile v1.8 — Create 1C subsystem from JSON definition
|
# subsystem-compile v1.9 — Create 1C subsystem from JSON definition
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[string]$DefinitionFile,
|
[string]$DefinitionFile,
|
||||||
@@ -63,6 +63,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -99,10 +109,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# subsystem-compile v1.8 — Create 1C subsystem from JSON definition
|
# subsystem-compile v1.9 — Create 1C subsystem from JSON definition
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
@@ -32,6 +32,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -71,6 +83,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -78,6 +93,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# subsystem-edit v1.5 — Edit existing 1C subsystem XML
|
# subsystem-edit v1.6 — Edit existing 1C subsystem XML
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][Alias('Path')][string]$SubsystemPath,
|
[Parameter(Mandatory)][Alias('Path')][string]$SubsystemPath,
|
||||||
@@ -136,6 +136,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -172,10 +182,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# subsystem-edit v1.5 — Edit existing 1C subsystem XML
|
# subsystem-edit v1.6 — Edit existing 1C subsystem XML
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -32,6 +32,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -71,6 +83,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -78,6 +93,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# subsystem-info v1.1 — Compact summary of 1C subsystem structure
|
# subsystem-info v1.2 — Compact summary of 1C subsystem structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][Alias('Path')][string]$SubsystemPath,
|
[Parameter(Mandatory=$true)][Alias('Path')][string]$SubsystemPath,
|
||||||
@@ -17,6 +17,16 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$script:lines = @()
|
$script:lines = @()
|
||||||
function Out([string]$text) { $script:lines += $text }
|
function Out([string]$text) { $script:lines += $text }
|
||||||
|
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Get-SupportStatusForPath([string]$targetPath) {
|
function Get-SupportStatusForPath([string]$targetPath) {
|
||||||
try {
|
try {
|
||||||
$rp = (Resolve-Path $targetPath).Path
|
$rp = (Resolve-Path $targetPath).Path
|
||||||
@@ -35,8 +45,10 @@ function Get-SupportStatusForPath([string]$targetPath) {
|
|||||||
}
|
}
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return $null }
|
||||||
$d = [System.IO.Path]::GetDirectoryName($rp)
|
$d = [System.IO.Path]::GetDirectoryName($rp)
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return $null }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $binPath) {
|
if (-not $binPath) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
@@ -175,7 +187,8 @@ function Get-SubsystemDir([string]$xmlPath) {
|
|||||||
# --- Show functions for full mode ---
|
# --- Show functions for full mode ---
|
||||||
function Show-Overview {
|
function Show-Overview {
|
||||||
Out "Подсистема: $subName"
|
Out "Подсистема: $subName"
|
||||||
Out "Поддержка: $(Get-SupportStatusForPath $SubsystemPath)"
|
$support = Get-SupportStatusForPath $SubsystemPath
|
||||||
|
if ($null -ne $support) { Out "Поддержка: $support" }
|
||||||
if ($synonym -and $synonym -ne $subName) { Out "Синоним: $synonym" }
|
if ($synonym -and $synonym -ne $subName) { Out "Синоним: $synonym" }
|
||||||
if ($commentText) { Out "Комментарий: $commentText" }
|
if ($commentText) { Out "Комментарий: $commentText" }
|
||||||
Out "ВключатьВКомандныйИнтерфейс: $inclCI"
|
Out "ВключатьВКомандныйИнтерфейс: $inclCI"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# subsystem-info v1.1 — Compact summary of 1C subsystem structure
|
# subsystem-info v1.2 — Compact summary of 1C subsystem structure
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -150,14 +150,29 @@ def get_support_status_for_path(target_path):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
def is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
|
||||||
elem_uuid = root_uuid(rp)
|
elem_uuid = root_uuid(rp)
|
||||||
|
if is_external_root(rp):
|
||||||
|
return None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
d = os.path.dirname(rp)
|
d = os.path.dirname(rp)
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if is_external_root(d + ".xml"):
|
||||||
|
return None
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = root_uuid(d + ".xml")
|
elem_uuid = root_uuid(d + ".xml")
|
||||||
if not bin_path:
|
if not bin_path:
|
||||||
@@ -208,7 +223,9 @@ def get_support_status_for_path(target_path):
|
|||||||
def show_overview(sub_name, synonym, comment_text, incl_ci, use_one_cmd,
|
def show_overview(sub_name, synonym, comment_text, incl_ci, use_one_cmd,
|
||||||
explanation, pic_text, content_items, groups, child_names, has_ci):
|
explanation, pic_text, content_items, groups, child_names, has_ci):
|
||||||
out(f"Подсистема: {sub_name}")
|
out(f"Подсистема: {sub_name}")
|
||||||
out(f"Поддержка: {get_support_status_for_path(subsystem_path)}")
|
_support = get_support_status_for_path(subsystem_path)
|
||||||
|
if _support is not None:
|
||||||
|
out(f"Поддержка: {_support}")
|
||||||
if synonym and synonym != sub_name:
|
if synonym and synonym != sub_name:
|
||||||
out(f"Синоним: {synonym}")
|
out(f"Синоним: {synonym}")
|
||||||
if comment_text:
|
if comment_text:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# template-add v1.7 — Add template to 1C object
|
# template-add v1.8 — Add template to 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -38,6 +38,16 @@ function Get-RootUuid([string]$xmlPath) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
function Test-ExternalObjectRoot([string]$xmlPath) {
|
||||||
|
if (-not (Test-Path $xmlPath)) { return $false }
|
||||||
|
try {
|
||||||
|
[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
|
||||||
|
$el = $mx.DocumentElement.FirstChild
|
||||||
|
while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
|
||||||
|
if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
function Find-V8Project([string]$startDir) {
|
function Find-V8Project([string]$startDir) {
|
||||||
$d = $startDir
|
$d = $startDir
|
||||||
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
for ($i = 0; $i -lt 20 -and $d; $i++) {
|
||||||
@@ -74,10 +84,13 @@ function Assert-EditAllowed([string]$targetPath, [string]$require) {
|
|||||||
try {
|
try {
|
||||||
$rp = $targetPath
|
$rp = $targetPath
|
||||||
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if (Test-ExternalObjectRoot $rp) { return }
|
||||||
$elemUuid = Get-RootUuid $rp
|
$elemUuid = Get-RootUuid $rp
|
||||||
$cfgDir = $null; $binPath = $null
|
$cfgDir = $null; $binPath = $null
|
||||||
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
|
||||||
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
for ($i = 0; $i -lt 12 -and $d; $i++) {
|
||||||
|
if (Test-ExternalObjectRoot "$d.xml") { return }
|
||||||
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
|
||||||
if (-not $cfgDir) {
|
if (-not $cfgDir) {
|
||||||
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# add-template v1.7 — Add template to 1C object
|
# add-template v1.8 — Add template to 1C object
|
||||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -34,6 +34,18 @@ def _sg_root_uuid(xml_path):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sg_is_external_root(xml_path):
|
||||||
|
if not os.path.isfile(xml_path):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
mx = etree.parse(xml_path).getroot()
|
||||||
|
for child in mx:
|
||||||
|
if isinstance(child.tag, str):
|
||||||
|
return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
def _sg_find_v8project(start_dir):
|
def _sg_find_v8project(start_dir):
|
||||||
d = start_dir
|
d = start_dir
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
@@ -73,6 +85,9 @@ def _sg_get_edit_mode(cfg_dir):
|
|||||||
def assert_edit_allowed(target_path, require):
|
def assert_edit_allowed(target_path, require):
|
||||||
try:
|
try:
|
||||||
rp = os.path.abspath(target_path)
|
rp = os.path.abspath(target_path)
|
||||||
|
# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
|
||||||
|
if _sg_is_external_root(rp):
|
||||||
|
return
|
||||||
elem_uuid = _sg_root_uuid(rp)
|
elem_uuid = _sg_root_uuid(rp)
|
||||||
cfg_dir = None
|
cfg_dir = None
|
||||||
bin_path = None
|
bin_path = None
|
||||||
@@ -80,6 +95,8 @@ def assert_edit_allowed(target_path, require):
|
|||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
if not d:
|
if not d:
|
||||||
break
|
break
|
||||||
|
if _sg_is_external_root(d + ".xml"):
|
||||||
|
return
|
||||||
if not elem_uuid:
|
if not elem_uuid:
|
||||||
elem_uuid = _sg_root_uuid(d + ".xml")
|
elem_uuid = _sg_root_uuid(d + ".xml")
|
||||||
if not cfg_dir:
|
if not cfg_dir:
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ node $RUN run <url> script.js # exits when done, no session
|
|||||||
### Interactive mode (step-by-step development)
|
### Interactive mode (step-by-step development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Start session (run_in_background=true, prints JSON when ready)
|
# 1. Start session in the background — `start` stays running as the server, so don't wait on
|
||||||
node $RUN start <url>
|
# its stdout. Poll `status` instead: it exits 0 only once the session is loaded and live.
|
||||||
|
node $RUN start <url> # run_in_background=true
|
||||||
|
until node $RUN status >/dev/null 2>&1; do sleep 2; done # exit 0 = ready
|
||||||
|
|
||||||
# 2. Execute scripts against running session
|
# 2. Execute scripts against running session
|
||||||
cat <<'SCRIPT' | node $RUN exec -
|
cat <<'SCRIPT' | node $RUN exec -
|
||||||
@@ -140,7 +142,7 @@ Returns current form structure. This is the primary way to understand what's on
|
|||||||
|
|
||||||
**openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead.
|
**openTabs** — array of `{ name, active? }` from the open-windows tab bar. Only present when the tab bar is enabled in 1C settings. Do NOT rely on this — use `formCount`/`openForms` instead.
|
||||||
|
|
||||||
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields)
|
**fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields), `disabled?` (control is unavailable). `buttons[]` carry `disabled?` too.
|
||||||
|
|
||||||
**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы").
|
**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы").
|
||||||
|
|
||||||
@@ -189,6 +191,22 @@ Special row fields:
|
|||||||
- `hierarchical: true` — list has groups (on result object)
|
- `hierarchical: true` — list has groups (on result object)
|
||||||
- `viewMode: 'tree'` — tree view active (on result object)
|
- `viewMode: 'tree'` — tree view active (on result object)
|
||||||
|
|
||||||
|
Row state — in object lists, decoded from the row's state icon (no need to add a column to the list):
|
||||||
|
- `_deleted: true|false` — marked for deletion (catalogs, documents, tasks, business processes, charts of accounts/calculation types)
|
||||||
|
- `_posted: true|false` — documents
|
||||||
|
- `_predefined: true|false` — catalogs, charts of accounts/calculation types
|
||||||
|
- `_completed: true|false` — tasks
|
||||||
|
- `_started`, `_finished` — business processes
|
||||||
|
- `_rowPic: '<icon>:<N>'` — raw icon id, for diagnostics
|
||||||
|
|
||||||
|
```js
|
||||||
|
const t = await readTable();
|
||||||
|
const doc = t.rows.find(r => r['Номер'] === 'ТД00-000005');
|
||||||
|
if (doc._deleted === true) { /* marked for deletion */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
**A missing state field means "unknown", never `false`** — the property may not apply (documents have no `_predefined`), or the icon may be unrecognised. So `if (!row._deleted)` is unsafe: it reads "unknown" as "not deleted". Compare explicitly (`=== true` / `=== false`) and treat `undefined` as a third outcome. Rows outside object lists (form tabular sections, value lists) have no state fields at all. If `_rowPic` is present but the booleans aren't, report its value — that icon needs decoding support.
|
||||||
|
|
||||||
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
|
**`total` is misleading for long lists.** 1С virtualizes both dynamic lists and form tabular sections — the DOM holds only a window of visible rows. `total` / `shown` count what's *loaded right now*, not the size of the underlying collection. Use **`hasMore`** to know if there's more data outside the window:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -240,6 +258,8 @@ Sections + all open tabs.
|
|||||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
||||||
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||||
|
|
||||||
|
**Disabled controls throw.** `clickElement`, `fillFields`, and `selectValue` throw `"X" is disabled` on an unavailable control instead of reporting a fake success — check `getFormState().buttons[].disabled` / `fields[].disabled` first.
|
||||||
|
|
||||||
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
- `table` — scope button search to a specific grid's command panel (by name from `tables[]`):
|
||||||
```js
|
```js
|
||||||
await clickElement('Добавить', { table: 'Исходящие' }); // clicks "Добавить" near "Исходящие" grid
|
await clickElement('Добавить', { table: 'Исходящие' }); // clicks "Добавить" near "Исходящие" grid
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ export default {
|
|||||||
// },
|
// },
|
||||||
// defaultContext: 'clerk',
|
// defaultContext: 'clerk',
|
||||||
|
|
||||||
|
// Context-pool / 1C license management (all optional; omit = no cap, default stays open).
|
||||||
|
// maxContexts: 2, // cap on simultaneous 1C sessions; omit for unlimited
|
||||||
|
// contextPolicy: 'reuse', // 'reuse' (keep open within cap) | 'strict' (close after each test)
|
||||||
|
// pinnedContexts: [], // never evicted; defaults to [defaultContext], [] makes default evictable
|
||||||
|
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
screenshot: 'on-failure', // 'every-step' | 'off'
|
screenshot: 'on-failure', // 'every-step' | 'off'
|
||||||
@@ -319,7 +324,9 @@ export default async function({ clerk, manager, step, assert }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state.
|
Close contexts you no longer need (`manager.closeContext('clerk')`) before the next multi-user test starts — frees a 1C web-client license and stops the previous role from holding state. On tight-license stands prefer configuring the pool (`maxContexts` + `contextPolicy` + `pinnedContexts`) over manual per-test closing — the runner then evicts and reuses sessions automatically.
|
||||||
|
|
||||||
|
**Context pool (1C licenses).** With `maxContexts` set, the runner caps simultaneous 1C sessions: before each test it evicts least-recently-used contexts that are neither pinned nor needed, reusing already-open ones. `contextPolicy: 'reuse'` (default) keeps sessions for speed; `'strict'` closes a test's non-pinned contexts right after it. `pinnedContexts` are never evicted (default `[defaultContext]`; set `[]` to make the default context evictable on a tight stand). If the pool can't fit even after eviction, the test fails with a clear `context pool exhausted` error instead of an opaque connection failure.
|
||||||
|
|
||||||
### Failing-test repro
|
### Failing-test repro
|
||||||
|
|
||||||
@@ -370,9 +377,18 @@ node $RUN test tests/<app-name>/ --grep='накладн' #
|
|||||||
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
||||||
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
||||||
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
node $RUN test tests/<app-name>/ --report=- # machine JSON to stdout, progress to stderr
|
||||||
|
node $RUN test tests/<app-name>/ --global-timeout=3600000 # ceiling for the whole run (exit 2)
|
||||||
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
node $RUN test tests/<app-name>/ -- --rebuild-stand # after `--` → hookArgs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Timeouts and hangs.** A test's `timeout` is a contract, not a wish: when it expires the runner probes the
|
||||||
|
context and destroys whatever is wedged, so the run always moves on. The failure carries a verdict — `hang`
|
||||||
|
(browser alive, renderer's JS thread blocked; the context is aborted, its 1C seance released from Node, and
|
||||||
|
the next test recreates it) versus `slow`/`slow-network` (nothing is broken — raise `export const timeout`).
|
||||||
|
A `hang` is never retried. Exit codes: `1` red tests, `2` `--global-timeout` fired (report written, seances
|
||||||
|
released), `3` the shutdown itself wedged. Allure results are written per test as it finishes, so a hang
|
||||||
|
cannot destroy the results collected before it — no external watchdog needed.
|
||||||
|
|
||||||
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
**Output contract.** `test` behaves like a test runner: by default the human report (with the summary as the last line) goes to **stdout** — read the tail of stdout + exit code. The machine report is opt-in via `--report`: `--report=path` writes it to a file (default JSON; XML for `--format=junit`), `--report=-` writes it to stdout while progress moves to stderr. Allure needs `--format=allure` + a directory (`-` is invalid for allure). For detailed triage use `--report=path` or `--report=-`. **In `--report=-` mode never use `2>&1`** — it merges stderr progress into the stdout JSON. (In the default mode there is no JSON in stdout, so `… | tail` is safe.)
|
||||||
|
|
||||||
### Allure static config — `_allure/`
|
### Allure static config — `_allure/`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test browser v1.18 — engine facade: re-exports the public API from engine/*
|
// web-test browser v1.19 — engine facade: re-exports the public API from engine/*
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
* Public API of the web-test engine. Pure re-export facade — no logic here.
|
||||||
@@ -22,6 +22,9 @@ export {
|
|||||||
connect, disconnect, attach, detach, getSession,
|
connect, disconnect, attach, detach, getSession,
|
||||||
createContext, setActiveContext, listContexts, getActiveContext,
|
createContext, setActiveContext, listContexts, getActiveContext,
|
||||||
hasContext, closeContext,
|
hasContext, closeContext,
|
||||||
|
// Unresponsive-context handling (test runner). abortContext is the sanctioned way to
|
||||||
|
// mutate the registry from outside — the `contexts` Map itself stays private.
|
||||||
|
abortContext, probeContext, getContextDiagnostics,
|
||||||
} from './engine/core/session.mjs';
|
} from './engine/core/session.mjs';
|
||||||
|
|
||||||
// ── navigation ────────────────────────────────────────────────────────────
|
// ── navigation ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
|
// web-test cli/commands/run v1.1 — autonomous connect → exec → disconnect (no server)
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@@ -13,7 +13,14 @@ export async function cmdRun(url, fileOrDash) {
|
|||||||
? await readStdin()
|
? await readStdin()
|
||||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||||
|
|
||||||
await browser.connect(url);
|
// Same as cmdStart: a startup blocker is a diagnosis, not a crash. connect() has already
|
||||||
|
// released the seance and closed the browser; a stack trace pointing into session.mjs would
|
||||||
|
// read as an engine bug and send the reader off to debug the wrong thing.
|
||||||
|
try {
|
||||||
|
await browser.connect(url);
|
||||||
|
} catch (e) {
|
||||||
|
die(e.message);
|
||||||
|
}
|
||||||
const result = await executeScript(code);
|
const result = await executeScript(code);
|
||||||
await browser.disconnect();
|
await browser.disconnect();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/commands/start v1.0
|
// web-test cli/commands/start v1.1
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
@@ -10,7 +10,15 @@ import { handleRequest } from '../server.mjs';
|
|||||||
export async function cmdStart(url) {
|
export async function cmdStart(url) {
|
||||||
if (!url) die('Usage: node src/run.mjs start <url>');
|
if (!url) die('Usage: node src/run.mjs start <url>');
|
||||||
|
|
||||||
const state = await browser.connect(url);
|
// A startup blocker (no free 1C licence, publication asking for credentials) is a diagnosis,
|
||||||
|
// not a crash — connect() already released the seance and closed the browser, so print the
|
||||||
|
// message and leave instead of dumping a stack trace that reads like an engine failure.
|
||||||
|
let state;
|
||||||
|
try {
|
||||||
|
state = await browser.connect(url);
|
||||||
|
} catch (e) {
|
||||||
|
die(e.message);
|
||||||
|
}
|
||||||
|
|
||||||
const httpServer = http.createServer(handleRequest);
|
const httpServer = http.createServer(handleRequest);
|
||||||
httpServer.listen(0, '127.0.0.1', () => {
|
httpServer.listen(0, '127.0.0.1', () => {
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
// web-test cli/commands/status v1.0 — check session
|
// web-test cli/commands/status v1.1 — check session (active liveness probe)
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { out } from '../util.mjs';
|
import { out } from '../util.mjs';
|
||||||
import { SESSION_FILE } from '../session.mjs';
|
import { SESSION_FILE, cleanup } from '../session.mjs';
|
||||||
|
|
||||||
export function cmdStatus() {
|
export async function cmdStatus() {
|
||||||
if (!existsSync(SESSION_FILE)) {
|
if (!existsSync(SESSION_FILE)) {
|
||||||
out({ ok: false, message: 'No active session' });
|
out({ ok: false, ready: false, message: 'No active session' });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
||||||
out({ ok: true, ...sess });
|
// The session file is written only after connect() finished, but the server process may have
|
||||||
|
// died since (crash/reboot) and left the file behind. Don't trust the file — probe the
|
||||||
|
// in-process /status endpoint for real liveness.
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`http://127.0.0.1:${sess.port}/status`, { signal: AbortSignal.timeout(2000) });
|
||||||
|
const body = await resp.json();
|
||||||
|
if (body.connected) {
|
||||||
|
out({ ok: true, ready: true, ...sess });
|
||||||
|
} else {
|
||||||
|
out({ ok: false, ready: false, reason: 'browser-disconnected', ...sess });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server unreachable → the file is stale (process gone). Self-heal by removing it so the
|
||||||
|
// next status/start reads clean.
|
||||||
|
cleanup();
|
||||||
|
out({ ok: false, ready: false, reason: 'server-unreachable', ...sess });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// web-test cli/commands/test v1.3 — regression test runner
|
// web-test cli/commands/test v1.8 — regression test runner
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
|
||||||
import { resolve, dirname, basename, relative } from 'path';
|
import { resolve, dirname, basename, relative } from 'path';
|
||||||
import * as browser from '../../browser.mjs';
|
import * as browser from '../../browser.mjs';
|
||||||
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs';
|
import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps, softDeadline } from '../util.mjs';
|
||||||
import { buildContext, buildScopedContext } from '../exec-context.mjs';
|
import { buildContext, buildScopedContext, setErrorShotDir } from '../exec-context.mjs';
|
||||||
import { createAssertions } from '../test-runner/assertions.mjs';
|
import { createAssertions } from '../test-runner/assertions.mjs';
|
||||||
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
import { buildSeverityIndex } from '../test-runner/severity.mjs';
|
||||||
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
|
||||||
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
import { discoverTests, resetState } from '../test-runner/discover.mjs';
|
||||||
|
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs';
|
||||||
|
|
||||||
export async function cmdTest(rawArgs) {
|
export async function cmdTest(rawArgs) {
|
||||||
// Split off everything after `--` — those args belong to user-defined hooks
|
// Split off everything after `--` — those args belong to user-defined hooks
|
||||||
@@ -18,8 +19,30 @@ export async function cmdTest(rawArgs) {
|
|||||||
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs;
|
||||||
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : [];
|
||||||
|
|
||||||
|
// Deadline budgets for the cleanup path. Every one of these calls reaches into Playwright
|
||||||
|
// and can hang forever against a wedged renderer (page.evaluate has no timeout of its own),
|
||||||
|
// so none of them may be awaited bare. A breach is always logged — silent swallowing is what
|
||||||
|
// turned the original incident into a 29-minute mystery.
|
||||||
|
//
|
||||||
|
// These defaults are sized for a light stand. A heavy application legitimately needs more —
|
||||||
|
// override per key via `deadlines: {...}` in webtest.config.mjs rather than editing this.
|
||||||
|
const D = {
|
||||||
|
screenshot: 10000,
|
||||||
|
teardown: 15000,
|
||||||
|
afterEach: 15000,
|
||||||
|
setActive: 5000,
|
||||||
|
resetState: 20000,
|
||||||
|
startRecording: 15000,
|
||||||
|
stopRecording: 40000, // ffmpeg has its own 30s inside
|
||||||
|
closeContext: 20000,
|
||||||
|
disconnect: 30000,
|
||||||
|
hooks: 120000, // afterAll/cleanup only — prepare/beforeAll stay unbounded (see below)
|
||||||
|
abortAll: 30000, // whole abort+cleanup sequence for one hung test
|
||||||
|
probe: 2000,
|
||||||
|
};
|
||||||
|
|
||||||
// Parse flags
|
// Parse flags
|
||||||
const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
const opts = { bail: false, retry: 0, timeout: 30000, globalTimeout: 0, report: null, format: 'json', screenshot: null, reportDir: null, record: false };
|
||||||
let tags = null, grep = null, urlFlag = null;
|
let tags = null, grep = null, urlFlag = null;
|
||||||
const positional = [];
|
const positional = [];
|
||||||
for (const a of ownArgs) {
|
for (const a of ownArgs) {
|
||||||
@@ -29,6 +52,7 @@ export async function cmdTest(rawArgs) {
|
|||||||
else if (a === '--bail') opts.bail = true;
|
else if (a === '--bail') opts.bail = true;
|
||||||
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0;
|
||||||
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000;
|
||||||
|
else if (a.startsWith('--global-timeout=')) opts.globalTimeout = parseInt(a.slice(17)) || 0;
|
||||||
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
else if (a.startsWith('--report=')) opts.report = a.slice(9);
|
||||||
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
else if (a.startsWith('--format=')) opts.format = a.slice(9);
|
||||||
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13);
|
||||||
@@ -87,10 +111,48 @@ export async function cmdTest(rawArgs) {
|
|||||||
}
|
}
|
||||||
if (!url) url = contextSpecs[defaultContextName].url;
|
if (!url) url = contextSpecs[defaultContextName].url;
|
||||||
|
|
||||||
|
// Context-pool config (license management). All three optional; without them the runner keeps
|
||||||
|
// its legacy behavior: default stays open, contexts accumulate, no eviction.
|
||||||
|
// maxContexts — cap on simultaneous 1C sessions (null = unlimited).
|
||||||
|
// contextPolicy — 'reuse' (keep open within the cap) | 'strict' (close a test's non-pinned
|
||||||
|
// contexts right after it, to release licenses ASAP).
|
||||||
|
// pinnedContexts — never evicted by LRU. Defaults to [defaultContext] so today's "default is
|
||||||
|
// never closed between tests" holds; set [] to make default evictable.
|
||||||
|
let maxContexts = null;
|
||||||
|
if (config.maxContexts != null) {
|
||||||
|
if (!Number.isInteger(config.maxContexts) || config.maxContexts < 1) {
|
||||||
|
die(`Invalid maxContexts=${config.maxContexts} (expected a positive integer or omit for unlimited)`);
|
||||||
|
}
|
||||||
|
maxContexts = config.maxContexts;
|
||||||
|
}
|
||||||
|
const contextPolicy = config.contextPolicy == null ? 'reuse' : config.contextPolicy;
|
||||||
|
if (!['reuse', 'strict'].includes(contextPolicy)) {
|
||||||
|
die(`Invalid contextPolicy="${contextPolicy}" (expected 'reuse' or 'strict')`);
|
||||||
|
}
|
||||||
|
const pinnedContexts = Array.isArray(config.pinnedContexts) ? config.pinnedContexts : [defaultContextName];
|
||||||
|
for (const n of pinnedContexts) {
|
||||||
|
if (!contextSpecs[n]) die(`pinnedContexts entry "${n}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`);
|
||||||
|
}
|
||||||
|
const pinnedSet = new Set(pinnedContexts);
|
||||||
|
// LRU usage order — oldest first, freshest last. Drives eviction under a maxContexts cap.
|
||||||
|
const lruOrder = [];
|
||||||
|
|
||||||
// Apply config defaults (CLI flags override)
|
// Apply config defaults (CLI flags override)
|
||||||
if (!tags && config.tags) tags = config.tags;
|
if (!tags && config.tags) tags = config.tags;
|
||||||
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout);
|
||||||
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry);
|
||||||
|
opts.globalTimeout = ownArgs.some(a => a.startsWith('--global-timeout=')) ? opts.globalTimeout : (config.globalTimeout || opts.globalTimeout);
|
||||||
|
|
||||||
|
// Per-key deadline overrides. Defaults suit a light stand; a heavy application may honestly
|
||||||
|
// need longer (a big form's resetState, a slow close). Unknown keys are a typo, not a wish —
|
||||||
|
// fail fast rather than silently ignoring an override the author believed was in effect.
|
||||||
|
if (config.deadlines) {
|
||||||
|
for (const [k, v] of Object.entries(config.deadlines)) {
|
||||||
|
if (!(k in D)) die(`Invalid deadlines.${k} in config (expected one of: ${Object.keys(D).join(', ')})`);
|
||||||
|
if (typeof v !== 'number' || !(v > 0)) die(`Invalid deadlines.${k}=${v} (expected a positive number of ms)`);
|
||||||
|
D[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) {
|
||||||
browser.setPreserveClipboard(false);
|
browser.setPreserveClipboard(false);
|
||||||
}
|
}
|
||||||
@@ -116,6 +178,10 @@ export async function cmdTest(rawArgs) {
|
|||||||
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
: (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
|
||||||
if (opts.screenshot !== 'off') {
|
if (opts.screenshot !== 'off') {
|
||||||
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
try { mkdirSync(reportDir, { recursive: true }); } catch {}
|
||||||
|
// 1C-error screenshots (taken inside the action wrapper) default to a single
|
||||||
|
// fixed file at the skill root — outside reportDir and shared by every test.
|
||||||
|
// Point them at reportDir so each failure keeps its own attachable file.
|
||||||
|
setErrorShotDir(reportDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover test files
|
// Discover test files
|
||||||
@@ -179,10 +245,153 @@ export async function cmdTest(rawArgs) {
|
|||||||
const results = [];
|
const results = [];
|
||||||
let passCount = 0, failCount = 0, skipCount = 0;
|
let passCount = 0, failCount = 0, skipCount = 0;
|
||||||
|
|
||||||
|
// Per-test diagnostics are BUFFERED and flushed right after that test's ✓/✗ line.
|
||||||
|
// A test's cleanup runs before its result is printed, so writing straight to the stream put
|
||||||
|
// `! …` lines ABOVE the test they belong to — i.e. visually under the PREVIOUS test's result.
|
||||||
|
// Anyone reading the log (a model included) attributes them to the wrong test; that misreading
|
||||||
|
// already cost this session a wrong conclusion. Outside a test (hooks, final teardown) there is
|
||||||
|
// nothing to attach to, so lines go straight out.
|
||||||
|
let diagSink = null;
|
||||||
|
const emit = (line) => { if (diagSink) diagSink.push(line); else W.write(line); };
|
||||||
|
const flushDiag = () => {
|
||||||
|
if (!diagSink) return;
|
||||||
|
for (const line of diagSink) W.write(line);
|
||||||
|
diagSink = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded best-effort await: the replacement for `try { await x } catch {}`.
|
||||||
|
* Same tolerance for failure, but a call that never settles can no longer stall the run,
|
||||||
|
* and every breach leaves a visible line instead of a silent 29-minute stall.
|
||||||
|
*/
|
||||||
|
async function bounded(promise, ms, label) {
|
||||||
|
const r = await softDeadline(promise, ms, label);
|
||||||
|
if (!r.ok) emit(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset one context between tests — and only reuse it if the reset actually WORKED.
|
||||||
|
*
|
||||||
|
* Reusing a context whose UI was not cleaned leaks someone else's open form into the next test:
|
||||||
|
* silent drift instead of a visible error, the worst possible outcome. Two ways to end up there,
|
||||||
|
* and both must lead here:
|
||||||
|
* - the reset breached its deadline (badly-sized budget, wedged page);
|
||||||
|
* - the reset ran to completion but did not clean anything (a modal that refuses to close) —
|
||||||
|
* this one used to pass as success, because `bounded` only reports timeouts and throws.
|
||||||
|
* Either way: destroy the slot, ensureContext recreates a clean one. The cost is a relaunch,
|
||||||
|
* never a wrong test result.
|
||||||
|
*/
|
||||||
|
async function resetOrAbort(cn, ctx) {
|
||||||
|
const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`);
|
||||||
|
if (!sw.ok) return false;
|
||||||
|
const r = await bounded(resetState(ctx), D.resetState, `resetState(${cn})`);
|
||||||
|
if (r.ok && r.value?.clean) return true;
|
||||||
|
|
||||||
|
if (r.ok) {
|
||||||
|
// Name what stayed open — otherwise the next investigation starts from archaeology.
|
||||||
|
const v = r.value || {};
|
||||||
|
const what = v.title ? `"${v.title}"` : `#${v.form}`;
|
||||||
|
emit(` ! resetState(${cn}): not clean — form ${what}${v.modal ? ' (modal)' : ''} still open` +
|
||||||
|
` after ${v.attempts} close attempt(s)` +
|
||||||
|
`${v.lastError ? `, last error: ${v.lastError.message.split('\n')[0]}` : ''}\n`);
|
||||||
|
}
|
||||||
|
emit(` ! context "${cn}" left dirty — aborting it, the next test gets a fresh one\n`);
|
||||||
|
await bounded(browser.abortContext(cn), D.closeContext, `abortContext(${cn})`);
|
||||||
|
dropLru(lruOrder, cn);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bumped for every attempt. A timed-out test's body keeps running — a promise cannot be
|
||||||
|
// cancelled, so when its pending call finally rejects, its own `finally` would go on to
|
||||||
|
// drive the UI of whichever test is running by then. Silent cross-test corruption.
|
||||||
|
//
|
||||||
|
// The run shares one `ctx` object (hooks hold it too), so an epoch stamped on ctx could not
|
||||||
|
// tell the zombie from the live caller — both are the same object. Each attempt therefore
|
||||||
|
// gets its own Proxy view bound to its epoch; calls through a stale view throw.
|
||||||
|
let abortEpoch = 0;
|
||||||
|
function makeTestCtx(base, epoch) {
|
||||||
|
return new Proxy(base, {
|
||||||
|
get(target, prop, recv) {
|
||||||
|
const v = Reflect.get(target, prop, recv);
|
||||||
|
if (typeof v !== 'function') return v;
|
||||||
|
return (...args) => {
|
||||||
|
if (epoch !== abortEpoch) {
|
||||||
|
throw new Error(`test abandoned (timeout) — blocked a late ${String(prop)}() call from its body; it would have hit the next test`);
|
||||||
|
}
|
||||||
|
return v.apply(target, args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReport(state) {
|
||||||
|
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||||
|
return {
|
||||||
|
runner: 'web-test', url, startedAt, finishedAt: new Date().toISOString(),
|
||||||
|
state,
|
||||||
|
duration: totalDuration,
|
||||||
|
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
||||||
|
tests: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let allureWritten = false;
|
||||||
|
/**
|
||||||
|
* Record a finished test AND persist it immediately. The report used to be written only
|
||||||
|
* after the loop, so a single hang destroyed every result collected so far.
|
||||||
|
* writeAllure([tr]) is byte-identical to the batch call: it mints its own uuid per test and
|
||||||
|
* severityIndex is read-only.
|
||||||
|
*/
|
||||||
|
function recordResult(tr) {
|
||||||
|
results.push(tr);
|
||||||
|
if (opts.format === 'allure') {
|
||||||
|
try { writeAllure([tr], reportDir, severityIndex); allureWritten = true; } catch (e) { W.write(` ! allure write: ${e.message}\n`); }
|
||||||
|
} else if (opts.format === 'json' && opts.report && !reportToStdout) {
|
||||||
|
try { writeFileSync(resolve(opts.report), JSON.stringify(buildReport('partial'), null, 2)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`);
|
||||||
const hookEnv = { hookArgs, log: hookLog, config };
|
const hookEnv = { hookArgs, log: hookLog, config };
|
||||||
|
// Deliberately unbounded and allowed to throw: prepare() rebuilds the stand (db-create +
|
||||||
|
// load + update), whose honest duration depends on the application's size — a deadline here
|
||||||
|
// would cut a legitimate rebuild. And its failure must stay fatal: proceeding into a run
|
||||||
|
// without a stand turns one clear error into a screenful of confusing ones.
|
||||||
if (hooks.prepare) await hooks.prepare(hookEnv);
|
if (hooks.prepare) await hooks.prepare(hookEnv);
|
||||||
|
|
||||||
|
/** Force-release every open context (frees 1C licenses), then drop the browser. */
|
||||||
|
async function shutdownAll() {
|
||||||
|
for (const name of browser.listContexts()) {
|
||||||
|
await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||||
|
}
|
||||||
|
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wall-clock ceiling for the whole run. This works even while a test is wedged: a pending
|
||||||
|
* Playwright await does not block the event loop, it is merely an unsettled promise — which
|
||||||
|
* is precisely why the original incident stalled quietly instead of crashing.
|
||||||
|
* Report first (that's what the user needs), hygiene second, exit unconditionally.
|
||||||
|
*/
|
||||||
|
let globalTimer = null;
|
||||||
|
let hardStopping = false;
|
||||||
|
async function hardStop(reason) {
|
||||||
|
if (hardStopping) return;
|
||||||
|
hardStopping = true;
|
||||||
|
W.write(`\n!! ${reason}: run exceeded --global-timeout=${opts.globalTimeout}ms — forcing shutdown\n`);
|
||||||
|
abortEpoch++;
|
||||||
|
// Last-resort exit if the shutdown itself wedges. Referenced on purpose: it must survive.
|
||||||
|
const bailout = setTimeout(() => process.exit(3), 20000);
|
||||||
|
try { writeFinalReport('aborted'); } catch (e) { W.write(` ! report: ${e.message}\n`); }
|
||||||
|
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||||
|
clearTimeout(bailout);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
if (opts.globalTimeout > 0) {
|
||||||
|
globalTimer = setTimeout(() => { void hardStop('global-timeout'); }, opts.globalTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy context creation
|
// Lazy context creation
|
||||||
async function ensureContext(name) {
|
async function ensureContext(name) {
|
||||||
if (browser.hasContext(name)) return;
|
if (browser.hasContext(name)) return;
|
||||||
@@ -210,8 +419,25 @@ export async function cmdTest(rawArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Connect: create default context up front
|
// Connect: create default context up front (hosts beforeAll / hooks). It is NOT permanently
|
||||||
await ensureContext(defaultContextName);
|
// pinned — under a maxContexts cap it becomes an LRU eviction candidate unless it is listed in
|
||||||
|
// pinnedContexts. Register it in the LRU order.
|
||||||
|
//
|
||||||
|
// This one call needs its own catch: it sits in a try that has only a `finally`, and run.mjs
|
||||||
|
// does not wrap cmdTest — so a throw here would escape as a raw stack trace and skip the
|
||||||
|
// report entirely. A blocked startup (e.g. no free 1C licence) dooms the whole run anyway,
|
||||||
|
// so say it once, keep the report, and leave.
|
||||||
|
try {
|
||||||
|
await ensureContext(defaultContextName);
|
||||||
|
} catch (e) {
|
||||||
|
W.write(`\n!! cannot open context "${defaultContextName}": ${e.message}\n\n`);
|
||||||
|
try { writeFinalReport('aborted'); } catch {}
|
||||||
|
// process.exit skips the `finally` below, and killing the process does NOT release a 1C
|
||||||
|
// seance — so release what we hold explicitly before leaving.
|
||||||
|
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
touchLru(lruOrder, defaultContextName);
|
||||||
|
|
||||||
const ctx = buildContext({ noRecord: false });
|
const ctx = buildContext({ noRecord: false });
|
||||||
ctx.assert = createAssertions();
|
ctx.assert = createAssertions();
|
||||||
@@ -230,6 +456,8 @@ export async function cmdTest(rawArgs) {
|
|||||||
let testIdx = 0;
|
let testIdx = 0;
|
||||||
for (const t of filtered) {
|
for (const t of filtered) {
|
||||||
testIdx++;
|
testIdx++;
|
||||||
|
// Buffer this test's diagnostics; they are flushed under its own result line below.
|
||||||
|
diagSink = [];
|
||||||
const declaredContexts = t.contexts && t.contexts.length
|
const declaredContexts = t.contexts && t.contexts.length
|
||||||
? t.contexts
|
? t.contexts
|
||||||
: [t.context || defaultContextName];
|
: [t.context || defaultContextName];
|
||||||
@@ -237,18 +465,57 @@ export async function cmdTest(rawArgs) {
|
|||||||
if (t.skip) {
|
if (t.skip) {
|
||||||
const reason = typeof t.skip === 'string' ? t.skip : '';
|
const reason = typeof t.skip === 'string' ? t.skip : '';
|
||||||
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`);
|
||||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
flushDiag();
|
||||||
|
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null });
|
||||||
skipCount++;
|
skipCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testContextNames = declaredContexts;
|
const testContextNames = declaredContexts;
|
||||||
try {
|
try {
|
||||||
|
// Make room in the license pool before opening this test's contexts. Already-open needed
|
||||||
|
// contexts are reused (ensureContext no-ops); LRU-oldest non-pinned contexts are evicted.
|
||||||
|
const plan = planEviction({
|
||||||
|
open: browser.listContexts(),
|
||||||
|
needed: testContextNames,
|
||||||
|
pinned: pinnedSet,
|
||||||
|
max: maxContexts,
|
||||||
|
lruOrder,
|
||||||
|
});
|
||||||
|
if (plan.error) throw new Error(plan.error);
|
||||||
|
// Needed-but-not-yet-open contexts — also serve as a parking fallback when eviction would
|
||||||
|
// close the sole open context (can't closeContext the active slot with no survivor).
|
||||||
|
const toOpenQueue = testContextNames.filter(n => !browser.hasContext(n));
|
||||||
|
for (const name of plan.toEvict) {
|
||||||
|
if (browser.getActiveContext() === name) {
|
||||||
|
let survivor = browser.listContexts().find(n => n !== name);
|
||||||
|
if (!survivor) {
|
||||||
|
// `name` is the only open context. Open a needed one first to park on — room is
|
||||||
|
// guaranteed because we free `name` right after and multi-context implies max>=2.
|
||||||
|
if (browser.listContexts().length < maxContexts && toOpenQueue.length) {
|
||||||
|
const parkName = toOpenQueue.shift();
|
||||||
|
await ensureContext(parkName);
|
||||||
|
survivor = parkName;
|
||||||
|
} else {
|
||||||
|
throw new Error(`cannot evict "${name}": it is the only open context and maxContexts=${maxContexts} leaves no room to switch. Use maxContexts>=2 when tests alternate contexts.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.setActiveContext(survivor);
|
||||||
|
}
|
||||||
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
|
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||||
|
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
await browser.closeContext(name);
|
||||||
|
dropLru(lruOrder, name);
|
||||||
|
}
|
||||||
for (const cn of testContextNames) await ensureContext(cn);
|
for (const cn of testContextNames) await ensureContext(cn);
|
||||||
await browser.setActiveContext(testContextNames[0]);
|
await browser.setActiveContext(testContextNames[0]);
|
||||||
|
touchLru(lruOrder, testContextNames);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`);
|
||||||
results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
flushDiag();
|
||||||
|
recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null });
|
||||||
failCount++;
|
failCount++;
|
||||||
if (opts.bail) break;
|
if (opts.bail) break;
|
||||||
continue;
|
continue;
|
||||||
@@ -282,7 +549,8 @@ export async function cmdTest(rawArgs) {
|
|||||||
let videoFile = null;
|
let videoFile = null;
|
||||||
if (opts.record) {
|
if (opts.record) {
|
||||||
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`);
|
||||||
try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; }
|
const rec = await bounded(browser.startRecording(videoFile, { force: true }), D.startRecording, 'startRecording');
|
||||||
|
if (!rec.ok) videoFile = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
ctx.log = (...a) => output.push(a.map(String).join(' '));
|
||||||
@@ -323,6 +591,9 @@ export async function cmdTest(rawArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const myEpoch = ++abortEpoch;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
if (hooks.beforeEach) await hooks.beforeEach(ctx);
|
||||||
if (t.setup) await t.setup(ctx);
|
if (t.setup) await t.setup(ctx);
|
||||||
@@ -330,8 +601,8 @@ export async function cmdTest(rawArgs) {
|
|||||||
let timeoutTimer;
|
let timeoutTimer;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
t.fn(ctx, t.param),
|
t.fn(makeTestCtx(ctx, myEpoch), t.param),
|
||||||
new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout); }),
|
new Promise((_, reject) => { timeoutTimer = setTimeout(() => { timedOut = true; reject(new Error(`Timeout (${t.timeout}ms)`)); }, t.timeout); }),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
// Clear the guard timer — otherwise it stays armed in the event loop and,
|
||||||
@@ -340,16 +611,19 @@ export async function cmdTest(rawArgs) {
|
|||||||
clearTimeout(timeoutTimer);
|
clearTimeout(timeoutTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
// Bounded even on the green path: a test can pass and still leave the UI in a state
|
||||||
|
// where resetState wedges — that would stall the run just as dead as a failure would.
|
||||||
|
if (t.teardown) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||||
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps };
|
||||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||||
for (const cn of testContextNames) {
|
for (const cn of testContextNames) {
|
||||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
if (!browser.hasContext(cn)) continue;
|
||||||
|
await resetOrAbort(cn, ctx);
|
||||||
}
|
}
|
||||||
for (const k of scopedKeys) delete ctx[k];
|
for (const k of scopedKeys) delete ctx[k];
|
||||||
|
|
||||||
if (videoFile) {
|
if (videoFile) {
|
||||||
try { await browser.stopRecording(); } catch {}
|
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||||
}
|
}
|
||||||
const dur = elapsed(t0);
|
const dur = elapsed(t0);
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile };
|
||||||
@@ -357,35 +631,123 @@ export async function cmdTest(rawArgs) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
// ── Timeout: diagnose, then destroy what hung. Everything below this point that
|
||||||
let shotFile = e.onecError?.screenshot;
|
// goes through the renderer (screenshot, teardown, resetState) is pointless on a
|
||||||
if (!shotFile && opts.screenshot !== 'off') {
|
// wedged page and would itself hang — so on `hang` we skip straight to the abort.
|
||||||
try {
|
let diagnosis = null;
|
||||||
const png = await browser.screenshot();
|
if (timedOut) {
|
||||||
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
const active = browser.getActiveContext();
|
||||||
writeFileSync(shotFile, png);
|
const probe = active ? await browser.probeContext(active, { ms: D.probe }) : null;
|
||||||
} catch {}
|
const diag = active ? browser.getContextDiagnostics(active) : null;
|
||||||
|
const verdict = !probe ? 'no-context'
|
||||||
|
: !probe.browserAlive ? 'browser-dead'
|
||||||
|
: !probe.rendererAlive ? 'hang'
|
||||||
|
: diag?.net.inFlight > 0 ? 'slow-network'
|
||||||
|
: 'slow';
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`verdict: ${verdict}` + (verdict === 'hang' ? ' (renderer unresponsive, browser alive)' : ''),
|
||||||
|
` context "${active}" [${diag?.isolation}] · renderer probe: ${probe?.rendererAlive ? `ok in ${probe.rendererMs}ms` : `timed out at ${D.probe}ms`}` +
|
||||||
|
` · browser probe: ${probe?.browserAlive ? `ok in ${probe.browserMs}ms` : `timed out at ${D.probe}ms`}`,
|
||||||
|
` network: ${diag?.net.inFlight} in flight, last event ${diag?.msSinceLastNetEvent != null ? (diag.msSinceLastNetEvent / 1000).toFixed(1) + 's ago' : 'never'}` +
|
||||||
|
` (${diag?.net.requests} req / ${diag?.net.responses} resp)`,
|
||||||
|
];
|
||||||
|
// Same failure, different remedy — say which, or the next person guesses.
|
||||||
|
if (verdict === 'slow' || verdict === 'slow-network') {
|
||||||
|
lines.push(' no hang detected — the test is simply slower than its declared timeout; raise `export const timeout`');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict === 'hang' || verdict === 'browser-dead') {
|
||||||
|
const ab = await bounded(browser.abortContext(active), D.abortAll, 'abortContext');
|
||||||
|
const r = ab.ok ? ab.value : null;
|
||||||
|
lines.push(` recovery: ${r ? `context aborted (logout: ${r.logout}, closed: ${r.closed}${r.escalated ? ', escalated to browser kill' : ''})` : 'abort failed'} — next test recreates it`);
|
||||||
|
if (r?.notes?.length) lines.push(` notes: ${r.notes.join('; ')}`);
|
||||||
|
if (active) dropLru(lruOrder, active);
|
||||||
|
}
|
||||||
|
diagnosis = { verdict, probe, net: diag?.net };
|
||||||
|
e.message = `${e.message} — ${lines[0]}`;
|
||||||
|
output.push(...lines);
|
||||||
|
emit(lines.map(l => ` ${l}\n`).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.teardown) try { await t.teardown(ctx); } catch {}
|
const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead');
|
||||||
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError };
|
|
||||||
|
// Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI.
|
||||||
|
// Skipped on a dead page: it goes through the renderer, so it can only hang.
|
||||||
|
let shotFile = e.onecError?.screenshot;
|
||||||
|
if (!shotFile && opts.screenshot !== 'off' && !dead) {
|
||||||
|
const shot = await bounded(browser.screenshot(), D.screenshot, 'screenshot');
|
||||||
|
if (shot.ok) {
|
||||||
|
try {
|
||||||
|
shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||||
|
writeFileSync(shotFile, shot.value);
|
||||||
|
} catch { shotFile = undefined; }
|
||||||
|
}
|
||||||
|
} else if (shotFile && dirname(resolve(shotFile)) !== reportDir) {
|
||||||
|
// Shot came from a context built before setErrorShotDir (e.g. a server
|
||||||
|
// session started earlier): reporters attach by basename, so anything
|
||||||
|
// outside reportDir is a dead link. Move it in under a unique name.
|
||||||
|
const dest = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`);
|
||||||
|
try {
|
||||||
|
renameSync(resolve(shotFile), dest);
|
||||||
|
shotFile = dest;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
copyFileSync(resolve(shotFile), dest);
|
||||||
|
try { unlinkSync(resolve(shotFile)); } catch {}
|
||||||
|
shotFile = dest;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.teardown && !dead) await bounded(t.teardown(ctx), D.teardown, 'teardown');
|
||||||
|
const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError, diagnosis };
|
||||||
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps };
|
||||||
if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {}
|
if (hooks.afterEach) await bounded(hooks.afterEach(ctx), D.afterEach, 'hooks.afterEach');
|
||||||
for (const cn of testContextNames) {
|
// resetState drives the UI (up to 10 × getFormState + closeForm, all page.evaluate).
|
||||||
try { await browser.setActiveContext(cn); await resetState(ctx); } catch {}
|
// On a dead page it cannot succeed — the slot is already gone anyway.
|
||||||
|
if (!dead) {
|
||||||
|
for (const cn of testContextNames) {
|
||||||
|
if (!browser.hasContext(cn)) continue;
|
||||||
|
await resetOrAbort(cn, ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const k of scopedKeys) delete ctx[k];
|
for (const k of scopedKeys) delete ctx[k];
|
||||||
|
|
||||||
if (videoFile) {
|
if (videoFile) {
|
||||||
try { await browser.stopRecording(); } catch {}
|
await bounded(browser.stopRecording(), D.stopRecording, 'stopRecording');
|
||||||
}
|
}
|
||||||
lastError = errInfo;
|
lastError = errInfo;
|
||||||
const dur = elapsed(t0);
|
const dur = elapsed(t0);
|
||||||
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile };
|
||||||
|
|
||||||
|
// A wedged renderer is not flakiness — retrying just buys another full timeout
|
||||||
|
// plus another abort. Stop after the first hang.
|
||||||
|
if (dead) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push(testResult);
|
// strict policy: release this test's non-pinned contexts right after it (all attempts done),
|
||||||
|
// instead of keeping them for reuse. Frees 1C licenses ASAP on shared/tight stands. Parks
|
||||||
|
// active on a survivor before closing; never closes the sole remaining context.
|
||||||
|
if (contextPolicy === 'strict') {
|
||||||
|
for (const name of testContextNames) {
|
||||||
|
if (pinnedSet.has(name) || !browser.hasContext(name)) continue;
|
||||||
|
if (browser.getActiveContext() === name) {
|
||||||
|
const survivor = browser.listContexts().find(n => n !== name);
|
||||||
|
if (!survivor) continue; // can't close the sole active context — leave it open
|
||||||
|
try { await browser.setActiveContext(survivor); } catch {}
|
||||||
|
}
|
||||||
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
|
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||||
|
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
|
}
|
||||||
|
try { await browser.closeContext(name); } catch {}
|
||||||
|
dropLru(lruOrder, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordResult(testResult);
|
||||||
|
|
||||||
if (testResult.status === 'passed') {
|
if (testResult.status === 'passed') {
|
||||||
passCount++;
|
passCount++;
|
||||||
@@ -398,26 +760,35 @@ export async function cmdTest(rawArgs) {
|
|||||||
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flushDiag();
|
||||||
|
|
||||||
if (opts.bail && testResult.status === 'failed') break;
|
if (opts.bail && testResult.status === 'failed') break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {}
|
// Out of the per-test scope (also on `break`): afterAll and the final teardown have no test
|
||||||
|
// to nest under, so their diagnostics go straight to the stream again.
|
||||||
|
flushDiag();
|
||||||
|
|
||||||
|
if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll');
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
|
clearTimeout(globalTimer);
|
||||||
// Per-context teardown
|
// Per-context teardown
|
||||||
try {
|
try {
|
||||||
const remaining = browser.listContexts();
|
const remaining = browser.listContexts();
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
const survivor = remaining[0];
|
const survivor = remaining[0];
|
||||||
try { await browser.setActiveContext(survivor); } catch {}
|
await bounded(browser.setActiveContext(survivor), D.setActive, `setActiveContext(${survivor})`);
|
||||||
for (let i = remaining.length - 1; i >= 1; i--) {
|
for (let i = remaining.length - 1; i >= 1; i--) {
|
||||||
const name = remaining[i];
|
const name = remaining[i];
|
||||||
if (hooks.beforeCloseContext && hookCtx) {
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); }
|
||||||
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); }
|
||||||
}
|
}
|
||||||
try { await browser.closeContext(name); }
|
// closeContext goes through the page (logout + close). If it breaches, fall back to
|
||||||
catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); }
|
// abortContext: it logs out from Node, which is the path that survives a dead page.
|
||||||
|
const cc = await bounded(browser.closeContext(name), D.closeContext, `closeContext(${name})`);
|
||||||
|
if (!cc.ok) await bounded(browser.abortContext(name), D.closeContext, `abortContext(${name})`);
|
||||||
}
|
}
|
||||||
if (hooks.beforeCloseContext && hookCtx) {
|
if (hooks.beforeCloseContext && hookCtx) {
|
||||||
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); }
|
||||||
@@ -427,32 +798,35 @@ export async function cmdTest(rawArgs) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`);
|
||||||
}
|
}
|
||||||
try { await browser.disconnect(); } catch {}
|
await bounded(browser.disconnect(), D.disconnect, 'disconnect');
|
||||||
if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {}
|
if (hooks.cleanup) await bounded(hooks.cleanup(hookEnv), D.hooks, 'hooks.cleanup');
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishedAt = new Date().toISOString();
|
|
||||||
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
const totalDuration = results.reduce((s, r) => s + r.duration, 0);
|
||||||
|
|
||||||
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`);
|
||||||
|
|
||||||
const report = {
|
writeFinalReport('complete');
|
||||||
runner: 'web-test', url, startedAt, finishedAt,
|
|
||||||
duration: totalDuration,
|
|
||||||
summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount },
|
|
||||||
tests: results,
|
|
||||||
};
|
|
||||||
if (opts.format === 'allure') {
|
|
||||||
writeAllure(results, reportDir, severityIndex);
|
|
||||||
syncAllureExtras(testDir, reportDir);
|
|
||||||
} else if (opts.format === 'junit') {
|
|
||||||
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
|
||||||
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
|
||||||
} else if (reportToStdout) {
|
|
||||||
out(report);
|
|
||||||
} else if (opts.report) {
|
|
||||||
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failCount > 0) process.exit(1);
|
if (failCount > 0) process.exit(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allure results are already on disk (recordResult writes each test as it finishes), so this
|
||||||
|
* only completes the formats that need whole-run totals. Also called from hardStop, where
|
||||||
|
* `state` is 'aborted' and `results` holds whatever finished before the ceiling hit.
|
||||||
|
*/
|
||||||
|
function writeFinalReport(state) {
|
||||||
|
const report = buildReport(state);
|
||||||
|
if (opts.format === 'allure') {
|
||||||
|
// Guard against a result-producing path that skipped recordResult; normally a no-op.
|
||||||
|
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
|
||||||
|
syncAllureExtras(testDir, reportDir);
|
||||||
|
} else if (opts.format === 'junit') {
|
||||||
|
if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
|
||||||
|
else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
|
||||||
|
} else if (reportToStdout) {
|
||||||
|
out(report);
|
||||||
|
} else if (opts.report) {
|
||||||
|
writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test
|
// web-test cli/exec-context v1.1 — buildContext + executeScript для run/exec/test
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { readFileSync, writeFileSync } from 'fs';
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
import { resolve, dirname } from 'path';
|
import { resolve, dirname } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import * as browser from '../browser.mjs';
|
import * as browser from '../browser.mjs';
|
||||||
import { elapsed } from './util.mjs';
|
import { elapsed, slugify } from './util.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
const DEFAULT_ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png');
|
||||||
|
|
||||||
|
// Where 1C-error screenshots go. The default is a single fixed file: fine for
|
||||||
|
// interactive `exec`/`run` (last error wins, easy to find), wrong for a test run
|
||||||
|
// where every test needs its own file inside reportDir. cmdTest overrides this.
|
||||||
|
let errorShotDir = null;
|
||||||
|
let errorShotSeq = 0;
|
||||||
|
|
||||||
|
export function setErrorShotDir(dir) {
|
||||||
|
errorShotDir = dir;
|
||||||
|
errorShotSeq = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextErrorShotPath(label) {
|
||||||
|
if (!errorShotDir) return DEFAULT_ERROR_SHOT_PATH;
|
||||||
|
return resolve(errorShotDir, `onec-error-${++errorShotSeq}-${slugify(label || 'shot')}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a per-context wrapper: same shape as buildContext output, but every call
|
* Build a per-context wrapper: same shape as buildContext output, but every call
|
||||||
@@ -69,7 +85,7 @@ export function buildContext({ noRecord = false } = {}) {
|
|||||||
let errorShot;
|
let errorShot;
|
||||||
try {
|
try {
|
||||||
const png = await ctx.screenshot();
|
const png = await ctx.screenshot();
|
||||||
errorShot = ERROR_SHOT_PATH;
|
errorShot = nextErrorShotPath(name);
|
||||||
writeFileSync(errorShot, png);
|
writeFileSync(errorShot, png);
|
||||||
} catch {}
|
} catch {}
|
||||||
// Try to fetch call stack for modal errors before throwing
|
// Try to fetch call stack for modal errors before throwing
|
||||||
@@ -127,7 +143,7 @@ export async function executeScript(code, { noRecord } = {}) {
|
|||||||
if (!shotFile) {
|
if (!shotFile) {
|
||||||
try {
|
try {
|
||||||
const png = await browser.screenshot();
|
const png = await browser.screenshot();
|
||||||
shotFile = ERROR_SHOT_PATH;
|
shotFile = nextErrorShotPath('script');
|
||||||
writeFileSync(shotFile, png);
|
writeFileSync(shotFile, png);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// web-test cli/test-runner/context-pool v1.0 — pure context-pool planner (LRU eviction).
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Decides which already-open contexts (each = one live 1C session = one license) to evict
|
||||||
|
// so the next test's declared contexts fit within `maxContexts` simultaneous sessions.
|
||||||
|
// Pure functions, no browser — unit-tested in context-pool.test.mjs.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} p
|
||||||
|
* @param {string[]} p.open currently open context names (live 1C sessions)
|
||||||
|
* @param {string[]} p.needed context names the next test declares
|
||||||
|
* @param {Set<string>|string[]} [p.pinned] never-evict context names
|
||||||
|
* @param {number|null} [p.max] simultaneous-session cap; null/undefined = unlimited
|
||||||
|
* @param {string[]} [p.lruOrder] usage order, oldest first / freshest last
|
||||||
|
* @returns {{ toEvict: string[], error: string|null }}
|
||||||
|
*/
|
||||||
|
export function planEviction({ open = [], needed = [], pinned = [], max = null, lruOrder = [] }) {
|
||||||
|
const pinnedSet = pinned instanceof Set ? pinned : new Set(pinned);
|
||||||
|
const neededSet = new Set(needed);
|
||||||
|
const openSet = new Set(open);
|
||||||
|
|
||||||
|
// Unlimited pool → never evict (back-compat: behaves like the pre-pool runner).
|
||||||
|
if (max == null) return { toEvict: [], error: null };
|
||||||
|
|
||||||
|
// Lower bound that must stay live regardless of eviction: this test's needed contexts, plus
|
||||||
|
// pinned contexts that are ALREADY open (pinned = "don't evict while open", NOT "always open" —
|
||||||
|
// a pinned context that is currently closed does not count against this test's budget).
|
||||||
|
const mustStay = new Set(needed);
|
||||||
|
for (const p of pinnedSet) if (openSet.has(p)) mustStay.add(p);
|
||||||
|
if (mustStay.size > max) {
|
||||||
|
return {
|
||||||
|
toEvict: [],
|
||||||
|
error: `context pool exhausted: this test needs ${mustStay.size} simultaneous 1C sessions `
|
||||||
|
+ `(declared contexts + already-open pinned) but maxContexts=${max}. `
|
||||||
|
+ `Raise maxContexts, reduce declared contexts, or shrink pinnedContexts.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// projected = everything live once we open `needed`. If it already fits, nothing to evict.
|
||||||
|
const projected = new Set([...open, ...needed]);
|
||||||
|
if (projected.size <= max) return { toEvict: [], error: null };
|
||||||
|
|
||||||
|
// Evictable = open, not pinned, not needed — oldest first by lruOrder.
|
||||||
|
const evictable = [];
|
||||||
|
for (const name of lruOrder) {
|
||||||
|
if (openSet.has(name) && !pinnedSet.has(name) && !neededSet.has(name)) evictable.push(name);
|
||||||
|
}
|
||||||
|
// Any open evictable missing from lruOrder → treat as oldest (evict first).
|
||||||
|
for (const name of open) {
|
||||||
|
if (!lruOrder.includes(name) && !pinnedSet.has(name) && !neededSet.has(name)) {
|
||||||
|
evictable.unshift(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toEvict = [];
|
||||||
|
let size = projected.size;
|
||||||
|
for (const name of evictable) {
|
||||||
|
if (size <= max) break;
|
||||||
|
toEvict.push(name);
|
||||||
|
size--;
|
||||||
|
}
|
||||||
|
// Guaranteed size <= max here: after removing all evictable, projected collapses to
|
||||||
|
// (open ∩ pinned) ∪ needed == mustStay, and mustStay.size <= max passed the guard above.
|
||||||
|
return { toEvict, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move `names` to the fresh end of the LRU order (most-recently-used last). Mutates and returns
|
||||||
|
* `lruOrder`. Idempotent per name — existing entries are relocated, not duplicated.
|
||||||
|
*/
|
||||||
|
export function touchLru(lruOrder, names) {
|
||||||
|
for (const n of (Array.isArray(names) ? names : [names])) {
|
||||||
|
const i = lruOrder.indexOf(n);
|
||||||
|
if (i >= 0) lruOrder.splice(i, 1);
|
||||||
|
lruOrder.push(n);
|
||||||
|
}
|
||||||
|
return lruOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove `names` from the LRU order (e.g. after a context is closed). Mutates and returns it. */
|
||||||
|
export function dropLru(lruOrder, names) {
|
||||||
|
for (const n of (Array.isArray(names) ? names : [names])) {
|
||||||
|
const i = lruOrder.indexOf(n);
|
||||||
|
if (i >= 0) lruOrder.splice(i, 1);
|
||||||
|
}
|
||||||
|
return lruOrder;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test cli/test-runner/discover v1.1 — test file discovery + state reset between tests
|
// web-test cli/test-runner/discover v1.3 — test file discovery + state reset between tests
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { existsSync, readdirSync } from 'fs';
|
import { existsSync, readdirSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@@ -29,15 +29,54 @@ export function discoverTests(testPaths) {
|
|||||||
return [...new Set(files)].sort();
|
return [...new Set(files)].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the context to a clean desktop between tests — and REPORT whether that worked.
|
||||||
|
*
|
||||||
|
* The verdict is the point. closeForm does not throw when a form refuses to close: it returns
|
||||||
|
* `{closed:false}`, and this loop used to drop that on the floor, so a context with someone else's
|
||||||
|
* modal still open went back into the pool as "clean" and the next test clicked into it. Measured
|
||||||
|
* on the pilot's stand: 10 idle iterations, `closed:false` every time, state unchanged — and the
|
||||||
|
* runner called it a success.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{clean: boolean, attempts: number, form?: any, title?: string, modal?: boolean, lastError?: Error}>}
|
||||||
|
* `clean:false` also when the check itself failed — not being able to confirm is not being clean.
|
||||||
|
*/
|
||||||
export async function resetState(ctx) {
|
export async function resetState(ctx) {
|
||||||
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {}
|
||||||
|
let attempts = 0;
|
||||||
|
let lastError = null;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
const state = await ctx.getFormState();
|
const state = await ctx.getFormState();
|
||||||
// form === null means no form open (desktop). form === 0 is a real background form
|
// form === null means no form open (desktop). form === 0 is a real background form
|
||||||
// 1C exposes in some states — must still close it to fully reset.
|
// 1C exposes in some states — must still close it to fully reset.
|
||||||
if (state.form == null) break;
|
if (state.form == null) return { clean: true, attempts };
|
||||||
await ctx.closeForm({ save: false });
|
attempts++;
|
||||||
} catch { break; }
|
const r = await ctx.closeForm({ save: false });
|
||||||
|
// The platform found nothing closable → this is the desktop, however many forms sit on it.
|
||||||
|
// Without this the check would be "form == null", which is only true for an EMPTY desktop:
|
||||||
|
// on a real application the home page keeps its own forms (measured: form=5, formCount=3,
|
||||||
|
// no cross), so the old rule declared a perfectly clean context dirty after every test.
|
||||||
|
if (r?.nothingToClose) return { clean: true, attempts, desktop: true };
|
||||||
|
// Deliberately NOT bailing out on `closed:false`: measured A/B on the live suite — a dirty
|
||||||
|
// «Приходная накладная *» reports closed:false on the first round and closes on a later one,
|
||||||
|
// so an early exit aborted a context that was about to be clean. `closed` compares form
|
||||||
|
// numbers, so an intermediate step (a popup going away) reads as "nothing happened" even
|
||||||
|
// though progress was made. The verdict below judges the END state, which is what matters.
|
||||||
|
} catch (e) { lastError = e; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control check — the loop proves nothing on its own: it can also exit via `catch` above.
|
||||||
|
try {
|
||||||
|
const state = await ctx.getFormState();
|
||||||
|
if (state.form == null) return { clean: true, attempts };
|
||||||
|
return {
|
||||||
|
clean: false, attempts, lastError,
|
||||||
|
form: state.form,
|
||||||
|
title: state.activeTab || null,
|
||||||
|
modal: !!state.modal,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { clean: false, attempts, lastError: lastError || e };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
// web-test cli/util v1.2 — generic helpers for CLI commands
|
// web-test cli/util v1.4 — generic helpers for CLI commands
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
|
// Wall-clock bounds live in the engine (session.mjs needs them too, and the engine must not
|
||||||
|
// depend on cli/). Re-exported here so CLI callers have one import site.
|
||||||
|
export { withDeadline, softDeadline, DeadlineError } from '../engine/core/deadline.mjs';
|
||||||
|
|
||||||
export function out(obj) {
|
export function out(obj) {
|
||||||
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
@@ -35,12 +39,29 @@ export function elapsed2(start, stop) {
|
|||||||
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
return Math.round(((stop || Date.now()) - start) / 100) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSLIT = {
|
||||||
|
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i',
|
||||||
|
й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't',
|
||||||
|
у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '',
|
||||||
|
э: 'e', ю: 'yu', я: 'ya',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASCII-only slug for artifact file names (screenshots, videos).
|
||||||
|
* Non-ASCII names are unusable as Allure attachments: the Allure CLI silently
|
||||||
|
* fails to resolve them and emits `"size": 0` with no link to the file
|
||||||
|
* (JAVA_OPTS encoding flags do not help). Cyrillic is transliterated so the
|
||||||
|
* name stays readable; anything else non-ASCII collapses to `-`.
|
||||||
|
*/
|
||||||
export function slugify(s) {
|
export function slugify(s) {
|
||||||
return String(s).trim()
|
const ascii = String(s).trim().toLowerCase()
|
||||||
.replace(/[\s/\\:*?"<>|]+/g, '-')
|
.replace(/[а-яё]/g, ch => TRANSLIT[ch] ?? '-');
|
||||||
|
return ascii
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '')
|
.replace(/^-|-$/g, '')
|
||||||
.slice(0, 60) || 'step';
|
.slice(0, 60)
|
||||||
|
.replace(/^-|-$/g, '') || 'step';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(seconds) {
|
export function formatDuration(seconds) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/
|
// web-test dom v1.20 — facade re-exporting injectable DOM scripts from dom/
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* Facade: re-exports DOM selector & semantic mapping script generators.
|
* Facade: re-exports DOM selector & semantic mapping script generators.
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
detectFormScript,
|
detectFormScript,
|
||||||
|
closeCrossScript,
|
||||||
readFormScript,
|
readFormScript,
|
||||||
findClickTargetScript,
|
findClickTargetScript,
|
||||||
findFieldButtonScript,
|
findFieldButtonScript,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test dom shared v1.2 — embedded JS function constants
|
// web-test dom shared v1.4 — embedded JS function constants
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* Shared function strings embedded into page.evaluate() generators.
|
* Shared function strings embedded into page.evaluate() generators.
|
||||||
@@ -105,6 +105,209 @@ export const HEADERLESS_GRID_FN = `function synthHeaderlessColumns(grid) {
|
|||||||
return cols;
|
return cols;
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for columns of a grid WITH a header — the headed twin of
|
||||||
|
* synthHeaderlessColumns above, and for the same reason: a column name must map to the same
|
||||||
|
* physical cell for readers (readTable) and resolvers (click, row search, filter, fill).
|
||||||
|
*
|
||||||
|
* Column identity is `colindex` — 1С's own column id, present on both header boxes and body
|
||||||
|
* cells. Geometry is the FALLBACK, used only for cells that have no header of their own
|
||||||
|
* (sub-rows of a merged header, e.g. «Субконто Дт» over three stacked cells).
|
||||||
|
*
|
||||||
|
* Why colindex first: a wide header (ERP task list, «Исполнитель» spanning x 1085…1515) covers
|
||||||
|
* the narrow headers below it («Срок» 1085…1251, «Выполнена» 1251…1515). Matching a cell by its
|
||||||
|
* center-x alone puts the «Исполнитель» cell (center 1300) into the «Выполнена» group — which
|
||||||
|
* both fakes a merged header (phantom «Выполнена 1/2») and glues foreign values together.
|
||||||
|
* The write path (grid-edit.mjs) already resolves cells by colindex for exactly this reason.
|
||||||
|
*
|
||||||
|
* Column: { name, text, title, ci, x, right, y, h, fixed, kind?, subIdx? }
|
||||||
|
* - ci — anchor; null for expanded sub-columns (their cells carry a different colindex).
|
||||||
|
* - subIdx — set on «Имя 1/2/3» columns expanded from ONE header over several sub-rows;
|
||||||
|
* such a cell is found by its Y order inside the header's x-range.
|
||||||
|
*/
|
||||||
|
export const COLUMN_MODEL_FN = HEADERLESS_GRID_FN + `
|
||||||
|
function picInfoShared(cell) {
|
||||||
|
if (!cell) return null;
|
||||||
|
if (cell.querySelector('.gridListH, .gridListV, [tree="true"], .gridBoxTree')) return null;
|
||||||
|
const dib = cell.querySelector('.gridBoxImg .dIB');
|
||||||
|
if (!dib) return null;
|
||||||
|
const bg = dib.style.backgroundImage || '';
|
||||||
|
if (!bg.includes('pictureCollection/picture/')) return null;
|
||||||
|
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||||
|
return { gx: m ? m[1] : '0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumnModel(grid) {
|
||||||
|
const head = grid.querySelector('.gridHead');
|
||||||
|
const body = grid.querySelector('.gridBody');
|
||||||
|
const empty = { columns: [], byCi: {}, groups: new Map(), subRows: {}, multiRow: {}, headless: !head };
|
||||||
|
if (!body) return empty;
|
||||||
|
|
||||||
|
if (!head) {
|
||||||
|
const cols = synthHeaderlessColumns(grid).map(c => ({
|
||||||
|
name: c.name, text: c.name, title: '', ci: c.colindex, subTarget: c.subTarget,
|
||||||
|
kind: c.kind, x: 0, right: 0, y: 0, h: 0, fixed: false,
|
||||||
|
}));
|
||||||
|
const byCi = {};
|
||||||
|
cols.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||||
|
return { columns: cols, byCi, groups: new Map(), subRows: {}, multiRow: {}, headless: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headLine = head.querySelector('.gridLine') || head;
|
||||||
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
|
const cellByCi = (line, ci) => [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === ci);
|
||||||
|
const columns = [];
|
||||||
|
|
||||||
|
[...headLine.children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const ci = box.getAttribute('colindex');
|
||||||
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
|
const text = ((textEl || box).innerText || '').trim().replace(/\\n/g, ' ');
|
||||||
|
const title = (box.getAttribute('title') || '').trim();
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
const base = { ci, x: r.x, right: r.x + r.width, y: r.y, h: r.height,
|
||||||
|
fixed: box.classList.contains('gridBoxFix') };
|
||||||
|
if (text) { columns.push(Object.assign(base, { name: text, text: text, title: title })); return; }
|
||||||
|
|
||||||
|
// Unnamed header — a column only if its cells hold a checkbox or a picture. 1С doesn't
|
||||||
|
// expose the technical name, so it is named by the header tooltip.
|
||||||
|
// Sample SEVERAL rows: a picture bound to a Boolean draws nothing for false, so an empty
|
||||||
|
// first row is not evidence that the column has no pictures at all.
|
||||||
|
let kind = null;
|
||||||
|
for (const line of lines.slice(0, 10)) {
|
||||||
|
const cell = ci != null ? cellByCi(line, ci) : null;
|
||||||
|
if (!cell) continue;
|
||||||
|
if (cell.querySelector('.checkbox')) { kind = 'checkbox'; break; }
|
||||||
|
if (picInfoShared(cell)) { kind = 'picture'; break; }
|
||||||
|
}
|
||||||
|
if (!kind && picInfoShared(box)) kind = 'picture';
|
||||||
|
if (!kind) return;
|
||||||
|
let name = kind === 'checkbox' ? '(checkbox)' : (title || '(picture)');
|
||||||
|
if (columns.some(c => c.name === name)) {
|
||||||
|
let n = 2;
|
||||||
|
while (columns.some(c => c.name === name + ' ' + n)) n++;
|
||||||
|
name = name + ' ' + n;
|
||||||
|
}
|
||||||
|
columns.push(Object.assign(base, { name: name, text: '', title: title, kind: kind }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyOf = c => Math.round(c.x) + ':' + Math.round(c.right);
|
||||||
|
const groups = new Map();
|
||||||
|
columns.forEach(c => { const k = keyOf(c); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(c); });
|
||||||
|
for (const hdrs of groups.values()) hdrs.sort((a, b) => a.y - b.y);
|
||||||
|
const byCi = {};
|
||||||
|
columns.forEach(c => { if (c.ci != null && byCi[c.ci] === undefined) byCi[c.ci] = c; });
|
||||||
|
|
||||||
|
// Sub-rows per x-group, measured on the first data line. A cell belongs to the group of its
|
||||||
|
// OWN header whenever colindex says so; only header-less cells are placed geometrically.
|
||||||
|
const subRows = {};
|
||||||
|
if (lines[0]) {
|
||||||
|
[...lines[0].children].forEach(box => {
|
||||||
|
if (box.offsetWidth === 0) return;
|
||||||
|
const ci = box.getAttribute('colindex');
|
||||||
|
const own = ci != null ? byCi[ci] : null;
|
||||||
|
let key = null;
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
if (own) key = keyOf(own);
|
||||||
|
else {
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
for (const [k, hdrs] of groups) {
|
||||||
|
if (cx >= hdrs[0].x && cx < hdrs[0].right) { key = k; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key == null) return;
|
||||||
|
(subRows[key] = subRows[key] || []).push({ y: r.y });
|
||||||
|
});
|
||||||
|
Object.keys(subRows).forEach(k => subRows[k].sort((a, b) => a.y - b.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stacked headers (2+ over several sub-rows) → match by Y order.
|
||||||
|
// ONE header over several sub-rows → merged header: expand into «Имя 1..N».
|
||||||
|
const multiRow = {};
|
||||||
|
for (const [k, hdrs] of groups) {
|
||||||
|
const subs = subRows[k];
|
||||||
|
if (!subs || subs.length <= 1) continue;
|
||||||
|
if (hdrs.length >= 2) { multiRow[k] = hdrs; continue; }
|
||||||
|
const base = hdrs[0];
|
||||||
|
const at = columns.indexOf(base);
|
||||||
|
columns.splice(at, 1);
|
||||||
|
if (base.ci != null && byCi[base.ci] === base) delete byCi[base.ci];
|
||||||
|
const expanded = [];
|
||||||
|
for (let si = 0; si < subs.length; si++) {
|
||||||
|
const col = Object.assign({}, base, {
|
||||||
|
name: base.name + ' ' + (si + 1), ci: null,
|
||||||
|
y: base.y + si, h: base.h / subs.length, subIdx: si,
|
||||||
|
});
|
||||||
|
columns.splice(at + si, 0, col);
|
||||||
|
expanded.push(col);
|
||||||
|
}
|
||||||
|
groups.set(k, expanded);
|
||||||
|
multiRow[k] = expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { columns: columns, byCi: byCi, groups: groups, subRows: subRows, multiRow: multiRow, headless: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cell → column. colindex first; geometry only for cells without a header of their own. */
|
||||||
|
function columnForCell(model, box) {
|
||||||
|
const ci = box.getAttribute('colindex');
|
||||||
|
if (ci != null && model.byCi[ci]) return model.byCi[ci];
|
||||||
|
const r = box.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
const fixed = box.classList.contains('gridBoxFix');
|
||||||
|
for (const k of Object.keys(model.multiRow)) {
|
||||||
|
const hdrs = model.multiRow[k];
|
||||||
|
if (cx < hdrs[0].x || cx >= hdrs[0].right) continue;
|
||||||
|
const subs = model.subRows[k];
|
||||||
|
if (subs) {
|
||||||
|
const si = subs.findIndex(s => Math.abs(s.y - r.y) < 5);
|
||||||
|
if (si >= 0 && si < hdrs.length) return hdrs[si];
|
||||||
|
}
|
||||||
|
let best = hdrs[0], bd = Infinity;
|
||||||
|
for (const h of hdrs) { const d = Math.abs(r.y - h.y); if (d < bd) { bd = d; best = h; } }
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
return model.columns.find(c => cx >= c.x && cx < c.right && c.fixed === fixed) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column → cell inside a given line. Mirror of columnForCell, same precedence. */
|
||||||
|
function cellForColumn(model, line, col) {
|
||||||
|
const boxes = [...line.children].filter(b => b.offsetWidth > 0);
|
||||||
|
if (col.subIdx != null) {
|
||||||
|
const inGroup = boxes
|
||||||
|
.filter(b => {
|
||||||
|
const r = b.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
return cx >= col.x && cx < col.right && b.classList.contains('gridBoxFix') === col.fixed;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y);
|
||||||
|
return inGroup[col.subIdx] || null;
|
||||||
|
}
|
||||||
|
if (col.ci != null) {
|
||||||
|
const hit = boxes.find(b => b.getAttribute('colindex') === col.ci);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return boxes
|
||||||
|
.filter(b => b.classList.contains('gridBoxFix') === col.fixed)
|
||||||
|
.find(b => {
|
||||||
|
const r = b.getBoundingClientRect();
|
||||||
|
const cx = r.x + r.width / 2;
|
||||||
|
return cx >= col.x && cx < col.right;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column by user-supplied name: exact → «Группа / Имя» suffix → substring. */
|
||||||
|
function resolveColumnByName(model, name) {
|
||||||
|
const lo = s => (s || '').toLowerCase().replace(/ё/g, 'е').trim();
|
||||||
|
const cand = c => [c.name, c.text, c.title].filter(Boolean);
|
||||||
|
const n = lo(name);
|
||||||
|
const suffix = lo(' / ' + name);
|
||||||
|
return model.columns.find(c => cand(c).some(t => lo(t) === n))
|
||||||
|
|| model.columns.find(c => cand(c).some(t => lo(t).endsWith(suffix)))
|
||||||
|
|| model.columns.find(c => cand(c).some(t => lo(t).includes(n)))
|
||||||
|
|| null;
|
||||||
|
}`;
|
||||||
|
|
||||||
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
/** Detect active form number. Picks form with most visible elements, skipping form0.
|
||||||
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
* When modalSurface is visible — prefer the highest-numbered form (modal dialog). */
|
||||||
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + `
|
||||||
@@ -214,6 +417,7 @@ function readForm(p) {
|
|||||||
type: 'checkbox'
|
type: 'checkbox'
|
||||||
};
|
};
|
||||||
if (label && label !== name) field.label = label;
|
if (label && label !== name) field.label = label;
|
||||||
|
if (el.classList.contains('checkboxDisabled')) field.disabled = true;
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,6 +453,7 @@ function readForm(p) {
|
|||||||
options: options.map(o => o.label)
|
options: options.map(o => o.label)
|
||||||
};
|
};
|
||||||
if (label && label !== name) field.label = label;
|
if (label && label !== name) field.label = label;
|
||||||
|
if (document.getElementById(p + name)?.classList.contains('radioDisabled')) field.disabled = true;
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,15 +482,21 @@ function readForm(p) {
|
|||||||
const text = nbsp(el.innerText?.trim() || '');
|
const text = nbsp(el.innerText?.trim() || '');
|
||||||
const idName = el.id?.replace(p, '') || '';
|
const idName = el.id?.replace(p, '') || '';
|
||||||
if (!text && !idName) return;
|
if (!text && !idName) return;
|
||||||
buttons.push({ name: text || idName, frame: true });
|
// frameButton disabled uses the same class as a.press buttons (pressDisabled).
|
||||||
|
const btn = { name: text || idName, frame: true };
|
||||||
|
if (el.classList.contains('pressDisabled')) btn.disabled = true;
|
||||||
|
buttons.push(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tumbler items
|
// Tumbler items. Disabled state lives on the group element .frameTumbler
|
||||||
|
// (class tumblerDisabled), not on the individual segments.
|
||||||
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => {
|
||||||
if (el.offsetWidth === 0) return;
|
if (el.offsetWidth === 0) return;
|
||||||
const text = el.innerText?.trim();
|
const text = el.innerText?.trim();
|
||||||
const idName = el.id?.replace(p, '') || '';
|
const idName = el.id?.replace(p, '') || '';
|
||||||
buttons.push({ name: text || idName, tumbler: true });
|
const btn = { name: text || idName, tumbler: true };
|
||||||
|
if (el.closest('.frameTumbler')?.classList.contains('tumblerDisabled')) btn.disabled = true;
|
||||||
|
buttons.push(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tabs — scoped to form by checking ancestor IDs
|
// Tabs — scoped to form by checking ancestor IDs
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList
|
// web-test dom/filter v1.1 — DOM scripts for filterList / unfilterList
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
import { COLUMN_MODEL_FN } from './_shared.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the first grid cell on the form and return its center coords.
|
* Find the first grid cell on the form and return its center coords.
|
||||||
@@ -39,32 +40,26 @@ export function findColumnFirstCellCoordsScript(formNum, field) {
|
|||||||
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')]
|
||||||
.find(g => g.offsetWidth > 0);
|
.find(g => g.offsetWidth > 0);
|
||||||
if (!grid) return { error: 'no_grid' };
|
if (!grid) return { error: 'no_grid' };
|
||||||
|
${COLUMN_MODEL_FN}
|
||||||
const targetField = ${JSON.stringify(field)};
|
const targetField = ${JSON.stringify(field)};
|
||||||
const headers = [...grid.querySelectorAll('.gridHead .gridBox')];
|
|
||||||
let colIndex = -1;
|
|
||||||
let startsWithIdx = -1;
|
|
||||||
let includesIdx = -1;
|
|
||||||
for (let i = 0; i < headers.length; i++) {
|
|
||||||
const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' ');
|
|
||||||
if (!t) continue;
|
|
||||||
const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' ');
|
|
||||||
const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase());
|
|
||||||
if (tl === fl) { colIndex = i; break; }
|
|
||||||
if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; }
|
|
||||||
else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; }
|
|
||||||
}
|
|
||||||
if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx;
|
|
||||||
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
const rows = [...grid.querySelectorAll('.gridBody .gridLine')];
|
||||||
if (!rows.length) return { error: 'no_rows' };
|
if (!rows.length) return { error: 'no_rows' };
|
||||||
if (colIndex < 0) {
|
|
||||||
|
// Column resolution — shared model (dom/_shared.mjs), same as readTable/click. The old
|
||||||
|
// positional index (Nth header box → Nth cell box) breaks on multi-row headers, where
|
||||||
|
// header and cell counts and order differ.
|
||||||
|
const model = buildColumnModel(grid);
|
||||||
|
const col = resolveColumnByName(model, targetField);
|
||||||
|
if (!col) {
|
||||||
|
// Unknown column → click the first cell and let the caller fall back to DLB.
|
||||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
||||||
if (!cells.length) return { error: 'no_cells' };
|
if (!cells.length) return { error: 'no_cells' };
|
||||||
const r = cells[0].getBoundingClientRect();
|
const r0 = cells[0].getBoundingClientRect();
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true };
|
return { x: Math.round(r0.x + r0.width / 2), y: Math.round(r0.y + r0.height / 2), needDlb: true };
|
||||||
}
|
}
|
||||||
const cells = [...rows[0].querySelectorAll('.gridBox')];
|
const cell = cellForColumn(model, rows[0], col);
|
||||||
if (colIndex >= cells.length) return { error: 'cell_not_found' };
|
if (!cell) return { error: 'cell_not_found' };
|
||||||
const r = cells[colIndex].getBoundingClientRect();
|
const r = cell.getBoundingClientRect();
|
||||||
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test dom/forms v1.7 — form detection, content read, click-target/field-button resolution
|
// web-test dom/forms v1.10 — form detection, content read, click-target/field-button resolution
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { DETECT_FORM_FN, READ_FORM_FN, ROW_CLICK_POINT_FN } from './_shared.mjs';
|
import { DETECT_FORM_FN, READ_FORM_FN, ROW_CLICK_POINT_FN } from './_shared.mjs';
|
||||||
|
|
||||||
@@ -13,6 +13,44 @@ export function detectFormScript() {
|
|||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Id of the cross that closes the topmost closable surface, or null when there is nothing to
|
||||||
|
* close — which is exactly how the platform says "this is the desktop": the home page has no
|
||||||
|
* cross at all. That `null` is the reset-between-tests "clean" signal; no baseline snapshot and
|
||||||
|
* no exception list needed. Measured on a live stand:
|
||||||
|
* desktop, 1 form → form=0 formCount=1, no cross
|
||||||
|
* desktop, 3 forms → form=5 formCount=3 openForms=[5,6,7], no cross
|
||||||
|
* list open → cross VW_page2headerTopLine_cmd_CloseButton
|
||||||
|
*
|
||||||
|
* The id must leak out for the caller to click — Escape is not enough: on a real stand it closed
|
||||||
|
* neither a modal nor even a plain list.
|
||||||
|
*
|
||||||
|
* Order matters, and each step was measured:
|
||||||
|
* 1. floating window (`ps<N>`) — a modal sits ON TOP of the form that opened it, so its cross
|
||||||
|
* wins. The index grows per window (ps0 → ps1 → …): a literal `ps0` breaks on the second
|
||||||
|
* modal of a session.
|
||||||
|
* 2. the form's own page cross — works whether or not the tab panel is switched on, which is
|
||||||
|
* why it beats the tab cross. With the panel hidden it is the ONLY path.
|
||||||
|
* 3. the ACTIVE tab's cross — last resort. `select` marks the active tab (same signal as
|
||||||
|
* dom/form-state.mjs); NOT `querySelector('.openedClose')` like closeModals does — that
|
||||||
|
* grabs the first tab in the DOM and would close someone else's form.
|
||||||
|
* Anchored on ids rather than title="Закрыть", so a non-Russian locale keeps working.
|
||||||
|
*/
|
||||||
|
export function closeCrossScript() {
|
||||||
|
return `(() => {
|
||||||
|
const vis = (e) => e.offsetWidth > 0;
|
||||||
|
const crosses = [...document.querySelectorAll('[id*="headerTopLine_cmd_CloseButton"]')].filter(vis);
|
||||||
|
const floating = crosses.filter(e => /ps\\d+headerTopLine_cmd_CloseButton$/.test(e.id));
|
||||||
|
if (floating.length) return floating.pop().id;
|
||||||
|
const page = crosses.filter(e => /^VW_page\\d+headerTopLine_cmd_CloseButton$/.test(e.id));
|
||||||
|
if (page.length) return page.pop().id;
|
||||||
|
const tab = [...document.querySelectorAll('[id^="openedCell_cmd_"]')]
|
||||||
|
.find(e => e.classList.contains('select') && vis(e));
|
||||||
|
const tabCross = tab && tab.querySelector('.openedClose');
|
||||||
|
return (tabCross && vis(tabCross)) ? tabCross.id : null;
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read full form state for a given form number.
|
* Read full form state for a given form number.
|
||||||
* Uses shared READ_FORM_FN.
|
* Uses shared READ_FORM_FN.
|
||||||
@@ -51,6 +89,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
if (!text && !el.classList.contains('pressCommand')) return;
|
if (!text && !el.classList.contains('pressCommand')) return;
|
||||||
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
const isSubmenu = /^(?:Подменю|allActions)/i.test(idName);
|
||||||
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' };
|
||||||
|
if (el.classList.contains('pressDisabled')) item.disabled = true;
|
||||||
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
// Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress)
|
||||||
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; }
|
||||||
items.push(item);
|
items.push(item);
|
||||||
@@ -68,14 +107,19 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
const text = norm(el.innerText);
|
const text = norm(el.innerText);
|
||||||
const idName = el.id.replace(p, '');
|
const idName = el.id.replace(p, '');
|
||||||
if (!text && !idName) return;
|
if (!text && !idName) return;
|
||||||
items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' });
|
const item = { id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' };
|
||||||
|
if (el.classList.contains('pressDisabled')) item.disabled = true;
|
||||||
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tumbler items (toggle switch segments)
|
// Tumbler items (toggle switch segments). Disabled state lives on the group
|
||||||
|
// element .frameTumbler (class tumblerDisabled), not on the segments themselves.
|
||||||
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
[...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => {
|
||||||
const idName = el.id.replace(p, '');
|
const idName = el.id.replace(p, '');
|
||||||
const text = norm(el.innerText);
|
const text = norm(el.innerText);
|
||||||
items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' });
|
const item = { id: el.id, name: text || idName, label: idName, kind: 'tumbler' };
|
||||||
|
if (el.closest('.frameTumbler')?.classList.contains('tumblerDisabled')) item.disabled = true;
|
||||||
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checkboxes (div.checkbox) — match by label or internal name
|
// Checkboxes (div.checkbox) — match by label or internal name
|
||||||
@@ -83,7 +127,9 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
const idName = el.id.replace(p, '');
|
const idName = el.id.replace(p, '');
|
||||||
const titleEl = document.getElementById(p + idName + '#title_text');
|
const titleEl = document.getElementById(p + idName + '#title_text');
|
||||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||||
items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' });
|
const item = { id: el.id, name: label || idName, label: idName, kind: 'checkbox' };
|
||||||
|
if (el.classList.contains('checkboxDisabled')) item.disabled = true;
|
||||||
|
items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tabs (scoped to form)
|
// Tabs (scoped to form)
|
||||||
@@ -136,7 +182,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target);
|
||||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target));
|
||||||
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.disabled) res.disabled = true; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; }
|
||||||
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
// Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить)
|
||||||
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
const gridName = gridEl.id ? gridEl.id.replace(p, '') : '';
|
||||||
if (gridName) {
|
if (gridName) {
|
||||||
@@ -144,7 +190,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
let pf = prefixItems.find(i => i.name.toLowerCase() === target);
|
||||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target));
|
||||||
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target));
|
||||||
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.disabled) res.disabled = true; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall through to unscoped search
|
// Fall through to unscoped search
|
||||||
@@ -164,6 +210,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
const res = { id: found.id, kind: found.kind, name: found.name };
|
const res = { id: found.id, kind: found.kind, name: found.name };
|
||||||
|
if (found.disabled) res.disabled = true;
|
||||||
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
if (found.x != null) { res.x = found.x; res.y = found.y; }
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -224,7 +271,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
||||||
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||||
fields.push({ id: el.id, name: idName, label });
|
fields.push({ id: el.id, name: idName, label, disabled: !!el.disabled });
|
||||||
});
|
});
|
||||||
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
||||||
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
||||||
@@ -232,7 +279,7 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
||||||
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||||
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
||||||
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name, ...(ff.disabled ? { disabled: true } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
||||||
@@ -306,6 +353,11 @@ export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB')
|
|||||||
}
|
}
|
||||||
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix };
|
||||||
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId };
|
||||||
|
// Disabled reference field keeps its DLB/CB visible (won't hit button_not_found),
|
||||||
|
// so flag disabled off the field input for selectValue to guard against a silent no-op.
|
||||||
|
const fieldInput = document.getElementById(p + found.name + '_i0')
|
||||||
|
|| document.querySelector('input[id^="' + p + found.name + '_i"]');
|
||||||
|
if (fieldInput?.disabled) result.disabled = true;
|
||||||
return result;
|
return result;
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
@@ -332,6 +384,7 @@ export function resolveFieldsScript(formNum, fields) {
|
|||||||
|| document.getElementById(p + name + '#title_div');
|
|| document.getElementById(p + name + '#title_div');
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
const last = { inputId: el.id, name, label };
|
const last = { inputId: el.id, name, label };
|
||||||
|
if (el.disabled) last.disabled = true;
|
||||||
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true;
|
||||||
const cbEl = document.getElementById(p + name + '_CB');
|
const cbEl = document.getElementById(p + name + '_CB');
|
||||||
if (cbEl?.offsetWidth > 0) {
|
if (cbEl?.offsetWidth > 0) {
|
||||||
@@ -348,7 +401,9 @@ export function resolveFieldsScript(formNum, fields) {
|
|||||||
const titleEl = document.getElementById(p + name + '#title_text');
|
const titleEl = document.getElementById(p + name + '#title_text');
|
||||||
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, '');
|
||||||
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select');
|
||||||
allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked });
|
const cb = { inputId: el.id, name, label, isCheckbox: true, checked };
|
||||||
|
if (el.classList.contains('checkboxDisabled')) cb.disabled = true;
|
||||||
|
allFields.push(cb);
|
||||||
});
|
});
|
||||||
// Radio button groups — base element = option 0, others are #N#radio
|
// Radio button groups — base element = option 0, others are #N#radio
|
||||||
const radioSeen = new Set();
|
const radioSeen = new Set();
|
||||||
@@ -377,7 +432,10 @@ export function resolveFieldsScript(formNum, fields) {
|
|||||||
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text');
|
||||||
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') });
|
||||||
}
|
}
|
||||||
allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options });
|
const groupEl = document.getElementById(p + groupName);
|
||||||
|
const radio = { inputId: p + groupName, name: groupName, label, isRadio: true, options };
|
||||||
|
if (groupEl?.classList.contains('radioDisabled')) radio.disabled = true;
|
||||||
|
allFields.push(radio);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build DCS pairs: checkbox label → paired value field
|
// Build DCS pairs: checkbox label → paired value field
|
||||||
@@ -413,6 +471,7 @@ export function resolveFieldsScript(formNum, fields) {
|
|||||||
|
|
||||||
if (found) {
|
if (found) {
|
||||||
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label };
|
||||||
|
if (found.disabled) entry.disabled = true;
|
||||||
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; }
|
||||||
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
if (found.isRadio) { entry.isRadio = true; entry.options = found.options; }
|
||||||
if (found.hasSelect) entry.hasSelect = true;
|
if (found.hasSelect) entry.hasSelect = true;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// web-test dom/grid-edit v1.2 — DOM scripts for row-fill (grid edit-time operations)
|
// web-test dom/grid-edit v1.3 — DOM scripts for row-fill (grid edit-time operations)
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
//
|
//
|
||||||
import { HEADERLESS_GRID_FN } from './_shared.mjs';
|
import { HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
|
||||||
//
|
//
|
||||||
// All helpers below accept an optional `gridSelector`. When passed, they target
|
// All helpers below accept an optional `gridSelector`. When passed, they target
|
||||||
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
// that exact grid; when null/undefined they pick the LAST visible `.grid` on
|
||||||
@@ -67,45 +67,28 @@ export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) {
|
|||||||
*/
|
*/
|
||||||
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
||||||
return `(() => {
|
return `(() => {
|
||||||
${HEADERLESS_GRID_FN}
|
${COLUMN_MODEL_FN}
|
||||||
const grid = ${gridResolver(gridSelector)};
|
const grid = ${gridResolver(gridSelector)};
|
||||||
if (!grid) return { error: 'no_grid' };
|
if (!grid) return { error: 'no_grid' };
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
const body = grid.querySelector('.gridBody');
|
||||||
if (!body) return { error: 'no_grid_body' };
|
if (!body) return { error: 'no_grid_body' };
|
||||||
|
|
||||||
// Read columns to find target colindex (+ subTarget for headerless split mark-boxes)
|
// Column resolution — shared model (dom/_shared.mjs): same names and same physical cell
|
||||||
const cols = [];
|
// as readTable/click report, including «Имя 1/2/3» of an expanded merged header.
|
||||||
if (head) {
|
const model = buildColumnModel(grid);
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const t = box.querySelector('.gridBoxText');
|
|
||||||
const ci = box.getAttribute('colindex');
|
|
||||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = ${JSON.stringify(fieldKeys)};
|
const keys = ${JSON.stringify(fieldKeys)};
|
||||||
let targetColindex = null, targetSub = null;
|
let targetCol = null;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const exact = cols.find(c => c.text === key);
|
targetCol = resolveColumnByName(model, key);
|
||||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; break; }
|
if (targetCol) break;
|
||||||
const inc = cols.find(c => c.text.includes(key) || key.includes(c.text));
|
|
||||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; break; }
|
|
||||||
}
|
}
|
||||||
|
const targetSub = targetCol ? (targetCol.subTarget || null) : null;
|
||||||
|
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length };
|
||||||
const line = rows[${row}];
|
const line = rows[${row}];
|
||||||
|
|
||||||
// Find body cell by colindex (reliable across merged headers)
|
let box = targetCol ? cellForColumn(model, line, targetCol) : null;
|
||||||
let box = null;
|
|
||||||
if (targetColindex != null) {
|
|
||||||
box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
|
||||||
}
|
|
||||||
// Fallback: second visible box (skip checkbox/N column)
|
// Fallback: second visible box (skip checkbox/N column)
|
||||||
if (!box) {
|
if (!box) {
|
||||||
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp'));
|
||||||
@@ -134,39 +117,30 @@ export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) {
|
|||||||
*/
|
*/
|
||||||
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
export function findNextCellCoordsByKeyScript(gridSelector, row, key) {
|
||||||
return `(() => {
|
return `(() => {
|
||||||
${HEADERLESS_GRID_FN}
|
${COLUMN_MODEL_FN}
|
||||||
const grid = ${gridResolver(gridSelector)};
|
const grid = ${gridResolver(gridSelector)};
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
const head = grid.querySelector('.gridHead');
|
|
||||||
const body = grid.querySelector('.gridBody');
|
const body = grid.querySelector('.gridBody');
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
const cols = [];
|
const model = buildColumnModel(grid);
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
[...headLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const t = box.querySelector('.gridBoxText');
|
|
||||||
const ci = box.getAttribute('colindex');
|
|
||||||
cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase(), subTarget: null });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
synthHeaderlessColumns(grid).forEach(c => cols.push({ colindex: c.colindex, text: c.name.toLowerCase(), subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
const kl = ${JSON.stringify(key.toLowerCase())};
|
const kl = ${JSON.stringify(key.toLowerCase())};
|
||||||
const klNoSpace = kl.replace(/[\\s\\-]+/g, '');
|
// Extra "no-space/no-dash" fuzz on top of the shared resolver: header «Группа Контрагентов»
|
||||||
let targetColindex = null, targetSub = null;
|
// must match the key «ГруппаКонтрагентов».
|
||||||
const exact = cols.find(c => c.text === kl);
|
let targetCol = resolveColumnByName(model, kl);
|
||||||
if (exact) { targetColindex = exact.colindex; targetSub = exact.subTarget; }
|
if (!targetCol) {
|
||||||
else {
|
const squash = s => (s || '').toLowerCase().replace(/ё/g, 'е').replace(/[\\s\\-]+/g, '');
|
||||||
const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text)
|
const klNoSpace = squash(kl);
|
||||||
|| c.text.includes(klNoSpace) || klNoSpace.includes(c.text));
|
targetCol = model.columns.find(c => {
|
||||||
if (inc) { targetColindex = inc.colindex; targetSub = inc.subTarget; }
|
const t = squash(c.name);
|
||||||
|
return t && (t.includes(klNoSpace) || klNoSpace.includes(t));
|
||||||
|
}) || null;
|
||||||
}
|
}
|
||||||
if (targetColindex == null) return null;
|
if (!targetCol) return null;
|
||||||
|
const targetSub = targetCol.subTarget || null;
|
||||||
const rows = [...body.querySelectorAll('.gridLine')];
|
const rows = [...body.querySelectorAll('.gridLine')];
|
||||||
if (${row} >= rows.length) return null;
|
if (${row} >= rows.length) return null;
|
||||||
const line = rows[${row}];
|
const line = rows[${row}];
|
||||||
const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex);
|
const box = cellForColumn(model, line, targetCol);
|
||||||
if (!box) return null;
|
if (!box) return null;
|
||||||
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
box.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
let cell;
|
let cell;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// web-test dom/grid v1.12 — grid resolution + table reading + edit-time helpers
|
// web-test dom/grid v1.14 — grid resolution + table reading + edit-time helpers
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN } from './_shared.mjs';
|
import { ROW_CLICK_POINT_FN, HEADERLESS_GRID_FN, COLUMN_MODEL_FN } from './_shared.mjs';
|
||||||
|
import { ROW_STATE_FN } from './row-state.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a specific grid by semantic name (table parameter).
|
* Resolve a specific grid by semantic name (table parameter).
|
||||||
@@ -94,7 +95,17 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
const m = bg.match(/[?&]gx=(\\d+)/);
|
const m = bg.match(/[?&]gx=(\\d+)/);
|
||||||
return { gx: m ? m[1] : '0' };
|
return { gx: m ? m[1] : '0' };
|
||||||
}
|
}
|
||||||
${HEADERLESS_GRID_FN}
|
${COLUMN_MODEL_FN}
|
||||||
|
${ROW_STATE_FN}
|
||||||
|
|
||||||
|
// Write the leading row-state sprite onto a row: _rowPic (raw, always) + decoded booleans
|
||||||
|
// (only when the sprite path AND frame are known — absence means "unknown", never false).
|
||||||
|
function applyRowState(row, line) {
|
||||||
|
const st = rowStateInfo(line);
|
||||||
|
if (!st) return;
|
||||||
|
row._rowPic = st.rowPic;
|
||||||
|
if (st.axes) Object.keys(st.axes).forEach(k => { row['_' + k] = st.axes[k]; });
|
||||||
|
}
|
||||||
|
|
||||||
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
// DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells
|
||||||
const head = grid.querySelector('.gridHead');
|
const head = grid.querySelector('.gridHead');
|
||||||
@@ -139,12 +150,13 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
}
|
}
|
||||||
row[c.name] = val;
|
row[c.name] = val;
|
||||||
});
|
});
|
||||||
// Row meta — mirrors the headed path (group/parent/tree/level/selected)
|
// Row meta — mirrors the headed path (group/parent/tree/level/selected/state)
|
||||||
const imgBox = line.querySelector('.gridBoxImg');
|
const imgBox = line.querySelector('.gridBoxImg');
|
||||||
if (imgBox) {
|
if (imgBox) {
|
||||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||||
}
|
}
|
||||||
|
applyRowState(row, line);
|
||||||
const treeBox = line.querySelector('.gridBoxTree');
|
const treeBox = line.querySelector('.gridBoxTree');
|
||||||
if (treeBox) {
|
if (treeBox) {
|
||||||
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
const treeIcon = imgBox?.querySelector('[tree="true"]');
|
||||||
@@ -180,116 +192,10 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract column headers with X-coordinates for alignment
|
// Columns + cell↔column mapping — single source of truth in dom/_shared.mjs.
|
||||||
const columns = [];
|
// colindex first, geometry only for cells without a header of their own.
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
const model = buildColumnModel(grid);
|
||||||
[...headLine.children].forEach(box => {
|
const columns = model.columns;
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
if (!text) {
|
|
||||||
// Unnamed column — check if data cells contain checkboxes or pictures.
|
|
||||||
// Picture columns have no header text (only an icon + a title tooltip); 1С
|
|
||||||
// doesn't expose the technical column name in the DOM, so we name them by
|
|
||||||
// the header's title attribute, falling back to '(picture)'.
|
|
||||||
const firstLine = body?.querySelector('.gridLine');
|
|
||||||
const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0);
|
|
||||||
const idx = visibleHeaders.indexOf(box);
|
|
||||||
const cells = firstLine ? [...firstLine.children].filter(c => c.offsetWidth > 0) : [];
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
if (cells[idx]?.querySelector('.checkbox')) {
|
|
||||||
columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
} else if (picInfo(box) || picInfo(cells[idx])) {
|
|
||||||
let title = (box.getAttribute('title') || '').trim() || '(picture)';
|
|
||||||
// Disambiguate duplicate picture-column names with a numeric suffix.
|
|
||||||
if (columns.some(c => c.text === title)) {
|
|
||||||
let n = 2;
|
|
||||||
while (columns.some(c => c.text === title + ' ' + n)) n++;
|
|
||||||
title = title + ' ' + n;
|
|
||||||
}
|
|
||||||
columns.push({ text: title, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Multi-row grid support: detect stacked/merged headers.
|
|
||||||
// Group headers by X-range. For each group, count data sub-rows from first line.
|
|
||||||
// - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order
|
|
||||||
// - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1")
|
|
||||||
const xGroups = new Map();
|
|
||||||
columns.forEach(c => {
|
|
||||||
const key = Math.round(c.x) + ':' + Math.round(c.right);
|
|
||||||
if (!xGroups.has(key)) xGroups.set(key, []);
|
|
||||||
xGroups.get(key).push(c);
|
|
||||||
});
|
|
||||||
for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y);
|
|
||||||
|
|
||||||
const firstDataLine = body?.querySelector('.gridLine');
|
|
||||||
const subRowMap = new Map();
|
|
||||||
if (firstDataLine) {
|
|
||||||
[...firstDataLine.children].forEach(box => {
|
|
||||||
if (box.offsetWidth === 0) return;
|
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
for (const [key, hdrs] of xGroups) {
|
|
||||||
const h0 = hdrs[0];
|
|
||||||
if (cx >= h0.x && cx < h0.right) {
|
|
||||||
if (!subRowMap.has(key)) subRowMap.set(key, []);
|
|
||||||
subRowMap.get(key).push({ y: r.y });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
const multiRowGroups = new Map();
|
|
||||||
for (const [key, hdrs] of xGroups) {
|
|
||||||
const subs = subRowMap.get(key);
|
|
||||||
if (!subs || subs.length <= 1) continue;
|
|
||||||
if (hdrs.length >= 2) {
|
|
||||||
multiRowGroups.set(key, hdrs);
|
|
||||||
} else if (hdrs.length === 1 && subs.length > 1) {
|
|
||||||
const base = hdrs[0];
|
|
||||||
const baseIdx = columns.indexOf(base);
|
|
||||||
columns.splice(baseIdx, 1);
|
|
||||||
const expanded = [];
|
|
||||||
for (let si = 0; si < subs.length; si++) {
|
|
||||||
const numbered = {
|
|
||||||
text: base.text + ' ' + (si + 1),
|
|
||||||
x: base.x, w: base.w, right: base.right,
|
|
||||||
y: base.y + si, h: base.h / subs.length, _subIdx: si
|
|
||||||
};
|
|
||||||
columns.splice(baseIdx + si, 0, numbered);
|
|
||||||
expanded.push(numbered);
|
|
||||||
}
|
|
||||||
multiRowGroups.set(key, expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchColumn(cellX, cellW, cellY) {
|
|
||||||
const cx = cellX + cellW / 2;
|
|
||||||
for (const [key, hdrs] of multiRowGroups) {
|
|
||||||
const h0 = hdrs[0];
|
|
||||||
if (cx >= h0.x && cx < h0.right) {
|
|
||||||
const subs = subRowMap.get(key);
|
|
||||||
if (subs) {
|
|
||||||
const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5);
|
|
||||||
if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx];
|
|
||||||
}
|
|
||||||
let best = hdrs[0], bestDist = Infinity;
|
|
||||||
for (const h of hdrs) {
|
|
||||||
const dist = Math.abs(cellY - h.y);
|
|
||||||
if (dist < bestDist) { bestDist = dist; best = h; }
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return columns.find(c => cx >= c.x && cx < c.right);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract data rows from gridBody
|
// Extract data rows from gridBody
|
||||||
const allLines = body.querySelectorAll('.gridLine');
|
const allLines = body.querySelectorAll('.gridLine');
|
||||||
@@ -300,7 +206,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
const line = allLines[i];
|
const line = allLines[i];
|
||||||
if (!line) break;
|
if (!line) break;
|
||||||
const row = {};
|
const row = {};
|
||||||
columns.forEach(c => { row[c.text] = ''; });
|
columns.forEach(c => { row[c.name] = ''; });
|
||||||
[...line.children].forEach(box => {
|
[...line.children].forEach(box => {
|
||||||
if (box.offsetWidth === 0) return;
|
if (box.offsetWidth === 0) return;
|
||||||
const textEl = box.querySelector('.gridBoxText');
|
const textEl = box.querySelector('.gridBoxText');
|
||||||
@@ -318,11 +224,9 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
else return;
|
else return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Match cell to column by X+Y overlap (multi-row aware)
|
const col = columnForCell(model, box);
|
||||||
const r = box.getBoundingClientRect();
|
|
||||||
const col = matchColumn(r.x, r.width, r.y);
|
|
||||||
if (col) {
|
if (col) {
|
||||||
row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val;
|
row[col.name] = row[col.name] ? row[col.name] + ' / ' + val : val;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
// Detect row kind: group (gridListH), parent/up (gridListV), or element
|
||||||
@@ -331,6 +235,8 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
if (imgBox.querySelector('.gridListH')) row._kind = 'group';
|
||||||
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
else if (imgBox.querySelector('.gridListV')) row._kind = 'parent';
|
||||||
}
|
}
|
||||||
|
// Leading row-state sprite → _rowPic + decoded booleans
|
||||||
|
applyRowState(row, line);
|
||||||
// Tree mode: detect expand/collapse state and indent level
|
// Tree mode: detect expand/collapse state and indent level
|
||||||
const treeBox = line.querySelector('.gridBoxTree');
|
const treeBox = line.querySelector('.gridBoxTree');
|
||||||
if (treeBox) {
|
if (treeBox) {
|
||||||
@@ -374,7 +280,7 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto
|
|||||||
hasMore = { below: body.scrollHeight > body.clientHeight };
|
hasMore = { below: body.scrollHeight > body.clientHeight };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
const result = { name, columns: columns.map(c => c.name), rows, total, offset: ${offset}, shown: rows.length, hasMore };
|
||||||
if (isTree) result.viewMode = 'tree';
|
if (isTree) result.viewMode = 'tree';
|
||||||
if (hasGroups) result.hierarchical = true;
|
if (hasGroups) result.hierarchical = true;
|
||||||
return result;
|
return result;
|
||||||
@@ -509,7 +415,7 @@ export function getSelectedOrLastRowIndexScript(gridSelector) {
|
|||||||
export function scanGridRowsScript(formNum, search) {
|
export function scanGridRowsScript(formNum, search) {
|
||||||
return `(() => {
|
return `(() => {
|
||||||
${ROW_CLICK_POINT_FN}
|
${ROW_CLICK_POINT_FN}
|
||||||
${HEADERLESS_GRID_FN}
|
${COLUMN_MODEL_FN}
|
||||||
const p = 'form${formNum}_';
|
const p = 'form${formNum}_';
|
||||||
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid');
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
@@ -533,41 +439,14 @@ export function scanGridRowsScript(formNum, search) {
|
|||||||
if (!search || (isObj && !Object.keys(search).length)) {
|
if (!search || (isObj && !Object.keys(search).length)) {
|
||||||
sel = lines[0]; matchKind = 'first';
|
sel = lines[0]; matchKind = 'first';
|
||||||
} else if (isObj) {
|
} else if (isObj) {
|
||||||
// Resolve each key to a header column (fuzzy, normalised) — mirror resolveCol.
|
// Column resolution — shared model (dom/_shared.mjs): colindex first, geometry only
|
||||||
const headLine = grid.querySelector('.gridHead .gridLine') || grid.querySelector('.gridHead');
|
// for cells without a header of their own. Same names as readTable reports.
|
||||||
let headers;
|
const model = buildColumnModel(grid);
|
||||||
if (headLine) {
|
const cellAtCol = (line, col) => cellForColumn(model, line, col);
|
||||||
headers = [...headLine.children]
|
|
||||||
.filter(c => c.offsetWidth > 0)
|
|
||||||
.map(c => {
|
|
||||||
const t = (c.querySelector('.gridBoxText') || c).innerText || '';
|
|
||||||
const title = c.getAttribute('title') || '';
|
|
||||||
const r = c.getBoundingClientRect();
|
|
||||||
return { name: disp(t) || disp(title), text: t, title, x: r.x, right: r.x + r.width };
|
|
||||||
})
|
|
||||||
.filter(h => h.name);
|
|
||||||
} else {
|
|
||||||
// Headerless: synthesized columns anchored by colindex.
|
|
||||||
headers = synthHeaderlessColumns(grid).map(c => ({ name: c.name, text: c.name, title: '', x: 0, right: 0, colindex: c.colindex }));
|
|
||||||
}
|
|
||||||
const resolveCol = name => {
|
|
||||||
const n = norm(name);
|
|
||||||
const cand = h => [h.text, h.title].filter(Boolean);
|
|
||||||
return headers.find(h => cand(h).some(t => norm(t) === n))
|
|
||||||
|| headers.find(h => cand(h).some(t => norm(t).includes(n)));
|
|
||||||
};
|
|
||||||
const cellAtCol = (line, col) => {
|
|
||||||
if (col.colindex != null) return visCells(line).find(b => b.getAttribute('colindex') === col.colindex);
|
|
||||||
return visCells(line).find(b => {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
return cx >= col.x && cx < col.right;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const keys = Object.keys(search);
|
const keys = Object.keys(search);
|
||||||
const cols = {};
|
const cols = {};
|
||||||
for (const k of keys) {
|
for (const k of keys) {
|
||||||
const c = resolveCol(k);
|
const c = resolveColumnByName(model, k);
|
||||||
if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample };
|
if (!c) return { rowCount: lines.length, error: 'filter_column_not_found', column: k, visibleSample };
|
||||||
cols[k] = c;
|
cols[k] = c;
|
||||||
}
|
}
|
||||||
@@ -650,66 +529,22 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
|||||||
const head = grid.querySelector('.gridHead');
|
const head = grid.querySelector('.gridHead');
|
||||||
const body = grid.querySelector('.gridBody');
|
const body = grid.querySelector('.gridBody');
|
||||||
if (!body) return { error: 'no_grid_structure' };
|
if (!body) return { error: 'no_grid_structure' };
|
||||||
${HEADERLESS_GRID_FN}
|
${COLUMN_MODEL_FN}
|
||||||
const isHeadless = !head;
|
const isHeadless = !head;
|
||||||
|
|
||||||
// Header X-ranges (mirror of readTableScript logic, simplified). We also
|
// Columns + name→column + column→cell — single source of truth in dom/_shared.mjs,
|
||||||
// remember whether each header is frozen (gridBoxFix) — frozen and scrollable
|
// shared with readTable, so a name it reports (incl. expanded «Имя 1/2/3») is clickable.
|
||||||
// columns can share X coordinates after horizontal scroll, so cell matching
|
const model = buildColumnModel(grid);
|
||||||
// must respect the frozen/scrollable partition.
|
const headers = model.columns;
|
||||||
let headers;
|
|
||||||
if (head) {
|
|
||||||
const headLine = head.querySelector('.gridLine') || head;
|
|
||||||
headers = [...headLine.children]
|
|
||||||
.filter(c => c.offsetWidth > 0)
|
|
||||||
.map(c => {
|
|
||||||
const textEl = c.querySelector('.gridBoxText');
|
|
||||||
const text = (textEl || c).innerText?.trim().replace(/\\n/g, ' ') || '';
|
|
||||||
// Picture/icon columns have no header text — fall back to the title tooltip
|
|
||||||
// (mirrors readTable naming) so they can still be targeted for clicking.
|
|
||||||
const title = (c.getAttribute('title') || '').trim();
|
|
||||||
const r = c.getBoundingClientRect();
|
|
||||||
return { text, title, name: text || title, x: r.x, right: r.x + r.width, fixed: c.classList.contains('gridBoxFix') };
|
|
||||||
})
|
|
||||||
.filter(h => h.name);
|
|
||||||
} else {
|
|
||||||
// Headerless: synthesized columns anchored by colindex (cellAtColX matches by colindex).
|
|
||||||
headers = synthHeaderlessColumns(grid).map(c => ({ text: c.name, title: '', name: c.name, x: 0, right: 0, fixed: false, colindex: c.colindex, subTarget: c.subTarget }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveCol = (name) => {
|
|
||||||
const suffix = ' / ' + name;
|
|
||||||
const cand = h => [h.text, h.title].filter(Boolean);
|
|
||||||
return headers.find(h => cand(h).some(t => lo(t) === lo(name)))
|
|
||||||
|| headers.find(h => cand(h).some(t => t.endsWith(suffix)))
|
|
||||||
|| headers.find(h => cand(h).some(t => lo(t).includes(lo(name))));
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetCol = ${JSON.stringify(column)};
|
const targetCol = ${JSON.stringify(column)};
|
||||||
const col = resolveCol(targetCol);
|
const col = resolveColumnByName(model, targetCol);
|
||||||
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
if (!col) return { error: 'column_not_found', column: targetCol, available: headers.map(h => h.name) };
|
||||||
|
|
||||||
const lines = [...body.querySelectorAll('.gridLine')];
|
const lines = [...body.querySelectorAll('.gridLine')];
|
||||||
if (lines.length === 0) return { error: 'empty_grid' };
|
if (lines.length === 0) return { error: 'empty_grid' };
|
||||||
|
|
||||||
// Match cell to column by X overlap, but only among cells with the same
|
const cellAtColX = (line, c) => cellForColumn(model, line, c);
|
||||||
// fixed/scrollable kind as the header. After horizontal scroll a scrollable
|
|
||||||
// cell may have the same x as a frozen one — without this guard cellAtColX
|
|
||||||
// would silently return the frozen cell for a scrollable header.
|
|
||||||
const cellAtColX = (line, c) => {
|
|
||||||
// Headerless columns carry colindex → match the body cell directly (robust,
|
|
||||||
// and returns the same box for both logical columns of a combined mark-box).
|
|
||||||
if (c.colindex != null) {
|
|
||||||
return [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === c.colindex);
|
|
||||||
}
|
|
||||||
return [...line.children]
|
|
||||||
.filter(b => b.offsetWidth > 0 && b.classList.contains('gridBoxFix') === c.fixed)
|
|
||||||
.find(b => {
|
|
||||||
const r = b.getBoundingClientRect();
|
|
||||||
const cx = r.x + r.width / 2;
|
|
||||||
return cx >= c.x && cx < c.right;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
const cellText = (b) => norm(b?.querySelector('.gridBoxText')?.innerText || b?.innerText || '');
|
||||||
|
|
||||||
const target = ${JSON.stringify(row)};
|
const target = ${JSON.stringify(row)};
|
||||||
@@ -724,7 +559,7 @@ export function findGridCellScript(formNum, gridSelector, { row, column }) {
|
|||||||
const entries = Object.entries(target);
|
const entries = Object.entries(target);
|
||||||
const colsByKey = {};
|
const colsByKey = {};
|
||||||
for (const [k] of entries) {
|
for (const [k] of entries) {
|
||||||
const c = resolveCol(k);
|
const c = resolveColumnByName(model, k);
|
||||||
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
if (!c) return { error: 'filter_column_not_found', column: k, available: headers.map(h => h.name) };
|
||||||
colsByKey[k] = c;
|
colsByKey[k] = c;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// web-test dom/row-state v1.0 — leading row-state sprite decoding
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
/**
|
||||||
|
* Ведущая иконка состояния строки списка.
|
||||||
|
*
|
||||||
|
* 1С рисует состояние строки (проведён / помечен на удаление / выполнена / …) отдельным спрайтом,
|
||||||
|
* приходящим из `e1cib/convertPicture?url=e1csys/<dir>/<file>.zip&…&gx=<N>`, где `gx` — индекс кадра.
|
||||||
|
* Это НЕ то же самое, что именованные pic-колонки (`pictureCollection/picture/<id>`) — у тех другой
|
||||||
|
* URL и другой смысл (значение ячейки), их разбирает picInfo() в grid.mjs.
|
||||||
|
*
|
||||||
|
* Ключ словаря — ПОЛНЫЙ путь спрайта, не имя файла: `basic/folder.zip` (справочники) и
|
||||||
|
* `accnt/folder.zip` (план счетов) — разные файлы с одинаковым именем и РАЗНОЙ раскладкой gx
|
||||||
|
* (у basic gx=1 — элемент, у accnt gx=1 — предопределённый). Нормализация до имени файла склеила бы
|
||||||
|
* их и дала неверную расшифровку.
|
||||||
|
*
|
||||||
|
* Набор осей свой у каждого спрайта: docList → deleted+posted, folder → deleted+predefined,
|
||||||
|
* Task → deleted+completed. Неприменимая ось не эмитится. Незнакомый путь или нерасшифрованный gx →
|
||||||
|
* только `_rowPic`, без булевых: ОТСУТСТВИЕ БУЛЕВА ЗНАЧИТ «НЕ ЗНАЮ», а не false.
|
||||||
|
*
|
||||||
|
* Раскладки сняты живьём на ERP и подтверждены отрисовкой кадров спрайта (convertPicture&scale).
|
||||||
|
*/
|
||||||
|
export const ROW_STATE_FN = `function rowStateInfo(line) {
|
||||||
|
// Словарь: полный путь → (gx → набор осей). null = кадр не расшифрован (только _rowPic).
|
||||||
|
const SPRITES = {
|
||||||
|
// Документы, журналы документов. Спрайт — СЕТКА: база + 3·пометка_удаления.
|
||||||
|
// База: 0 записан, 1 проведён, 2 «загнутый угол» (не расшифрован).
|
||||||
|
// Кадров ровно 6 (gx 6..8 пустые). gx4 (проведён+помечен) на практике недостижим:
|
||||||
|
// пометка проведённого документа его распроводит.
|
||||||
|
'e1csys/basic/docList.zip': function (gx) {
|
||||||
|
if (gx > 5) return null;
|
||||||
|
const base = gx % 3, deleted = gx >= 3;
|
||||||
|
if (base === 2) return { deleted: deleted }; // база не расшифрована → posted не эмитим
|
||||||
|
return { deleted: deleted, posted: base === 1 };
|
||||||
|
},
|
||||||
|
// Справочники, планы видов характеристик. Ось группа/элемент СОЗНАТЕЛЬНО не выводим —
|
||||||
|
// _kind живёт от .gridListH/.gridListV, на нём завязана иерархия.
|
||||||
|
//
|
||||||
|
// Кадры 4/5 — не «неизвестное состояние», а ВТОРОЕ измерение: у справочника с иерархией
|
||||||
|
// элементов (ERP «Партнеры») родитель — элемент, а не папка, и платформа берёт кадр 4.
|
||||||
|
// Кадры попарно байт-в-байт равны: gx4 ≡ gx1 (чистый элемент), gx5 ≡ gx3 (элемент + красный
|
||||||
|
// крест), gx8 ≡ gx7. Что означает второе измерение — знать не нужно: обе НАШИ оси кадр
|
||||||
|
// задаёт однозначно (крест есть → помечен).
|
||||||
|
'e1csys/basic/folder.zip': {
|
||||||
|
0: { deleted: false, predefined: false }, // группа
|
||||||
|
1: { deleted: false, predefined: false }, // элемент
|
||||||
|
2: { deleted: true, predefined: false }, // группа, помечена
|
||||||
|
3: { deleted: true, predefined: false }, // элемент, помечен
|
||||||
|
4: { deleted: false, predefined: false }, // элемент-узел иерархии (кадр ≡ gx1)
|
||||||
|
5: { deleted: true, predefined: false }, // элемент-узел, помечен (кадр ≡ gx3)
|
||||||
|
6: { deleted: false, predefined: true }, // группа, предопределённая
|
||||||
|
7: { deleted: false, predefined: true }, // элемент, предопределённый
|
||||||
|
8: { deleted: false, predefined: true }, // кадр ≡ gx7
|
||||||
|
},
|
||||||
|
// План счетов — ОТДЕЛЬНЫЙ спрайт с той же basename, но иной раскладкой.
|
||||||
|
'e1csys/accnt/folder.zip': {
|
||||||
|
0: { deleted: false, predefined: false },
|
||||||
|
1: { deleted: false, predefined: true },
|
||||||
|
2: { deleted: true, predefined: false },
|
||||||
|
},
|
||||||
|
// Задачи. Бит-поле: 1·Выполнена + 2·ПометкаУдаления. Сверено с данными на ERP.
|
||||||
|
'e1csys/bp/Task.zip': function (gx) {
|
||||||
|
if (gx > 3) return null;
|
||||||
|
return { completed: (gx & 1) === 1, deleted: (gx & 2) === 2 };
|
||||||
|
},
|
||||||
|
// Бизнес-процессы. Бит-поле: 4·Стартован + 2·Завершён + 1·ПометкаУдаления. Сверено с данными.
|
||||||
|
'e1csys/bp/BusinessProcess.zip': function (gx) {
|
||||||
|
if (gx > 7) return null;
|
||||||
|
return { started: (gx & 4) === 4, finished: (gx & 2) === 2, deleted: (gx & 1) === 1 };
|
||||||
|
},
|
||||||
|
// Планы видов расчёта.
|
||||||
|
'e1csys/calc/calcKindImg.zip': {
|
||||||
|
0: { deleted: false, predefined: false },
|
||||||
|
1: { deleted: true, predefined: false },
|
||||||
|
2: { deleted: false, predefined: true },
|
||||||
|
},
|
||||||
|
// Планы обмена. gx 1, 4..7 не сняты → только _rowPic.
|
||||||
|
'e1csys/backend/DataExchangeImages.zip': {
|
||||||
|
0: { thisNode: false },
|
||||||
|
2: { thisNode: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeUrl(s) {
|
||||||
|
// В DOM путь закодирован ДВАЖДЫ: url=e1csys%252Fbasic%252FdocList.zip
|
||||||
|
let v = s;
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
let d;
|
||||||
|
try { d = decodeURIComponent(v); } catch (e) { break; }
|
||||||
|
if (d === v) break;
|
||||||
|
v = d;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ведущих значков в строке может быть несколько в РАЗНЫХ боксах: вид операции
|
||||||
|
// (pictureCollection, произвольная картинка конфигурации), tree-toggle и состояние.
|
||||||
|
// Поэтому перебираем все .dIB строки, а не берём первый .gridBoxImg.
|
||||||
|
const dibs = line.querySelectorAll('.gridBoxImg .dIB');
|
||||||
|
for (let i = 0; i < dibs.length; i++) {
|
||||||
|
const d = dibs[i];
|
||||||
|
if (d.getAttribute('tree') === 'true') continue;
|
||||||
|
const bg = d.style.backgroundImage || '';
|
||||||
|
if (!bg.includes('convertPicture')) continue;
|
||||||
|
const um = bg.match(/[?&]url=([^&"')]+)/);
|
||||||
|
if (!um) continue;
|
||||||
|
const path = decodeUrl(um[1]);
|
||||||
|
if (!/\\.zip$/.test(path)) continue;
|
||||||
|
const gm = bg.match(/[?&]gx=(\\d+)/);
|
||||||
|
const gx = gm ? parseInt(gm[1], 10) : 0;
|
||||||
|
|
||||||
|
const entry = SPRITES[path];
|
||||||
|
const axes = typeof entry === 'function' ? entry(gx) : (entry ? entry[gx] : null);
|
||||||
|
return { rowPic: path + ':' + gx, axes: axes || null };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}`;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
// web-test core/click v1.23 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import { page, ensureConnected, highlightMode } from './state.mjs';
|
import { page, ensureConnected, highlightMode } from './state.mjs';
|
||||||
@@ -116,7 +116,12 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi
|
|||||||
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Dispatch to the right handler by target kind.
|
// 4. Guard: a disabled target (button/frameButton/checkbox/tumbler/field) is a silent
|
||||||
|
// no-op in 1C — clicking it does nothing. Fail loud instead of masquerading success.
|
||||||
|
// Availability is checkable up front via getFormState().buttons[].disabled / fields[].disabled.
|
||||||
|
if (target.disabled) throw new Error(`clickElement: "${text}" is disabled`);
|
||||||
|
|
||||||
|
// 5. Dispatch to the right handler by target kind.
|
||||||
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
|
const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector };
|
||||||
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
||||||
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// web-test engine/core/deadline v1.0 — wall-clock bounds for calls that can hang
|
||||||
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
//
|
||||||
|
// Why this exists: `try { await x } catch {}` guards against a REJECTION, not against
|
||||||
|
// a promise that never settles. A Playwright call against a wedged renderer (page.evaluate
|
||||||
|
// has no timeout at all) does exactly that — it neither resolves nor rejects, and the runner
|
||||||
|
// waits forever. Every such call must be bounded by a wall-clock timer instead.
|
||||||
|
//
|
||||||
|
// Neither helper CANCELS the underlying work — that is impossible for a promise. They only
|
||||||
|
// stop *waiting* on it. Whoever breaks a deadline must also destroy the thing that hung
|
||||||
|
// (see session.abortContext) or the pending call keeps holding its resources.
|
||||||
|
|
||||||
|
export class DeadlineError extends Error {
|
||||||
|
constructor(label, ms) {
|
||||||
|
super(`${label} timed out after ${ms}ms`);
|
||||||
|
this.name = 'DeadlineError';
|
||||||
|
this.label = label;
|
||||||
|
this.ms = ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Await `promise`, but give up after `ms`.
|
||||||
|
* @throws {DeadlineError} when the deadline is reached first.
|
||||||
|
*/
|
||||||
|
export function withDeadline(promise, ms, label = 'operation') {
|
||||||
|
let timer;
|
||||||
|
return Promise.race([
|
||||||
|
Promise.resolve(promise),
|
||||||
|
new Promise((_, reject) => { timer = setTimeout(() => reject(new DeadlineError(label, ms)), ms); }),
|
||||||
|
]).finally(() => clearTimeout(timer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort variant: never throws, reports what happened instead.
|
||||||
|
* Use it where the old code said `try { await x } catch {}` — the point is that a breach
|
||||||
|
* becomes VISIBLE (callers are expected to log `err`) rather than silently swallowed.
|
||||||
|
* @returns {Promise<{ok: boolean, value?: any, err?: Error, timedOut: boolean, ms: number}>}
|
||||||
|
*/
|
||||||
|
export async function softDeadline(promise, ms, label = 'operation') {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const value = await withDeadline(promise, ms, label);
|
||||||
|
return { ok: true, value, timedOut: false, ms: Date.now() - t0 };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, err, timedOut: err instanceof DeadlineError, ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
// web-test core/session v1.20 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
import { softDeadline } from './deadline.mjs';
|
||||||
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
import { statSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
||||||
import { join as pathJoin } from 'path';
|
import { join as pathJoin } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
@@ -52,6 +53,90 @@ function findExtension(overridePath) {
|
|||||||
|
|
||||||
/* isConnected moved to core/state.mjs */
|
/* isConnected moved to core/state.mjs */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the 1C client to come up — or for the startup shell to say why it won't.
|
||||||
|
*
|
||||||
|
* Why this exists: when 1C has no free licence it renders a blocking startup dialog INSTEAD of
|
||||||
|
* the application. The old code just waited out INIT_TIMEOUT and returned success, leaving a
|
||||||
|
* session-less slot behind; the first engine call then produced a plainly wrong diagnosis
|
||||||
|
* ("Section panel is in icon-only mode…"). Measured: 66s wasted, then a lie.
|
||||||
|
*
|
||||||
|
* Two blockers are recognised, both by id:
|
||||||
|
* #messageBoxText — the startup message box (no free licence, and any other startup error)
|
||||||
|
* #authWindow — the login dialog: the publication wants credentials, which the engine
|
||||||
|
* cannot supply. Landing here used to be treated as legitimate ("login
|
||||||
|
* page"), but that was fiction: closeModals() presses Escape 5x right after
|
||||||
|
* this wait, which dismisses the dialog and leaves a blank page reported as
|
||||||
|
* a healthy start. Nobody could ever log in by hand either.
|
||||||
|
*
|
||||||
|
* Contract: throw ONLY on positive evidence — a visible dialog. A missing client marker is NOT
|
||||||
|
* evidence (an unknown or merely slow start must keep its old behaviour), hence the fallback.
|
||||||
|
*
|
||||||
|
* Anchors are ids, never text: the platform's wording is locale-dependent and only gets quoted
|
||||||
|
* into the message. Verified on this stand — on a healthy start neither #messageBoxText nor
|
||||||
|
* #authWindow exists at all, in the loaded client or at any point while the shell boots (polled
|
||||||
|
* every 100ms, including bpdemo's 19.5s auto-login boot). The offsetWidth/text conjuncts guard a
|
||||||
|
* future build that pre-renders them hidden. offsetWidth (not offsetParent — that is null for
|
||||||
|
* position:fixed, and #ps0win is an overlay) matches the convention in errors.mjs.
|
||||||
|
*/
|
||||||
|
async function waitForClientOrStartupBlock(pg, url, timeout = INIT_TIMEOUT) {
|
||||||
|
let outcome;
|
||||||
|
try {
|
||||||
|
outcome = await pg.waitForFunction(() => {
|
||||||
|
if (document.querySelector('#themesCell_theme_0')) return 'client';
|
||||||
|
const box = document.querySelector('#messageBoxText');
|
||||||
|
if (box && box.offsetWidth > 0 && box.textContent.trim()) return 'blocked';
|
||||||
|
const auth = document.querySelector('#authWindow');
|
||||||
|
if (auth && auth.offsetWidth > 0) return 'auth';
|
||||||
|
return false;
|
||||||
|
}, null, { timeout }).then(h => h.jsonValue());
|
||||||
|
} catch {
|
||||||
|
// Neither appeared: unchanged legacy behaviour — a login page or a slow start is not an error.
|
||||||
|
await pg.waitForTimeout(5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome === 'client') return;
|
||||||
|
|
||||||
|
// Re-confirm before accusing: costs 600ms on a path that is already lost, and buys immunity to
|
||||||
|
// a dialog that merely flickered while the shell drew itself.
|
||||||
|
await pg.waitForTimeout(600);
|
||||||
|
const evidence = await pg.evaluate(() => {
|
||||||
|
// innerText, not textContent: the latter concatenates without any rendered whitespace
|
||||||
|
// ("лицензии!Выберите…", "Веб-клиентсеанс: 5") — unreadable in an error message.
|
||||||
|
const visibleText = (el) => (el && el.offsetWidth > 0) ? (el.innerText || '').trim() : '';
|
||||||
|
if (document.querySelector('#themesCell_theme_0')) return null; // the client won after all
|
||||||
|
const text = visibleText(document.querySelector('#messageBoxText'));
|
||||||
|
if (text) return { kind: 'blocked', text, seances: visibleText(document.querySelector('#seancesToFinish')) };
|
||||||
|
const auth = document.querySelector('#authWindow');
|
||||||
|
if (auth && auth.offsetWidth > 0) return { kind: 'auth', text: visibleText(auth) };
|
||||||
|
return null;
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!evidence) return; // flicker — carry on exactly as before
|
||||||
|
|
||||||
|
const oneLine = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||||
|
|
||||||
|
if (evidence.kind === 'auth') {
|
||||||
|
// The publication asks a human for credentials. The engine cannot answer, and until now it
|
||||||
|
// did something worse than fail: it waited 66s and then closeModals()'s Escape dismissed the
|
||||||
|
// dialog, leaving a blank page reported as a healthy start. Note a seance IS already created
|
||||||
|
// here (unlike the licence case) and holds a licence — callers release it before rethrowing.
|
||||||
|
throw new Error(
|
||||||
|
`1C requires interactive login before the web client loads: "${oneLine(evidence.text)}"` +
|
||||||
|
'\n The engine cannot supply credentials — publish the infobase with a user' +
|
||||||
|
' (web-publish -UserName … → Usr=/Pwd= in the vrd) or put one in the connection string.' +
|
||||||
|
`\n URL: ${url}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`1C startup blocked before the web client loaded: "${oneLine(evidence.text)}"` +
|
||||||
|
(evidence.seances ? `\n Sessions the platform offers to terminate: ${oneLine(evidence.seances)}` : '') +
|
||||||
|
'\n The engine does not press this dialog\'s buttons: its countdown auto-start may terminate' +
|
||||||
|
' someone else\'s session on this machine.' +
|
||||||
|
`\n If this is a licence shortage — release 1C sessions and retry. URL: ${url}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open browser and navigate to 1C web client URL.
|
* Open browser and navigate to 1C web client URL.
|
||||||
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
||||||
@@ -102,12 +187,17 @@ export async function connect(url, { extensionPath } = {}) {
|
|||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for 1C to initialize — detect by section panel appearance
|
// Wait for 1C to initialize — or fail fast if the startup shell blocks the client.
|
||||||
|
// MUST run before closeModals(): that presses Escape 5x, which dismisses the auth dialog and
|
||||||
|
// destroys the evidence (measured — one Escape blanks the page).
|
||||||
try {
|
try {
|
||||||
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
|
await waitForClientOrStartupBlock(page, url);
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
|
// On the auth dialog a 1C seance already exists and holds a licence, and killing the process
|
||||||
await page.waitForTimeout(5000);
|
// does NOT release it. cmdStart has no catch and run.mjs does not wrap the command, so the
|
||||||
|
// error escapes and the process dies — release the seance here, while we still can.
|
||||||
|
await softDeadline(disconnect(), 20000, 'disconnect(startup-block)');
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to close startup modals (Путеводитель etc.)
|
// Try to close startup modals (Путеводитель etc.)
|
||||||
@@ -138,15 +228,21 @@ async function logoutSlot(slot, waitMs = 500) {
|
|||||||
* Sends POST /e1cib/logout to release the license before closing.
|
* Sends POST /e1cib/logout to release the license before closing.
|
||||||
*/
|
*/
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
|
const wasMultiContext = contexts.size > 0;
|
||||||
|
|
||||||
// Multi-context path: stop recording + logout each slot before closing browser
|
// Multi-context path: stop recording + logout each slot before closing browser
|
||||||
if (contexts.size > 0) {
|
if (wasMultiContext) {
|
||||||
saveActiveSlot();
|
saveActiveSlot();
|
||||||
// Recorder is global — one stop covers all contexts
|
// Recorder is global — one stop covers all contexts
|
||||||
if (recorder) {
|
if (recorder) {
|
||||||
try { await stopRecording(); } catch {}
|
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||||
}
|
}
|
||||||
for (const [, slot] of contexts.entries()) {
|
for (const [, slot] of contexts.entries()) {
|
||||||
await logoutSlot(slot);
|
// Deadline-bounded: an unresponsive slot must not hold up the shutdown of the others.
|
||||||
|
// nodeLogout is the fallback that needs no renderer — it is what keeps the license
|
||||||
|
// from leaking when the page is the thing that died.
|
||||||
|
const own = await softDeadline(logoutSlot(slot), 3000, 'logoutSlot');
|
||||||
|
if (!own.ok) await nodeLogout(slot, 3000);
|
||||||
}
|
}
|
||||||
contexts.clear();
|
contexts.clear();
|
||||||
setActiveContextName(null);
|
setActiveContextName(null);
|
||||||
@@ -155,13 +251,20 @@ export async function disconnect() {
|
|||||||
|
|
||||||
// Single-session path (connect): auto-stop recording if active
|
// Single-session path (connect): auto-stop recording if active
|
||||||
if (recorder) {
|
if (recorder) {
|
||||||
try { await stopRecording(); } catch {}
|
await softDeadline(stopRecording(), 40000, 'stopRecording');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Graceful logout — release the 1C license (single-session connect path)
|
// Graceful logout — release the 1C license (single-session connect path).
|
||||||
await logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
// Skipped after the multi-context path: `page` still mirrors the last active slot, which
|
||||||
await browser.close().catch(() => {});
|
// was just logged out above — re-sending it would pay a hung page's cost a second time.
|
||||||
|
if (!wasMultiContext) {
|
||||||
|
await softDeadline(logoutSlot({ page, sessionPrefix, seanceId }, 1000), 4000, 'logoutSlot');
|
||||||
|
}
|
||||||
|
await softDeadline(browser.close(), 10000, 'browser.close');
|
||||||
|
// Floor: if Chromium ignored close(), take the process out — never leave an orphan.
|
||||||
|
try { browser.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||||
|
try { browser.process?.()?.kill('SIGKILL'); } catch {}
|
||||||
setBrowser(null);
|
setBrowser(null);
|
||||||
setPage(null);
|
setPage(null);
|
||||||
setSessionPrefix(null);
|
setSessionPrefix(null);
|
||||||
@@ -241,7 +344,22 @@ function activateSlot(name) {
|
|||||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||||
function attachSessionListeners(pg, slot, name) {
|
function attachSessionListeners(pg, slot, name) {
|
||||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||||
|
|
||||||
|
// Network counters feed the hang/slow diagnosis (see probeContext). These events are
|
||||||
|
// emitted by the BROWSER process, so they keep flowing even when the page's JS thread is
|
||||||
|
// wedged and page.evaluate() can no longer answer. Supporting colour only, not a verdict:
|
||||||
|
// a wedged renderer cannot issue requests, so "quiet" looks the same as "idle waiting".
|
||||||
|
// Counters only — never buffer URLs, this runs for the whole suite.
|
||||||
|
slot.net = { lastEventAt: Date.now(), inFlight: 0, requests: 0, responses: 0 };
|
||||||
|
const settled = () => { slot.net.inFlight = Math.max(0, slot.net.inFlight - 1); slot.net.lastEventAt = Date.now(); };
|
||||||
|
pg.on('requestfinished', settled);
|
||||||
|
pg.on('requestfailed', settled);
|
||||||
|
pg.on('response', () => { slot.net.responses++; slot.net.lastEventAt = Date.now(); });
|
||||||
|
|
||||||
pg.on('request', req => {
|
pg.on('request', req => {
|
||||||
|
slot.net.requests++;
|
||||||
|
slot.net.inFlight++;
|
||||||
|
slot.net.lastEventAt = Date.now();
|
||||||
if (slot.seanceId) return;
|
if (slot.seanceId) return;
|
||||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||||
if (m) {
|
if (m) {
|
||||||
@@ -265,14 +383,30 @@ function attachSessionListeners(pg, slot, name) {
|
|||||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||||
* legacy persistent-context path.
|
* legacy persistent-context path.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Navigate the active slot to `url` and settle: client up, or a startup block raised.
|
||||||
|
*
|
||||||
|
* On a block the slot MUST NOT survive. It is registered before this runs, and the runner's
|
||||||
|
* ensureContext is `if (browser.hasContext(name)) return;` — so a broken slot left in the registry
|
||||||
|
* would silently serve every later test the refusal dialog, i.e. exactly the blindness being fixed.
|
||||||
|
* abortContext also handles the tab-mode last-page teardown, so the next createContext relaunches.
|
||||||
|
*/
|
||||||
|
async function openAndSettle(name, url) {
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||||
|
try {
|
||||||
|
await waitForClientOrStartupBlock(page, url);
|
||||||
|
} catch (e) {
|
||||||
|
await softDeadline(abortContext(name), 10000, 'abortContext(startup-block)');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
await closeModals();
|
||||||
|
return await getPageState();
|
||||||
|
}
|
||||||
|
|
||||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||||
if (contexts.has(name)) {
|
if (contexts.has(name)) {
|
||||||
await setActiveContext(name);
|
await setActiveContext(name);
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
return await openAndSettle(name, url);
|
||||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
|
||||||
catch { await page.waitForTimeout(5000); }
|
|
||||||
await closeModals();
|
|
||||||
return await getPageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['tab', 'window'].includes(isolation)) {
|
if (!['tab', 'window'].includes(isolation)) {
|
||||||
@@ -341,12 +475,7 @@ export async function createContext(name, url, { extensionPath, isolation = 'tab
|
|||||||
attachSessionListeners(newPage, slot, name);
|
attachSessionListeners(newPage, slot, name);
|
||||||
activateSlot(name);
|
activateSlot(name);
|
||||||
|
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
return await openAndSettle(name, url);
|
||||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
|
||||||
catch { await page.waitForTimeout(5000); }
|
|
||||||
await closeModals();
|
|
||||||
|
|
||||||
return await getPageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||||
@@ -402,3 +531,211 @@ export async function closeContext(name) {
|
|||||||
}
|
}
|
||||||
contexts.delete(name);
|
contexts.delete(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a 1C seance straight from Node — no renderer, no CDP, no browser involved.
|
||||||
|
*
|
||||||
|
* Measured on the webtest stand: the seance is identified by `seanceId` in the URL and the
|
||||||
|
* client holds NO cookies at all (context.cookies() → []), so this request is equivalent to
|
||||||
|
* the one logoutSlot makes from inside the page. Verified end-to-end: after this call the
|
||||||
|
* web client reports "сеанс был завершен" on its next action.
|
||||||
|
*
|
||||||
|
* This is the only logout that still works when the renderer is wedged — which is exactly
|
||||||
|
* when a license would otherwise leak until the server-side seance timeout.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>} true only on a 2xx answer (a 401/404 must not pass for success).
|
||||||
|
*/
|
||||||
|
async function nodeLogout(slot, ms = 3000) {
|
||||||
|
if (!slot?.sessionPrefix || !slot?.seanceId) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{"root":{}}',
|
||||||
|
signal: AbortSignal.timeout(ms),
|
||||||
|
});
|
||||||
|
return res.status >= 200 && res.status < 300;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this context's browser alive, and is its renderer still answering?
|
||||||
|
*
|
||||||
|
* The two probes separate the failure modes that look identical from the outside:
|
||||||
|
* browserAlive && !rendererAlive → the page's JS thread is wedged: a hang. Nothing that
|
||||||
|
* goes through the renderer (evaluate, screenshot, clicks) can ever come back.
|
||||||
|
* both alive → nothing is broken; the test simply outran its timeout.
|
||||||
|
*
|
||||||
|
* cookies() is served by the browser process (measured: 1ms against a wedged renderer),
|
||||||
|
* page.evaluate() is not — that asymmetry is the whole trick.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{browserAlive: boolean, rendererAlive: boolean, browserMs: number, rendererMs: number, pageClosed: boolean}>}
|
||||||
|
*/
|
||||||
|
export async function probeContext(name, { ms = 2000 } = {}) {
|
||||||
|
const slot = contexts.get(name);
|
||||||
|
if (!slot?.page) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||||
|
if (slot.page.isClosed()) return { browserAlive: false, rendererAlive: false, browserMs: 0, rendererMs: 0, pageClosed: true };
|
||||||
|
|
||||||
|
const [b, r] = await Promise.all([
|
||||||
|
softDeadline(slot.page.context().cookies(), ms, 'browser probe'),
|
||||||
|
softDeadline(slot.page.evaluate(() => 1), ms, 'renderer probe'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
browserAlive: b.ok,
|
||||||
|
rendererAlive: r.ok,
|
||||||
|
browserMs: b.ms,
|
||||||
|
rendererMs: r.ms,
|
||||||
|
pageClosed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only view of a slot's network activity. Never hands out the slot itself. */
|
||||||
|
export function getContextDiagnostics(name) {
|
||||||
|
const slot = contexts.get(name);
|
||||||
|
if (!slot) return null;
|
||||||
|
const net = slot.net || { lastEventAt: 0, inFlight: 0, requests: 0, responses: 0 };
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isolation: activeMode,
|
||||||
|
net: { ...net },
|
||||||
|
msSinceLastNetEvent: net.lastEventAt ? Date.now() - net.lastEventAt : null,
|
||||||
|
pageClosed: slot.page ? slot.page.isClosed() : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-release an unresponsive context — including the ACTIVE one, which closeContext
|
||||||
|
* refuses to touch. Every step is wall-clock bounded, so this path is never at the mercy
|
||||||
|
* of the thing that hung: it is bounded by timers, never by browser cooperation.
|
||||||
|
*
|
||||||
|
* Ordering matters: logout FIRST (a dead page can't release its own license), close second.
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.logoutMs=3000] budget per logout attempt
|
||||||
|
* @param {number} [opts.closeMs=5000] budget for page.close()/context.close()
|
||||||
|
* @param {string} [opts.parkOn] context to activate afterwards (default: any survivor)
|
||||||
|
* @returns {Promise<{name, logout: 'node'|'page'|'sibling'|'failed'|'skipped', closed: 'page'|'context'|'browser-killed'|'failed', escalated: boolean, notes: string[]}>}
|
||||||
|
*/
|
||||||
|
export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, parkOn } = {}) {
|
||||||
|
const out = { name, logout: 'skipped', closed: 'failed', escalated: false, notes: [] };
|
||||||
|
const slot = contexts.get(name);
|
||||||
|
if (!slot) { out.notes.push('not registered'); return out; }
|
||||||
|
|
||||||
|
// The recorder follows the active page; if we are about to close that page, stop it first
|
||||||
|
// or the CDP screencast is dead for the rest of the run.
|
||||||
|
if (recorder && activeContextName === name) {
|
||||||
|
const r = await softDeadline(stopRecording(), 10000, 'stopRecording');
|
||||||
|
if (!r.ok) out.notes.push(`stopRecording: ${r.err.message.split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logout cascade: first success wins. node first — it needs neither renderer nor CDP.
|
||||||
|
if (slot.seanceId && slot.sessionPrefix) {
|
||||||
|
if (await nodeLogout(slot, logoutMs)) {
|
||||||
|
out.logout = 'node';
|
||||||
|
} else {
|
||||||
|
const own = await softDeadline(logoutSlot(slot, 0), logoutMs, 'logoutSlot');
|
||||||
|
if (own.ok) {
|
||||||
|
out.logout = 'page';
|
||||||
|
} else {
|
||||||
|
// Same origin ⇒ same seance namespace; a live sibling can post the logout for us.
|
||||||
|
const sibling = [...contexts.entries()].find(([n, s]) =>
|
||||||
|
n !== name && s.page && !s.page.isClosed() && s.sessionPrefix === slot.sessionPrefix);
|
||||||
|
if (sibling) {
|
||||||
|
const url = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||||
|
const sib = await softDeadline(
|
||||||
|
sibling[1].page.evaluate(async (u) => {
|
||||||
|
const r = await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||||
|
return r.status;
|
||||||
|
}, url), logoutMs, 'sibling logout');
|
||||||
|
if (sib.ok && sib.value >= 200 && sib.value < 300) out.logout = 'sibling';
|
||||||
|
else out.logout = 'failed';
|
||||||
|
} else {
|
||||||
|
out.logout = 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.logout === 'failed') out.notes.push('license may leak until the 1C seance times out');
|
||||||
|
}
|
||||||
|
|
||||||
|
// In tab mode `browser` is a persistent BrowserContext (see createContext): closing its LAST
|
||||||
|
// page leaves Chromium with no windows and it exits, so the next createContext() would fail
|
||||||
|
// with "Failed to open a new tab". When this is the only slot, tear the browser down on
|
||||||
|
// purpose and reset state — the next createContext() then relaunches cleanly.
|
||||||
|
const lastPageInTabMode = activeMode === 'tab' && contexts.size === 1;
|
||||||
|
|
||||||
|
// ── Close. runBeforeUnload:false is what survives a wedged renderer: the browser process
|
||||||
|
// tears the target down instead of asking the page's JS to agree.
|
||||||
|
if (activeMode === 'tab') {
|
||||||
|
// tab mode: slot.context IS the shared browser — closing it would kill every context.
|
||||||
|
const c = await softDeadline(slot.page.close({ runBeforeUnload: false }), closeMs, 'page.close');
|
||||||
|
if (c.ok) out.closed = 'page';
|
||||||
|
} else {
|
||||||
|
const c = await softDeadline(slot.context.close(), closeMs, 'context.close');
|
||||||
|
if (c.ok) out.closed = 'context';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escalate: if even the close hung, the browser itself is suspect. Kill it and reset
|
||||||
|
// state, otherwise the next createContext() would call newPage() on a dead object and
|
||||||
|
// every remaining test would fail.
|
||||||
|
if (out.closed === 'failed') {
|
||||||
|
out.escalated = true;
|
||||||
|
out.notes.push('close breached its deadline — killing the browser');
|
||||||
|
const b = browser;
|
||||||
|
await softDeadline(Promise.resolve(b?.close?.()), 5000, 'browser.close');
|
||||||
|
try { b?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||||
|
try { b?.process?.()?.kill('SIGKILL'); } catch {}
|
||||||
|
out.closed = 'browser-killed';
|
||||||
|
contexts.clear();
|
||||||
|
setBrowser(null);
|
||||||
|
setPage(null);
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
setActiveContextName(null);
|
||||||
|
setActiveMode(null);
|
||||||
|
if (persistentUserDataDir) {
|
||||||
|
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||||
|
setPersistentUserDataDir(null);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
contexts.delete(name);
|
||||||
|
|
||||||
|
if (lastPageInTabMode) {
|
||||||
|
out.notes.push('last tab closed — browser torn down, next createContext relaunches it');
|
||||||
|
await softDeadline(Promise.resolve(browser?.close?.()), 5000, 'browser.close');
|
||||||
|
try { browser?.browser?.()?.process?.()?.kill('SIGKILL'); } catch {}
|
||||||
|
setBrowser(null);
|
||||||
|
setPage(null);
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
setActiveContextName(null);
|
||||||
|
setActiveMode(null);
|
||||||
|
if (persistentUserDataDir) {
|
||||||
|
try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {}
|
||||||
|
setPersistentUserDataDir(null);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Park the active pointer on a survivor (or nothing). Deliberately NOT via
|
||||||
|
// setActiveContext()/saveActiveSlot() — those would write the dying page back into a slot.
|
||||||
|
if (activeContextName === name) {
|
||||||
|
const survivor = (parkOn && contexts.has(parkOn)) ? parkOn : [...contexts.keys()][0];
|
||||||
|
if (survivor) {
|
||||||
|
activateSlot(survivor);
|
||||||
|
if (recorder) { try { await recorder._attachPage(page); } catch { /* recording is best-effort */ } }
|
||||||
|
} else {
|
||||||
|
// No slots left: isConnected() goes false and engine calls fail fast until the next
|
||||||
|
// ensureContext() recreates one. That is the intended contract, not a leak.
|
||||||
|
setPage(null);
|
||||||
|
setSessionPrefix(null);
|
||||||
|
setSeanceId(null);
|
||||||
|
setActiveContextName(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation.
|
// web-test forms/close v1.20 — Close current form via Escape, handle save-changes confirmation.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
import { page, recorder, ensureConnected } from '../core/state.mjs';
|
||||||
import { detectFormScript } from '../../dom.mjs';
|
import { detectFormScript, closeCrossScript } from '../../dom.mjs';
|
||||||
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs';
|
||||||
import { waitForStable } from '../core/wait.mjs';
|
import { waitForStable } from '../core/wait.mjs';
|
||||||
import { returnFormState } from '../core/helpers.mjs';
|
import { returnFormState } from '../core/helpers.mjs';
|
||||||
import { getFormState } from './state.mjs';
|
import { getFormState } from './state.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the current form/dialog via Escape.
|
* Which button actually CLOSES the form on this confirmation?
|
||||||
|
*
|
||||||
|
* 1C asks two different questions with the same two buttons:
|
||||||
|
* «Данные были изменены. Сохранить изменения?» → Да = save+close, Нет = close without saving
|
||||||
|
* «Виза сохранена не будет. Закрыть согласование?» → Да = close, Нет = STAY IN THE FORM
|
||||||
|
* A hard-coded «Нет» for save:false is right for the first and exactly backwards for the second:
|
||||||
|
* the caller asks to close and gets a form that stays open (measured — that is how a modal leaked
|
||||||
|
* into the next test).
|
||||||
|
*
|
||||||
|
* Decide by the QUESTION, not by the whole message: both texts contain the root «сохран», but only
|
||||||
|
* the interrogative sentence says what the buttons mean. Unknown wording keeps the legacy answer.
|
||||||
|
*/
|
||||||
|
function pickConfirmationLabel(message, save) {
|
||||||
|
const question = (String(message || '').match(/[^.!?]*\?/g) || []).pop() || '';
|
||||||
|
if (/сохранит/i.test(question)) return save ? 'Да' : 'Нет'; // "…Сохранить изменения?"
|
||||||
|
if (/закрыт/i.test(question)) return 'Да'; // "…Закрыть согласование?" — Да = закрыть
|
||||||
|
return save ? 'Да' : 'Нет'; // unknown → as before
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the current form/dialog: Escape first, the modal window cross if Escape does nothing.
|
||||||
* @param {Object} [opts]
|
* @param {Object} [opts]
|
||||||
* @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically:
|
* @param {boolean} [opts.save] - Handle a confirmation automatically. The button is chosen by the
|
||||||
* true → click "Да" (save and close)
|
* MEANING of the question (see pickConfirmationLabel), not by a fixed label:
|
||||||
* false → click "Нет" (discard and close)
|
* true → save and close
|
||||||
|
* false → close without saving
|
||||||
* undefined → return confirmation as hint for caller to decide
|
* undefined → return confirmation as hint for caller to decide
|
||||||
*/
|
*/
|
||||||
export async function closeForm({ save } = {}) {
|
export async function closeForm({ save } = {}) {
|
||||||
@@ -29,11 +50,33 @@ export async function closeForm({ save } = {}) {
|
|||||||
const beforeForm = await page.evaluate(detectFormScript());
|
const beforeForm = await page.evaluate(detectFormScript());
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await waitForStable(beforeForm);
|
await waitForStable(beforeForm);
|
||||||
const state = await getFormState();
|
let state = await getFormState();
|
||||||
const err = await checkForErrors();
|
let err = await checkForErrors();
|
||||||
|
let usedCross = false;
|
||||||
|
let nothingToClose = false;
|
||||||
|
|
||||||
|
// Escape did nothing and raised no question. On a real stand that is the norm, not the exception:
|
||||||
|
// Escape closed neither a modal nor even a plain list there. Fall back to the cross a human would
|
||||||
|
// click. Only when nothing moved, so ordinary forms keep the cheap Escape path.
|
||||||
|
if (!err?.confirmation && state.form === beforeForm) {
|
||||||
|
const crossId = await page.evaluate(closeCrossScript());
|
||||||
|
if (crossId) {
|
||||||
|
await page.click(`#${crossId}`).catch(() => {});
|
||||||
|
await waitForStable(beforeForm);
|
||||||
|
state = await getFormState();
|
||||||
|
err = await checkForErrors();
|
||||||
|
usedCross = true;
|
||||||
|
} else {
|
||||||
|
// No cross anywhere: the platform itself says this surface is not closable — i.e. we are on
|
||||||
|
// the desktop. This is what tells resetState "clean" without knowing anything about the
|
||||||
|
// application: a home page with three forms on it looks exactly like an empty one here.
|
||||||
|
nothingToClose = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (err?.confirmation) {
|
if (err?.confirmation) {
|
||||||
if (save === true || save === false) {
|
if (save === true || save === false) {
|
||||||
const label = save ? 'Да' : 'Нет';
|
const label = pickConfirmationLabel(err.confirmation.message, save);
|
||||||
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`;
|
||||||
const btns = await page.$$(btnSel);
|
const btns = await page.$$(btnSel);
|
||||||
for (const b of btns) {
|
for (const b of btns) {
|
||||||
@@ -46,11 +89,17 @@ export async function closeForm({ save } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const afterForm = await page.evaluate(detectFormScript());
|
const afterForm = await page.evaluate(detectFormScript());
|
||||||
return returnFormState({ closed: afterForm !== beforeForm });
|
// Report which button was pressed: on a "…Закрыть?" question it is «Да» even for save:false,
|
||||||
|
// and a silent surprise there is what cost the last investigation its afternoon.
|
||||||
|
return returnFormState({ closed: afterForm !== beforeForm, confirmationAnswered: label, closedViaCross: usedCross || undefined });
|
||||||
}
|
}
|
||||||
state.confirmation = err.confirmation;
|
state.confirmation = err.confirmation;
|
||||||
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel';
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return returnFormState({ closed: state.form !== beforeForm });
|
return returnFormState({
|
||||||
|
closed: state.form !== beforeForm,
|
||||||
|
closedViaCross: usedCross || undefined,
|
||||||
|
nothingToClose: nothingToClose || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test forms/fill v1.20 — Fill form fields by name (text/checkbox/date/number/dropdown/reference; array → multi-select via selectValue). Delegates references to selectValue / fillReferenceField.
|
// web-test forms/fill v1.21 — Fill form fields by name (text/checkbox/date/number/dropdown/reference; array → multi-select via selectValue). Delegates references to selectValue / fillReferenceField.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +33,12 @@ export async function fillFields(fields) {
|
|||||||
results.push(r);
|
results.push(r);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Disabled field: filling/toggling it is a silent no-op in 1C. Fail loud via the
|
||||||
|
// standard failure aggregation below instead of reporting a fake success.
|
||||||
|
if (r.disabled) {
|
||||||
|
results.push({ field: r.field, error: 'disabled', message: `field "${r.field}" is disabled` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Array value → multi-select. Delegate to selectValue's array branch (auto-detects
|
// Array value → multi-select. Delegate to selectValue's array branch (auto-detects
|
||||||
// the surface) so fillFields({field:[...]}) works the same as selectValue(field,[...]).
|
// the surface) so fillFields({field:[...]}) works the same as selectValue(field,[...]).
|
||||||
if (Array.isArray(fields[r.field])) {
|
if (Array.isArray(fields[r.field])) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user