diff --git a/.claude/skills/form-compile/scripts/form-compile.ps1 b/.claude/skills/form-compile/scripts/form-compile.ps1 index 80339fa0..150cbece 100644 --- a/.claude/skills/form-compile/scripts/form-compile.ps1 +++ b/.claude/skills/form-compile/scripts/form-compile.ps1 @@ -1,4 +1,4 @@ -# form-compile v1.38 — Compile 1C managed form from JSON or object metadata +# form-compile v1.39 — Compile 1C managed form from JSON or object metadata # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills param( [string]$JsonPath, @@ -1496,6 +1496,22 @@ if ($FromObject) { $def = $json | ConvertFrom-Json } +# Базовая директория для @file-ссылок в query динсписка (зеркало skd-compile) +$script:queryBaseDir = if ($JsonPath) { [System.IO.Path]::GetDirectoryName((Resolve-Path $JsonPath).Path) } else { (Get-Location).Path } +function Resolve-QueryValue { + param([string]$val, [string]$baseDir) + if (-not $val.StartsWith("@")) { return $val } + $filePath = $val.Substring(1) + if ([System.IO.Path]::IsPathRooted($filePath)) { + $candidates = @($filePath) + } else { + $candidates = @((Join-Path $baseDir $filePath), (Join-Path (Get-Location).Path $filePath)) + } + foreach ($c in $candidates) { if (Test-Path $c) { return (Get-Content -Raw -Encoding UTF8 $c).TrimEnd() } } + Write-Error "Query file not found: $filePath (searched: $($candidates -join ', '))" + exit 1 +} + # --- 2. ID allocator --- $script:nextId = 1 @@ -1547,6 +1563,347 @@ function Emit-MLText { X "$indent" } +# Каноничные GUID пустых контейнеров ListSettings (умолчание платформы, ~90% форм). +# Декомпилятор опускает пустые настройки → компилятор регенерит этот скелет → раундтрип +# (harness нормализует GUID для хвоста с иными идентификаторами). +$script:CANON_FILTER_ID = 'dfcece9d-5077-440b-b6b3-45a5cb4538eb' +$script:CANON_ORDER_ID = '88619765-ccb3-46c6-ac52-38e9c992ebd4' +$script:CANON_CA_ID = 'b75fecce-942b-4aed-abc9-e6a02e460fb3' +$script:CANON_ITEMS_ID = '911b6018-f537-43e8-a417-da56b22f9aec' + +# ───────────────────────────────────────────────────────────────────────────── +# Настройки компоновщика ListSettings: filter/order/conditionalAppearance. +# Грамматика DSL и эмиссия dcsset скопированы из skd-compile (навыки автономны). +# ───────────────────────────────────────────────────────────────────────────── +function New-Guid-String { return [System.Guid]::NewGuid().ToString() } + +$script:comparisonTypes = @{ + "=" = "Equal"; "<>" = "NotEqual" + ">" = "Greater"; ">=" = "GreaterOrEqual" + "<" = "Less"; "<=" = "LessOrEqual" + "in" = "InList"; "notIn" = "NotInList" + "inHierarchy" = "InHierarchy"; "inListByHierarchy" = "InListByHierarchy" + "contains" = "Contains"; "notContains" = "NotContains" + "beginsWith" = "BeginsWith"; "notBeginsWith" = "NotBeginsWith" + "filled" = "Filled"; "notFilled" = "NotFilled" +} + +function Parse-FilterShorthand { + param([string]$s) + $result = @{ field = ""; op = "Equal"; value = $null; use = $true; userSettingID = $null; viewMode = $null; presentation = $null } + if ($s -match '@user') { $result.userSettingID = "auto"; $s = $s -replace '\s*@user', '' } + if ($s -match '@off') { $result.use = $false; $s = $s -replace '\s*@off', '' } + if ($s -match '@quickAccess') { $result.viewMode = "QuickAccess"; $s = $s -replace '\s*@quickAccess', '' } + if ($s -match '@normal') { $result.viewMode = "Normal"; $s = $s -replace '\s*@normal', '' } + if ($s -match '@inaccessible') { $result.viewMode = "Inaccessible"; $s = $s -replace '\s*@inaccessible', '' } + $s = $s.Trim() + $opPatterns = @('<>', '>=', '<=', '=', '>', '<', + 'notIn\b', 'in\b', 'inHierarchy\b', 'inListByHierarchy\b', + 'notContains\b', 'contains\b', 'notBeginsWith\b', 'beginsWith\b', + 'notFilled\b', 'filled\b') + $opJoined = $opPatterns -join '|' + if ($s -match "^(.+?)\s+($opJoined)\s*(.*)?$") { + $result.field = $Matches[1].Trim() + $result.op = $Matches[2].Trim() + $valPart = if ($Matches[3]) { $Matches[3].Trim() } else { "" } + if ($valPart -and $valPart -ne "_") { + if ($valPart -eq "true" -or $valPart -eq "false") { $result.value = [bool]($valPart -eq "true"); $result["valueType"] = "xs:boolean" } + elseif ($valPart -match '^\d{4}-\d{2}-\d{2}T') { $result.value = $valPart; $result["valueType"] = "xs:dateTime" } + elseif ($valPart -match '^\d+(\.\d+)?$') { $result.value = $valPart; $result["valueType"] = "xs:decimal" } + elseif ($valPart -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.') { $result.value = $valPart; $result["valueType"] = "dcscor:DesignTimeValue" } + else { $result.value = $valPart; $result["valueType"] = "xs:string" } + } + } else { $result.field = $s } + return $result +} + +function Emit-FilterItem { + param($item, [string]$indent) + if ($item.group) { + $groupType = switch ("$($item.group)") { "And" { "AndGroup" } "Or" { "OrGroup" } "Not" { "NotGroup" } default { "$($item.group)Group" } } + X "$indent" + X "$indent`t$groupType" + if ($item.items) { + foreach ($sub in $item.items) { + if ($sub -is [string]) { + $parsed = Parse-FilterShorthand $sub + $obj = @{ field = $parsed.field; op = $parsed.op } + if ($parsed.use -eq $false) { $obj.use = $false } + if ($null -ne $parsed.value) { $obj.value = $parsed.value } + if ($parsed["valueType"]) { $obj.valueType = $parsed["valueType"] } + if ($parsed.userSettingID) { $obj.userSettingID = $parsed.userSettingID } + if ($parsed.viewMode) { $obj.viewMode = $parsed.viewMode } + $sub = [pscustomobject]$obj + } + Emit-FilterItem -item $sub -indent "$indent`t" + } + } + if ($item.presentation) { Emit-MLText -tag "dcsset:presentation" -text $item.presentation -indent "$indent`t" } + if ($item.viewMode) { X "$indent`t$(Esc-Xml "$($item.viewMode)")" } + if ($item.userSettingID) { + $guid = if ("$($item.userSettingID)" -eq "auto") { New-Guid-String } else { "$($item.userSettingID)" } + X "$indent`t$(Esc-Xml $guid)" + } + if ($item.userSettingPresentation) { Emit-MLText -tag "dcsset:userSettingPresentation" -text $item.userSettingPresentation -indent "$indent`t" } + X "$indent" + return + } + X "$indent" + if ($item.use -eq $false) { X "$indent`tfalse" } + X "$indent`t$(Esc-Xml "$($item.field)")" + $compType = $script:comparisonTypes["$($item.op)"] + if (-not $compType) { $compType = "$($item.op)" } + X "$indent`t$(Esc-Xml $compType)" + $valIsArray = ($item.value -is [array]) -or ($item.value -is [System.Collections.IList] -and $item.value -isnot [string]) + if ($valIsArray) { + if (@($item.value).Count -eq 0) { + X "$indent`t" + X "$indent`t`t" + X "$indent`t`t-1" + X "$indent`t" + } else { + foreach ($v in $item.value) { + $vt = if ($item.valueType) { "$($item.valueType)" } else { "" } + if (-not $vt) { + if ($v -is [bool]) { $vt = 'xs:boolean' } + elseif ($v -is [int] -or $v -is [long] -or $v -is [double]) { $vt = 'xs:decimal' } + elseif ("$v" -match '^\d{4}-\d{2}-\d{2}T') { $vt = 'xs:dateTime' } + elseif ("$v" -match '^-?\d+(\.\d+)?$') { $vt = 'xs:decimal' } + elseif ("$v" -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { $vt = 'dcscor:DesignTimeValue' } + else { $vt = 'xs:string' } + } + $vStr = if ($v -is [bool]) { "$v".ToLower() } else { Esc-Xml "$v" } + X "$indent`t$vStr" + } + } + } elseif ($null -ne $item.value) { + $vt = if ($item.valueType) { "$($item.valueType)" } else { "" } + if (-not $vt) { + $v = $item.value + if ($v -is [bool]) { $vt = "xs:boolean" } + elseif ($v -is [int] -or $v -is [long] -or $v -is [double]) { $vt = "xs:decimal" } + elseif ("$v" -match '^\d{4}-\d{2}-\d{2}T') { $vt = "xs:dateTime" } + elseif ("$v" -match '^-?\d+(\.\d+)?$') { $vt = "xs:decimal" } + elseif ("$v" -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') { $vt = "dcscor:DesignTimeValue" } + else { $vt = "xs:string" } + } + $vStr = if ($item.value -is [bool]) { "$($item.value)".ToLower() } else { Esc-Xml "$($item.value)" } + X "$indent`t$vStr" + } + if ($item.presentation) { Emit-MLText -tag "dcsset:presentation" -text $item.presentation -indent "$indent`t" } + if ($item.viewMode) { X "$indent`t$(Esc-Xml "$($item.viewMode)")" } + if ($item.userSettingID) { + $uid = if ("$($item.userSettingID)" -eq "auto") { New-Guid-String } else { "$($item.userSettingID)" } + X "$indent`t$(Esc-Xml $uid)" + } + if ($item.userSettingPresentation) { Emit-MLText -tag "dcsset:userSettingPresentation" -text $item.userSettingPresentation -indent "$indent`t" } + X "$indent" +} + +function Emit-Filter { + param($items, [string]$indent, $blockViewMode = $null, $blockUserSettingID = $null) + $hasItems = $items -and $items.Count -gt 0 + $hasBlockMeta = ($null -ne $blockViewMode) -or ($null -ne $blockUserSettingID) + if (-not $hasItems -and -not $hasBlockMeta) { return } + X "$indent" + foreach ($item in $items) { + if ($item -is [string]) { + $parsed = Parse-FilterShorthand $item + $obj = @{ field = $parsed.field; op = $parsed.op } + if ($parsed.use -eq $false) { $obj.use = $false } + if ($null -ne $parsed.value) { $obj.value = $parsed.value } + if ($parsed["valueType"]) { $obj.valueType = $parsed["valueType"] } + if ($parsed.userSettingID) { $obj.userSettingID = $parsed.userSettingID } + if ($parsed.viewMode) { $obj.viewMode = $parsed.viewMode } + Emit-FilterItem -item ([pscustomobject]$obj) -indent "$indent`t" + } else { Emit-FilterItem -item $item -indent "$indent`t" } + } + if ($null -ne $blockViewMode) { X "$indent`t$(Esc-Xml "$blockViewMode")" } + if ($null -ne $blockUserSettingID) { + $uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" } + X "$indent`t$(Esc-Xml $uid)" + } + X "$indent" +} + +function Emit-Order { + param($items, [string]$indent, [switch]$skipAuto, $blockViewMode = $null, $blockUserSettingID = $null) + $hasItems = $items -and $items.Count -gt 0 + $hasBlockMeta = ($null -ne $blockViewMode) -or ($null -ne $blockUserSettingID) + if (-not $hasItems -and -not $hasBlockMeta) { return } + X "$indent" + foreach ($item in $items) { + if ($item -is [string]) { + if ($item -eq "Auto") { if (-not $skipAuto) { X "$indent`t" } } + else { + $parts = $item -split '\s+' + $field = $parts[0] + $dir = "Asc" + if ($parts.Count -gt 1 -and $parts[1] -match '^(?i)(desc|убыв)') { $dir = "Desc" } + elseif ($parts.Count -gt 1 -and $parts[1] -match '^(?i)(asc|возр)') { $dir = "Asc" } + X "$indent`t" + X "$indent`t`t$(Esc-Xml $field)" + X "$indent`t`t$dir" + X "$indent`t" + } + } else { + if ($item.field -eq "Auto" -or $item.type -eq "auto") { if (-not $skipAuto) { X "$indent`t" }; continue } + $dir = if ($item.direction) { "$($item.direction)" } else { "Asc" } + if ($dir -match '^(?i)(desc|убыв)') { $dir = "Desc" } elseif ($dir -match '^(?i)(asc|возр)') { $dir = "Asc" } + X "$indent`t" + if ($item.use -eq $false) { X "$indent`t`tfalse" } + X "$indent`t`t$(Esc-Xml "$($item.field)")" + X "$indent`t`t$dir" + if ($item.viewMode) { X "$indent`t`t$(Esc-Xml "$($item.viewMode)")" } + X "$indent`t" + } + } + if ($null -ne $blockViewMode) { X "$indent`t$(Esc-Xml "$blockViewMode")" } + if ($null -ne $blockUserSettingID) { + $uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" } + X "$indent`t$(Esc-Xml $uid)" + } + X "$indent" +} + +function Emit-AppearanceValue { + param([string]$key, $val, [string]$indent) + X "$indent" + function _HasKey { param($o, [string]$k) + if ($o -is [PSCustomObject]) { return [bool]$o.PSObject.Properties[$k] } + if ($o -is [System.Collections.IDictionary]) { return $o.Contains($k) } + return $false + } + function _Get { param($o, [string]$k) + if ($o -is [PSCustomObject]) { return $o.$k } + if ($o -is [System.Collections.IDictionary]) { return $o[$k] } + return $null + } + $isTopLevelLine = (_HasKey $val '@type') -and ("$(_Get $val '@type')" -eq 'Line') + $useWrapper = $false + $innerVal = $val + $nestedItems = $null + if ($isTopLevelLine) { + if ((_HasKey $val 'use') -and ((_Get $val 'use') -eq $false)) { $useWrapper = $true } + if (_HasKey $val 'items') { $nestedItems = (_Get $val 'items') } + } elseif ((_HasKey $val 'value') -and (($val -is [PSCustomObject]) -or ($val -is [System.Collections.IDictionary]))) { + $innerVal = (_Get $val 'value') + if ((_HasKey $val 'use') -and ((_Get $val 'use') -eq $false)) { $useWrapper = $true } + if (_HasKey $val 'items') { $nestedItems = (_Get $val 'items') } + } + if ($useWrapper) { X "$indent`tfalse" } + X "$indent`t$(Esc-Xml $key)" + $isFontDict = $false + if ($innerVal -is [PSCustomObject]) { + $tProp = $innerVal.PSObject.Properties['@type'] + if ($tProp -and "$($tProp.Value)" -eq 'Font') { $isFontDict = $true } + } elseif ($innerVal -is [System.Collections.IDictionary]) { + if ($innerVal.Contains('@type') -and "$($innerVal['@type'])" -eq 'Font') { $isFontDict = $true } + } + $isLineDict = $false + if (_HasKey $innerVal '@type') { $isLineDict = ("$(_Get $innerVal '@type')" -eq 'Line') } + $isDict = ($innerVal -is [hashtable]) -or ($innerVal -is [System.Collections.IDictionary]) -or ($innerVal -is [PSCustomObject]) + if ($isLineDict) { + $lw = if (_HasKey $innerVal 'width') { _Get $innerVal 'width' } else { 0 } + $lg = if (_HasKey $innerVal 'gap') { if ((_Get $innerVal 'gap')) { 'true' } else { 'false' } } else { 'false' } + $ls = if (_HasKey $innerVal 'style') { "$(_Get $innerVal 'style')" } else { 'None' } + X "$indent`t" + X "$indent`t`t$(Esc-Xml $ls)" + X "$indent`t" + } elseif ($isFontDict) { + $attrParts = @() + foreach ($attrName in @('ref','faceName','height','bold','italic','underline','strikeout','kind','scale')) { + $av = $null + if ($innerVal -is [PSCustomObject]) { $ap = $innerVal.PSObject.Properties[$attrName]; if ($ap) { $av = $ap.Value } } + else { if ($innerVal.Contains($attrName)) { $av = $innerVal[$attrName] } } + if ($null -ne $av) { $attrParts += "$attrName=`"$(Esc-Xml "$av")`"" } + } + X "$indent`t" + } elseif ($isDict) { + Emit-MLText -tag "dcscor:value" -text $innerVal -indent "$indent`t" + } else { + $actualVal = "$innerVal" + $keyTypeMap = @{ + 'Размещение' = 'dcscor:DataCompositionTextPlacementType' + 'ГоризонтальноеПоложение' = 'v8ui:HorizontalAlign' + 'ВертикальноеПоложение' = 'v8ui:VerticalAlign' + 'ОриентацияТекста' = 'xs:decimal' + 'РасположениеИтогов' = 'dcscor:DataCompositionTotalPlacement' + 'ТипМакета' = 'dcsset:DataCompositionGroupTemplateType' + } + $keyType = $keyTypeMap[$key] + if ($keyType) { X "$indent`t$(Esc-Xml $actualVal)" } + elseif ($actualVal -match '^(style|web|win):') { X "$indent`t$(Esc-Xml $actualVal)" } + elseif ($actualVal -eq "true" -or $actualVal -eq "false") { X "$indent`t$actualVal" } + elseif ($key -eq "Текст" -or $key -eq "Заголовок" -or $key -eq "Формат") { Emit-MLText -tag "dcscor:value" -text $actualVal -indent "$indent`t" } + elseif ($actualVal -match '^-?\d+(\.\d+)?$') { X "$indent`t$actualVal" } + elseif ($key -eq 'ЦветТекста' -or $key -eq 'ЦветФона' -or $key -eq 'ЦветГраницы') { X "$indent`t$(Esc-Xml $actualVal)" } + else { X "$indent`t$(Esc-Xml $actualVal)" } + } + if ($nestedItems) { + $niProps = if ($nestedItems -is [PSCustomObject]) { $nestedItems.PSObject.Properties } else { $null } + if ($niProps) { foreach ($np in $niProps) { Emit-AppearanceValue -key $np.Name -val $np.Value -indent "$indent`t" } } + elseif ($nestedItems -is [System.Collections.IDictionary]) { foreach ($nk in $nestedItems.Keys) { Emit-AppearanceValue -key $nk -val $nestedItems[$nk] -indent "$indent`t" } } + } + X "$indent" +} + +function Emit-ConditionalAppearance { + param($items, [string]$indent, $blockViewMode = $null, $blockUserSettingID = $null) + $hasItems = $items -and $items.Count -gt 0 + $hasBlockMeta = ($null -ne $blockViewMode) -or ($null -ne $blockUserSettingID) + if (-not $hasItems -and -not $hasBlockMeta) { return } + X "$indent" + foreach ($ca in $items) { + X "$indent`t" + if ($ca.use -eq $false) { X "$indent`t`tfalse" } + if ($ca.selection -and $ca.selection.Count -gt 0) { + X "$indent`t`t" + foreach ($sel in $ca.selection) { + X "$indent`t`t`t" + X "$indent`t`t`t`t$(Esc-Xml "$sel")" + X "$indent`t`t`t" + } + X "$indent`t`t" + } else { X "$indent`t`t" } + if ($ca.filter -and $ca.filter.Count -gt 0) { Emit-Filter -items $ca.filter -indent "$indent`t`t" } + else { X "$indent`t`t" } + if ($ca.appearance) { + X "$indent`t`t" + foreach ($prop in $ca.appearance.PSObject.Properties) { Emit-AppearanceValue -key $prop.Name -val $prop.Value -indent "$indent`t`t`t" } + X "$indent`t`t" + } + if ($ca.presentation) { + if ($ca.presentation -is [hashtable] -or $ca.presentation -is [System.Collections.IDictionary] -or $ca.presentation -is [PSCustomObject]) { Emit-MLText -tag "dcsset:presentation" -text $ca.presentation -indent "$indent`t`t" } + else { X "$indent`t`t$(Esc-Xml "$($ca.presentation)")" } + } + if ($ca.viewMode) { X "$indent`t`t$(Esc-Xml "$($ca.viewMode)")" } + if ($ca.userSettingID) { + $uid = if ("$($ca.userSettingID)" -eq "auto") { New-Guid-String } else { "$($ca.userSettingID)" } + X "$indent`t`t$(Esc-Xml $uid)" + } + if ($ca.userSettingPresentation) { Emit-MLText -tag "dcsset:userSettingPresentation" -text $ca.userSettingPresentation -indent "$indent`t`t" } + if ($ca.useInDontUse -and $ca.useInDontUse.Count -gt 0) { + $useInOrder = @('group','hierarchicalGroup','overall','fieldsHeader','header','parameters','filter','resourceFieldsHeader','overallHeader','overallResourceFieldsHeader') + $set = @{} + foreach ($n in $ca.useInDontUse) { $set["$n"] = $true } + foreach ($n in $useInOrder) { + if ($set.ContainsKey($n)) { + $tag = "useIn" + ($n.Substring(0,1).ToUpper()) + ($n.Substring(1)) + X "$indent`t`tDontUse" + } + } + } + X "$indent`t" + } + if ($null -ne $blockViewMode) { X "$indent`t$(Esc-Xml "$blockViewMode")" } + if ($null -ne $blockUserSettingID) { + $uid = if ("$blockUserSettingID" -eq 'auto') { New-Guid-String } else { "$blockUserSettingID" } + X "$indent`t$(Esc-Xml $uid)" + } + X "$indent" +} + # --- 5. Type emitter --- $script:formTypeSynonyms = New-Object System.Collections.Hashtable @@ -2327,9 +2684,25 @@ $script:refRootSynonyms = @{ "РегистрБухгалтерии" = "AccountingRegister" "РегистрРасчета" = "CalculationRegister" "РегистрРасчёта" = "CalculationRegister" + "ЖурналДокументов" = "DocumentJournal" + "КритерийОтбора" = "FilterCriterion" } $script:enumValueSynonyms = @("EnumValue","ЗначениеПеречисления") +# Нормализация типа таблицы динсписка: "Справочник.Контрагенты" → "Catalog.Контрагенты". +# Прощающий ввод: принимаем рус-имя метаданных, переводим в платформенное. Уже англ — без изменений. +function Normalize-MetaTypeRef { + param([string]$ref) + if ([string]::IsNullOrEmpty($ref)) { return $ref } + $dot = $ref.IndexOf('.') + if ($dot -lt 1) { return $ref } + $root = $ref.Substring(0, $dot) + if ($script:refRootSynonyms.ContainsKey($root)) { + return $script:refRootSynonyms[$root] + $ref.Substring($dot) + } + return $ref +} + # Normalize a choiceList item value: returns @{ XsiType = "..."; Text = "..." } function Normalize-ChoiceValue { param($value) @@ -3056,15 +3429,48 @@ function Emit-Attributes { X "$inner" } - # Settings (for DynamicList) + # Settings (динамический список) if ($attr.settings) { + $st = $attr.settings X "$inner" $si = "$inner`t" - if ($attr.settings.mainTable) { X "$si$($attr.settings.mainTable)" } - $mq = if ($attr.settings.manualQuery -eq $true) { "true" } else { "false" } + # Порядок платформы: ManualQuery, DynamicDataRead, QueryText, Field*, MainTable, ListSettings + $hasQuery = $st.query -and "$($st.query)".Trim() + $mq = if ($hasQuery -or $st.manualQuery -eq $true) { "true" } else { "false" } X "$si$mq" - $ddr = if ($attr.settings.dynamicDataRead -eq $true) { "true" } else { "false" } + # DynamicDataRead: дефолт true; false только при явном отключении + $ddr = if ($st.dynamicDataRead -eq $false) { "false" } else { "true" } X "$si$ddr" + if ($hasQuery) { + $qtext = Resolve-QueryValue "$($st.query)" $script:queryBaseDir + X "$si$(Esc-Xml $qtext)" + } + # Явные поля набора (редко): override title/dataPath + if ($st.fields) { + foreach ($fld in $st.fields) { + X "$si" + $dp = if ($fld.dataPath) { $fld.dataPath } else { $fld.field } + X "$si`t$(Esc-Xml "$dp")" + X "$si`t$(Esc-Xml "$($fld.field)")" + if ($fld.title) { + X "$si`t" + Emit-MLItems -val $fld.title -indent "$si`t`t" + X "$si`t" + } + X "$si" + } + } + if ($st.mainTable) { X "$si$(Normalize-MetaTypeRef "$($st.mainTable)")" } + # ListSettings: filter/order/conditionalAppearance (skd-грамматика) + каноничные блок-GUID. + # Нет items → контейнеры всё равно эмитятся (blockMeta) = каноничный пустой скелет платформы. + $lsi = "$si`t" + X "$si" + Emit-Filter -items $st.filter -indent $lsi -blockViewMode 'Normal' -blockUserSettingID $script:CANON_FILTER_ID + Emit-Order -items $st.order -indent $lsi -blockViewMode 'Normal' -blockUserSettingID $script:CANON_ORDER_ID + Emit-ConditionalAppearance -items $st.conditionalAppearance -indent $lsi -blockViewMode 'Normal' -blockUserSettingID $script:CANON_CA_ID + X "$lsiNormal" + X "$lsi$($script:CANON_ITEMS_ID)" + X "$si" X "$inner" } diff --git a/.claude/skills/form-compile/scripts/form-compile.py b/.claude/skills/form-compile/scripts/form-compile.py index a5ab5c1a..be8064d1 100644 --- a/.claude/skills/form-compile/scripts/form-compile.py +++ b/.claude/skills/form-compile/scripts/form-compile.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# form-compile v1.38 — Compile 1C managed form from JSON or object metadata +# form-compile v1.39 — Compile 1C managed form from JSON or object metadata # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import argparse import copy @@ -1256,6 +1256,26 @@ def esc_xml(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') +# Базовая директория для @file-ссылок в query динсписка (устанавливается в main) +QUERY_BASE_DIR = None + + +def resolve_query_value(val, base_dir): + if not val.startswith('@'): + return val + file_path = val[1:] + if os.path.isabs(file_path): + candidates = [file_path] + else: + candidates = [os.path.join(base_dir or os.getcwd(), file_path), os.path.join(os.getcwd(), file_path)] + for c in candidates: + if os.path.exists(c): + with open(c, 'r', encoding='utf-8-sig') as f: + return f.read().rstrip() + print(f"Query file not found: {file_path} (searched: {', '.join(candidates)})", file=sys.stderr) + sys.exit(1) + + def emit_ml_items(lines, indent, val): # строка → один ru-элемент; объект {lang: text} → по элементу на язык if isinstance(val, dict): @@ -1280,10 +1300,395 @@ def emit_mltext(lines, indent, tag, text): lines.append(f"{indent}") +# Каноничные GUID пустых контейнеров ListSettings (умолчание платформы, ~90% форм). +CANON_FILTER_ID = 'dfcece9d-5077-440b-b6b3-45a5cb4538eb' +CANON_ORDER_ID = '88619765-ccb3-46c6-ac52-38e9c992ebd4' +CANON_CA_ID = 'b75fecce-942b-4aed-abc9-e6a02e460fb3' +CANON_ITEMS_ID = '911b6018-f537-43e8-a417-da56b22f9aec' + + def new_uuid(): return str(uuid.uuid4()) +# ───────────────────────────────────────────────────────────────────────────── +# Настройки компоновщика ListSettings: filter/order/conditionalAppearance. +# Грамматика DSL и эмиссия dcsset скопированы из skd-compile (навыки автономны). +# ───────────────────────────────────────────────────────────────────────────── +COMPARISON_TYPES = { + '=': 'Equal', '<>': 'NotEqual', + '>': 'Greater', '>=': 'GreaterOrEqual', + '<': 'Less', '<=': 'LessOrEqual', + 'in': 'InList', 'notIn': 'NotInList', + 'inHierarchy': 'InHierarchy', 'inListByHierarchy': 'InListByHierarchy', + 'contains': 'Contains', 'notContains': 'NotContains', + 'beginsWith': 'BeginsWith', 'notBeginsWith': 'NotBeginsWith', + 'filled': 'Filled', 'notFilled': 'NotFilled', +} + +_REF_TYPE_RE = re.compile( + r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|' + r'БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|' + r'ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|' + r'InformationRegister|ExchangePlan)\.') + + +def parse_filter_shorthand(s): + result = {'field': '', 'op': 'Equal', 'value': None, 'use': True, + 'userSettingID': None, 'viewMode': None, 'presentation': None} + if re.search(r'@user', s): + result['userSettingID'] = 'auto' + s = re.sub(r'\s*@user', '', s) + if re.search(r'@off', s): + result['use'] = False + s = re.sub(r'\s*@off', '', s) + if re.search(r'@quickAccess', s): + result['viewMode'] = 'QuickAccess' + s = re.sub(r'\s*@quickAccess', '', s) + if re.search(r'@normal', s): + result['viewMode'] = 'Normal' + s = re.sub(r'\s*@normal', '', s) + if re.search(r'@inaccessible', s): + result['viewMode'] = 'Inaccessible' + s = re.sub(r'\s*@inaccessible', '', s) + s = s.strip() + op_patterns = ['<>', '>=', '<=', '=', '>', '<', + r'notIn\b', r'in\b', r'inHierarchy\b', r'inListByHierarchy\b', + r'notContains\b', r'contains\b', r'notBeginsWith\b', r'beginsWith\b', + r'notFilled\b', r'filled\b'] + op_joined = '|'.join(op_patterns) + m = re.match(r'^(.+?)\s+(' + op_joined + r')\s*(.*)?$', s) + if m: + result['field'] = m.group(1).strip() + result['op'] = m.group(2).strip() + val_part = m.group(3).strip() if m.group(3) else '' + if val_part and val_part != '_': + if val_part == 'true' or val_part == 'false': + result['value'] = (val_part == 'true') + result['valueType'] = 'xs:boolean' + elif re.match(r'^\d{4}-\d{2}-\d{2}T', val_part): + result['value'] = val_part + result['valueType'] = 'xs:dateTime' + elif re.match(r'^\d+(\.\d+)?$', val_part): + result['value'] = val_part + result['valueType'] = 'xs:decimal' + elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', val_part): + result['value'] = val_part + result['valueType'] = 'dcscor:DesignTimeValue' + else: + result['value'] = val_part + result['valueType'] = 'xs:string' + else: + result['field'] = s + return result + + +def _value_type_for(v, explicit=None): + if explicit: + return explicit + if isinstance(v, bool): + return 'xs:boolean' + if isinstance(v, (int, float)): + return 'xs:decimal' + vs = str(v) + if re.match(r'^\d{4}-\d{2}-\d{2}T', vs): + return 'xs:dateTime' + if re.match(r'^-?\d+(\.\d+)?$', vs): + return 'xs:decimal' + if _REF_TYPE_RE.match(vs): + return 'dcscor:DesignTimeValue' + return 'xs:string' + + +def emit_filter_item(lines, item, indent): + if item.get('group'): + g = str(item['group']) + group_type = {'And': 'AndGroup', 'Or': 'OrGroup', 'Not': 'NotGroup'}.get(g, g + 'Group') + lines.append(f'{indent}') + lines.append(f'{indent}\t{group_type}') + if item.get('items'): + for sub in item['items']: + if isinstance(sub, str): + parsed = parse_filter_shorthand(sub) + obj = {'field': parsed['field'], 'op': parsed['op']} + if parsed['use'] is False: + obj['use'] = False + if parsed['value'] is not None: + obj['value'] = parsed['value'] + if parsed.get('valueType'): + obj['valueType'] = parsed['valueType'] + if parsed.get('userSettingID'): + obj['userSettingID'] = parsed['userSettingID'] + if parsed.get('viewMode'): + obj['viewMode'] = parsed['viewMode'] + sub = obj + emit_filter_item(lines, sub, f'{indent}\t') + if item.get('presentation'): + emit_mltext(lines, f'{indent}\t', 'dcsset:presentation', item['presentation']) + if item.get('viewMode'): + lines.append(f'{indent}\t{esc_xml(str(item["viewMode"]))}') + if item.get('userSettingID'): + guid = new_uuid() if str(item['userSettingID']) == 'auto' else str(item['userSettingID']) + lines.append(f'{indent}\t{esc_xml(guid)}') + if item.get('userSettingPresentation'): + emit_mltext(lines, f'{indent}\t', 'dcsset:userSettingPresentation', item['userSettingPresentation']) + lines.append(f'{indent}') + return + + lines.append(f'{indent}') + if item.get('use') is False: + lines.append(f'{indent}\tfalse') + lines.append(f'{indent}\t{esc_xml(str(item.get("field", "")))}') + comp_type = COMPARISON_TYPES.get(str(item.get('op'))) + if not comp_type: + comp_type = str(item.get('op')) + lines.append(f'{indent}\t{esc_xml(comp_type)}') + val = item.get('value') + if isinstance(val, list): + if len(val) == 0: + lines.append(f'{indent}\t') + lines.append(f'{indent}\t\t') + lines.append(f'{indent}\t\t-1') + lines.append(f'{indent}\t') + else: + for v in val: + vt = _value_type_for(v, item.get('valueType')) + v_str = str(v).lower() if isinstance(v, bool) else esc_xml(str(v)) + lines.append(f'{indent}\t{v_str}') + elif val is not None: + vt = _value_type_for(val, item.get('valueType')) + v_str = str(val).lower() if isinstance(val, bool) else esc_xml(str(val)) + lines.append(f'{indent}\t{v_str}') + if item.get('presentation'): + emit_mltext(lines, f'{indent}\t', 'dcsset:presentation', item['presentation']) + if item.get('viewMode'): + lines.append(f'{indent}\t{esc_xml(str(item["viewMode"]))}') + if item.get('userSettingID'): + uid = new_uuid() if str(item['userSettingID']) == 'auto' else str(item['userSettingID']) + lines.append(f'{indent}\t{esc_xml(uid)}') + if item.get('userSettingPresentation'): + emit_mltext(lines, f'{indent}\t', 'dcsset:userSettingPresentation', item['userSettingPresentation']) + lines.append(f'{indent}') + + +def emit_filter(lines, items, indent, block_view_mode=None, block_user_setting_id=None): + has_items = bool(items) and len(items) > 0 + has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) + if not has_items and not has_block_meta: + return + lines.append(f'{indent}') + for item in (items or []): + if isinstance(item, str): + parsed = parse_filter_shorthand(item) + obj = {'field': parsed['field'], 'op': parsed['op']} + if parsed['use'] is False: + obj['use'] = False + if parsed['value'] is not None: + obj['value'] = parsed['value'] + if parsed.get('valueType'): + obj['valueType'] = parsed['valueType'] + if parsed.get('userSettingID'): + obj['userSettingID'] = parsed['userSettingID'] + if parsed.get('viewMode'): + obj['viewMode'] = parsed['viewMode'] + emit_filter_item(lines, obj, f'{indent}\t') + else: + emit_filter_item(lines, item, f'{indent}\t') + if block_view_mode is not None: + lines.append(f'{indent}\t{esc_xml(str(block_view_mode))}') + if block_user_setting_id is not None: + uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id) + lines.append(f'{indent}\t{esc_xml(uid)}') + lines.append(f'{indent}') + + +def emit_order(lines, items, indent, skip_auto=False, block_view_mode=None, block_user_setting_id=None): + has_items = bool(items) and len(items) > 0 + has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) + if not has_items and not has_block_meta: + return + lines.append(f'{indent}') + for item in (items or []): + if isinstance(item, str): + if item == 'Auto': + if not skip_auto: + lines.append(f'{indent}\t') + else: + parts = re.split(r'\s+', item) + field = parts[0] + direction = 'Asc' + if len(parts) > 1 and re.match(r'(?i)^(desc|убыв)', parts[1]): + direction = 'Desc' + elif len(parts) > 1 and re.match(r'(?i)^(asc|возр)', parts[1]): + direction = 'Asc' + lines.append(f'{indent}\t') + lines.append(f'{indent}\t\t{esc_xml(field)}') + lines.append(f'{indent}\t\t{direction}') + lines.append(f'{indent}\t') + else: + if item.get('field') == 'Auto' or item.get('type') == 'auto': + if not skip_auto: + lines.append(f'{indent}\t') + continue + direction = str(item['direction']) if item.get('direction') else 'Asc' + if re.match(r'(?i)^(desc|убыв)', direction): + direction = 'Desc' + elif re.match(r'(?i)^(asc|возр)', direction): + direction = 'Asc' + lines.append(f'{indent}\t') + if item.get('use') is False: + lines.append(f'{indent}\t\tfalse') + lines.append(f'{indent}\t\t{esc_xml(str(item.get("field", "")))}') + lines.append(f'{indent}\t\t{direction}') + if item.get('viewMode'): + lines.append(f'{indent}\t\t{esc_xml(str(item["viewMode"]))}') + lines.append(f'{indent}\t') + if block_view_mode is not None: + lines.append(f'{indent}\t{esc_xml(str(block_view_mode))}') + if block_user_setting_id is not None: + uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id) + lines.append(f'{indent}\t{esc_xml(uid)}') + lines.append(f'{indent}') + + +def emit_appearance_value(lines, key, val, indent): + lines.append(f'{indent}') + + def _has_key(o, k): + return isinstance(o, dict) and (k in o) + + def _get(o, k): + return o.get(k) if isinstance(o, dict) else None + + is_top_level_line = _has_key(val, '@type') and (str(_get(val, '@type')) == 'Line') + use_wrapper = False + inner_val = val + nested_items = None + if is_top_level_line: + if _has_key(val, 'use') and (_get(val, 'use') is False): + use_wrapper = True + if _has_key(val, 'items'): + nested_items = _get(val, 'items') + elif _has_key(val, 'value') and isinstance(val, dict): + inner_val = _get(val, 'value') + if _has_key(val, 'use') and (_get(val, 'use') is False): + use_wrapper = True + if _has_key(val, 'items'): + nested_items = _get(val, 'items') + if use_wrapper: + lines.append(f'{indent}\tfalse') + lines.append(f'{indent}\t{esc_xml(key)}') + + is_font_dict = isinstance(inner_val, dict) and inner_val.get('@type') is not None and str(inner_val.get('@type')) == 'Font' + is_line_dict = _has_key(inner_val, '@type') and (str(_get(inner_val, '@type')) == 'Line') + is_dict = isinstance(inner_val, dict) + if is_line_dict: + lw = _get(inner_val, 'width') if _has_key(inner_val, 'width') else 0 + lg = ('true' if _get(inner_val, 'gap') else 'false') if _has_key(inner_val, 'gap') else 'false' + ls = str(_get(inner_val, 'style')) if _has_key(inner_val, 'style') else 'None' + lines.append(f'{indent}\t') + lines.append(f'{indent}\t\t{esc_xml(ls)}') + lines.append(f'{indent}\t') + elif is_font_dict: + attr_parts = [] + for attr_name in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'): + if attr_name in inner_val: + av = inner_val[attr_name] + if av is not None: + attr_parts.append(f'{attr_name}="{esc_xml(str(av))}"') + lines.append(f'{indent}\t') + elif is_dict: + emit_mltext(lines, f'{indent}\t', 'dcscor:value', inner_val) + else: + actual_val = str(inner_val) + key_type_map = { + 'Размещение': 'dcscor:DataCompositionTextPlacementType', + 'ГоризонтальноеПоложение': 'v8ui:HorizontalAlign', + 'ВертикальноеПоложение': 'v8ui:VerticalAlign', + 'ОриентацияТекста': 'xs:decimal', + 'РасположениеИтогов': 'dcscor:DataCompositionTotalPlacement', + 'ТипМакета': 'dcsset:DataCompositionGroupTemplateType', + } + key_type = key_type_map.get(key) + if key_type: + lines.append(f'{indent}\t{esc_xml(actual_val)}') + elif re.match(r'^(style|web|win):', actual_val): + lines.append(f'{indent}\t{esc_xml(actual_val)}') + elif actual_val == 'true' or actual_val == 'false': + lines.append(f'{indent}\t{actual_val}') + elif key == 'Текст' or key == 'Заголовок' or key == 'Формат': + emit_mltext(lines, f'{indent}\t', 'dcscor:value', actual_val) + elif re.match(r'^-?\d+(\.\d+)?$', actual_val): + lines.append(f'{indent}\t{actual_val}') + elif key == 'ЦветТекста' or key == 'ЦветФона' or key == 'ЦветГраницы': + lines.append(f'{indent}\t{esc_xml(actual_val)}') + else: + lines.append(f'{indent}\t{esc_xml(actual_val)}') + if nested_items: + if isinstance(nested_items, dict): + for nk, nv in nested_items.items(): + emit_appearance_value(lines, nk, nv, f'{indent}\t') + lines.append(f'{indent}') + + +def emit_conditional_appearance(lines, items, indent, block_view_mode=None, block_user_setting_id=None): + has_items = bool(items) and len(items) > 0 + has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) + if not has_items and not has_block_meta: + return + lines.append(f'{indent}') + for ca in (items or []): + lines.append(f'{indent}\t') + if ca.get('use') is False: + lines.append(f'{indent}\t\tfalse') + if ca.get('selection') and len(ca['selection']) > 0: + lines.append(f'{indent}\t\t') + for sel in ca['selection']: + lines.append(f'{indent}\t\t\t') + lines.append(f'{indent}\t\t\t\t{esc_xml(str(sel))}') + lines.append(f'{indent}\t\t\t') + lines.append(f'{indent}\t\t') + else: + lines.append(f'{indent}\t\t') + if ca.get('filter') and len(ca['filter']) > 0: + emit_filter(lines, ca['filter'], f'{indent}\t\t') + else: + lines.append(f'{indent}\t\t') + if ca.get('appearance'): + lines.append(f'{indent}\t\t') + for k, v in ca['appearance'].items(): + emit_appearance_value(lines, k, v, f'{indent}\t\t\t') + lines.append(f'{indent}\t\t') + if ca.get('presentation'): + if isinstance(ca['presentation'], dict): + emit_mltext(lines, f'{indent}\t\t', 'dcsset:presentation', ca['presentation']) + else: + lines.append(f'{indent}\t\t{esc_xml(str(ca["presentation"]))}') + if ca.get('viewMode'): + lines.append(f'{indent}\t\t{esc_xml(str(ca["viewMode"]))}') + if ca.get('userSettingID'): + uid = new_uuid() if str(ca['userSettingID']) == 'auto' else str(ca['userSettingID']) + lines.append(f'{indent}\t\t{esc_xml(uid)}') + if ca.get('userSettingPresentation'): + emit_mltext(lines, f'{indent}\t\t', 'dcsset:userSettingPresentation', ca['userSettingPresentation']) + if ca.get('useInDontUse') and len(ca['useInDontUse']) > 0: + use_in_order = ['group', 'hierarchicalGroup', 'overall', 'fieldsHeader', 'header', + 'parameters', 'filter', 'resourceFieldsHeader', 'overallHeader', + 'overallResourceFieldsHeader'] + sset = {str(n): True for n in ca['useInDontUse']} + for n in use_in_order: + if n in sset: + tag = 'useIn' + n[0].upper() + n[1:] + lines.append(f'{indent}\t\tDontUse') + lines.append(f'{indent}\t') + if block_view_mode is not None: + lines.append(f'{indent}\t{esc_xml(str(block_view_mode))}') + if block_user_setting_id is not None: + uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id) + lines.append(f'{indent}\t{esc_xml(uid)}') + lines.append(f'{indent}') + + def write_utf8_bom(path, content): with open(path, 'w', encoding='utf-8-sig', newline='') as f: f.write(content) @@ -1440,10 +1845,25 @@ REF_ROOT_SYNONYMS = { "РегистрБухгалтерии": "AccountingRegister", "РегистрРасчета": "CalculationRegister", "РегистрРасчёта": "CalculationRegister", + "ЖурналДокументов": "DocumentJournal", + "КритерийОтбора": "FilterCriterion", } ENUM_VALUE_SYNONYMS = {"EnumValue", "ЗначениеПеречисления"} +def normalize_meta_type_ref(ref): + # "Справочник.Контрагенты" → "Catalog.Контрагенты"; уже англ — без изменений + if not ref: + return ref + dot = ref.find('.') + if dot < 1: + return ref + root = ref[:dot] + if root in REF_ROOT_SYNONYMS: + return REF_ROOT_SYNONYMS[root] + ref[dot:] + return ref + + def normalize_choice_value(value): """Returns dict {xsi_type, text} for a choiceList item value.""" if isinstance(value, bool): @@ -2669,17 +3089,45 @@ def emit_attributes(lines, attrs, indent): lines.append(f'{inner}\t') lines.append(f'{inner}') - # Settings (for DynamicList) + # Settings (динамический список) if attr.get('settings'): s = attr['settings'] lines.append(f'{inner}') si = f'{inner}\t' - if s.get('mainTable'): - lines.append(f'{si}{s["mainTable"]}') - mq = 'true' if s.get('manualQuery') else 'false' + # Порядок платформы: ManualQuery, DynamicDataRead, QueryText, Field*, MainTable, ListSettings + has_query = bool(s.get('query') and str(s['query']).strip()) + mq = 'true' if (has_query or s.get('manualQuery')) else 'false' lines.append(f'{si}{mq}') - ddr = 'true' if s.get('dynamicDataRead') else 'false' + # DynamicDataRead: дефолт true; false только при явном отключении + ddr = 'false' if s.get('dynamicDataRead') is False else 'true' lines.append(f'{si}{ddr}') + if has_query: + qtext = resolve_query_value(str(s['query']), QUERY_BASE_DIR) + lines.append(f'{si}{esc_xml(qtext)}') + # Явные поля набора (редко): override title/dataPath + if s.get('fields'): + for fld in s['fields']: + lines.append(f'{si}') + dp = fld.get('dataPath') or fld.get('field') + lines.append(f'{si}\t{esc_xml(str(dp))}') + lines.append(f'{si}\t{esc_xml(str(fld.get("field", "")))}') + if fld.get('title'): + lines.append(f'{si}\t') + emit_ml_items(lines, f'{si}\t\t', fld['title']) + lines.append(f'{si}\t') + lines.append(f'{si}') + if s.get('mainTable'): + lines.append(f'{si}{normalize_meta_type_ref(str(s["mainTable"]))}') + # ListSettings: filter/order/conditionalAppearance (skd-грамматика) + каноничные блок-GUID. + # Нет items → контейнеры всё равно эмитятся (blockMeta) = каноничный пустой скелет платформы. + lsi = f'{si}\t' + lines.append(f'{si}') + emit_filter(lines, s.get('filter'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_FILTER_ID) + emit_order(lines, s.get('order'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_ORDER_ID) + emit_conditional_appearance(lines, s.get('conditionalAppearance'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_CA_ID) + lines.append(f'{lsi}Normal') + lines.append(f'{lsi}{CANON_ITEMS_ID}') + lines.append(f'{si}') lines.append(f'{inner}') lines.append(f'{indent}\t') @@ -3047,6 +3495,8 @@ def main(): with open(json_path, 'r', encoding='utf-8-sig') as f: defn = json.load(f) + global QUERY_BASE_DIR + QUERY_BASE_DIR = os.path.dirname(os.path.abspath(json_path)) # --- 1b. Pre-pass: synonyms, main attribute inference, heuristics, autoCmdBar extraction --- def _normalize_synonyms(el): diff --git a/.claude/skills/form-decompile/scripts/form-decompile.ps1 b/.claude/skills/form-decompile/scripts/form-decompile.ps1 index 475eb44f..4db284d9 100644 --- a/.claude/skills/form-decompile/scripts/form-decompile.ps1 +++ b/.claude/skills/form-decompile/scripts/form-decompile.ps1 @@ -1,4 +1,4 @@ -# form-decompile v0.18 — Decompile 1C managed Form.xml to JSON DSL (draft) +# form-decompile v0.21 — Decompile 1C managed Form.xml to JSON DSL (draft) # Source: https://github.com/Nikolay-Shirokov/cc-1c-skills # ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью. param( @@ -36,11 +36,80 @@ $NS_V8 = "http://v8.1c.ru/8.1/data/core" $NS_XR = "http://v8.1c.ru/8.3/xcf/readable" $NS_XSI = "http://www.w3.org/2001/XMLSchema-instance" +$NS_DCSSET = "http://v8.1c.ru/8.1/data-composition-system/settings" +$NS_DCSSCH = "http://v8.1c.ru/8.1/data-composition-system/schema" +$NS_DCSCOR = "http://v8.1c.ru/8.1/data-composition-system/core" +$NS_V8UI = "http://v8.1c.ru/8.1/data/ui" + $ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable) $ns.AddNamespace("lf", $NS_LF) $ns.AddNamespace("v8", $NS_V8) $ns.AddNamespace("xr", $NS_XR) $ns.AddNamespace("xsi", $NS_XSI) +$ns.AddNamespace("dcsset", $NS_DCSSET) +$ns.AddNamespace("dcssch", $NS_DCSSCH) +$ns.AddNamespace("dcscor", $NS_DCSCOR) +$ns.AddNamespace("v8ui", $NS_V8UI) + +# Каноничные GUID пустых контейнеров ListSettings (умолчание платформы, ~90% форм). +# Если ListSettings = пустой скелет с этими GUID → декомпилятор опускает настройки вовсе, +# компилятор регенерит тот же скелет → чистый раундтрип. +$CANON_FILTER_ID = 'dfcece9d-5077-440b-b6b3-45a5cb4538eb' +$CANON_ORDER_ID = '88619765-ccb3-46c6-ac52-38e9c992ebd4' +$CANON_CA_ID = 'b75fecce-942b-4aed-abc9-e6a02e460fb3' +$CANON_ITEMS_ID = '911b6018-f537-43e8-a417-da56b22f9aec' + +# --- Вынос запроса динсписка в .sql рядом с output (зеркало skd-decompile) --- +$script:outputDir = $null +$script:outputBasename = $null +if ($OutputPath) { + $od = Split-Path -Parent $OutputPath + if (-not $od) { $od = (Get-Location).Path } + $script:outputDir = $od + $script:outputBasename = [System.IO.Path]::GetFileNameWithoutExtension($OutputPath) +} +$script:queryFilesAccumulator = @() +$script:queryFileNamesUsed = @{} + +# Запрос ≥3 строк + есть outputDir → вынести в `-.sql`, вернуть "@file". +function Maybe-ExternalizeQuery { + param([string]$queryText, [string]$listName) + if (-not $queryText) { return $queryText } + if (-not $script:outputDir) { return $queryText } + $lineCount = ([regex]::Matches($queryText, "`n")).Count + 1 + if ($lineCount -lt 3) { return $queryText } + $safe = ($listName -replace '[^\w\-]', '_'); if (-not $safe) { $safe = 'query' } + $prefix = if ($script:outputBasename) { "$($script:outputBasename)-" } else { '' } + $fileName = "$prefix$safe.sql" + $suffix = 1 + while ($script:queryFileNamesUsed.ContainsKey($fileName)) { $suffix++; $fileName = "$prefix$safe`_$suffix.sql" } + $script:queryFileNamesUsed[$fileName] = $true + $script:queryFilesAccumulator += [ordered]@{ fileName = $fileName; text = $queryText } + return "@$fileName" +} +function Save-QueryFiles { + if ($script:queryFilesAccumulator.Count -eq 0) { return } + if (-not $script:outputDir) { return } + $enc = New-Object System.Text.UTF8Encoding($false) + foreach ($qf in $script:queryFilesAccumulator) { + [System.IO.File]::WriteAllText((Join-Path $script:outputDir $qf.fileName), $qf.text, $enc) + } + [Console]::Error.WriteLine("Saved $($script:queryFilesAccumulator.Count) external query file(s)") +} + +# Есть ли в ListSettings содержательные настройки (реальные items фильтра/порядка/ +# условного оформления/параметров)? Пустой скелет (только viewMode+GUID) → false: +# декомпилятор опускает настройки, компилятор регенерит каноничный скелет, harness +# нормализует GUID → чистый раундтрип. true → контент захватывается (см. ниже). +function Test-ListSettingsHasContent { + param($lsNode) + if (-not $lsNode) { return $false } + foreach ($cont in @('filter','order','conditionalAppearance','dataParameters')) { + $cn = $lsNode.SelectSingleNode("dcsset:$cont", $ns) + if ($cn -and $cn.SelectSingleNode("dcsset:item", $ns)) { return $true } + } + return $false +} # --- 1b. Ring-3 scan: конструкции вне зоны поддержки (draft list) --- function Fail-Ring3 { @@ -184,6 +253,464 @@ function Convert-TypedValue { } } +# ===================================================================== +# Захват настроек компоновщика динамического списка (ListSettings): +# filter / order / conditionalAppearance. Логика портирована из навыка +# skd-decompile (Build-FilterItem/Build-Order/Build-ConditionalAppearance +# и сериализаторы оформления). Механизм New-Sentinel/Add-Warning из skd +# заменён на запись в stderr + пропуск элемента (form-decompile — draft, +# скрипт не падает на непокрытых конструкциях). +# ===================================================================== + +# Прочитать дочерний скаляр по xpath (с $ns). Аналог skd Get-Text. +function Get-Text { + param($node, [string]$xpath) + if (-not $node) { return $null } + if ([string]::IsNullOrEmpty($xpath)) { return $node.InnerText } + $n = $node.SelectSingleNode($xpath, $ns) + if ($n) { return $n.InnerText } else { return $null } +} + +# Мультиязычный текст (LocalStringType) → string (ru) или ordered hash. +# Алиас на уже существующий Get-LangText (тот же контракт). +function Get-MLText { param($node) return (Get-LangText $node) } + +# Презентация: либо мультиязычный LocalStringType, либо плоский xs:string. +# Get-MLText даёт $null для xs:string (нет v8:item) → откат к InnerText. +function Get-PresText { + param($node) + if (-not $node) { return $null } + $ml = Get-MLText $node + if ($null -ne $ml) { return $ml } + if ($node.InnerText) { return $node.InnerText } + return $null +} + +# Снять namespace-префикс с xsi:type ("dcsset:Foo" → "Foo") +function Get-LocalXsiType { + param($node) + if (-not $node) { return $null } + $t = $node.GetAttribute("type", $NS_XSI) + if ($t -match ':(.+)$') { return $matches[1] } + return $t +} + +# Шрифт оформления → объект {@type:Font, ...} (bit-perfect для compile). +function Get-FontValue { + param($valNode) + $f = [ordered]@{ '@type' = 'Font' } + foreach ($attrName in @('ref','faceName','height','bold','italic','underline','strikeout','kind','scale')) { + $a = $valNode.Attributes[$attrName] + if ($null -ne $a) { $f[$attrName] = $a.Value } + } + return $f +} + +# Линия (граница) оформления → объект {@type:Line, width, gap, style}. +function Get-LineValue { + param($valNode) + $obj = [ordered]@{ '@type' = 'Line' } + $w = $valNode.GetAttribute("width") + $g = $valNode.GetAttribute("gap") + if ($w -ne '') { $obj['width'] = if ($w -match '^-?\d+$') { [int]$w } else { $w } } + if ($g -ne '') { $obj['gap'] = ($g -eq 'true') } + $styleNode = $valNode.SelectSingleNode("v8ui:style", $ns) + if ($styleNode) { $obj['style'] = $styleNode.InnerText } + return $obj +} + +# Прочитать в JSON-значение: Font/Line/multilang/raw text. +function Read-AppearanceValueNode { + param($valNode) + if (-not $valNode) { return $null } + $vt = Get-LocalXsiType $valNode + if ($vt -eq 'LocalStringType') { return (Get-MLText $valNode) } + if ($vt -eq 'Font') { return (Get-FontValue $valNode) } + if ($vt -eq 'Line') { return (Get-LineValue $valNode) } + return $valNode.InnerText +} + +# Обратная карта comparisonType → короткий оператор фильтра (зеркало skd). +$script:filterOpMap = @{ + 'Equal'='='; 'NotEqual'='<>'; 'Greater'='>'; 'GreaterOrEqual'='>='; + 'Less'='<'; 'LessOrEqual'='<='; 'InList'='in'; 'NotInList'='notIn'; + 'InHierarchy'='inHierarchy'; 'InListByHierarchy'='inListByHierarchy'; + 'Contains'='contains'; 'NotContains'='notContains'; + 'BeginsWith'='beginsWith'; 'NotBeginsWith'='notBeginsWith'; + 'Filled'='filled'; 'NotFilled'='notFilled' +} + +# Render filter value node → shorthand-acceptable scalar string +function Get-FilterValue { + param($valNode) + if (-not $valNode) { return '_' } + $nil = $valNode.GetAttribute("nil", $NS_XSI) + if ($nil -eq 'true') { return '_' } + $vType = Get-LocalXsiType $valNode + if ($vType -eq 'DesignTimeValue') { return $valNode.InnerText } + if ($vType -eq 'LocalStringType') { return (Get-MLText $valNode) } + $txt = $valNode.InnerText + if (-not $txt) { return '_' } + return $txt +} + +# Get-FilterValue + xsi:type значения (для valueType, например dcscor:Field). +function Get-FilterValueWithType { + param($valNode) + if (-not $valNode) { return @{ value = '_'; type = $null } } + $rawType = $valNode.GetAttribute("type", $NS_XSI) + $nil = $valNode.GetAttribute("nil", $NS_XSI) + if ($nil -eq 'true') { return @{ value = '_'; type = $null } } + $vType = Get-LocalXsiType $valNode + if ($vType -eq 'LocalStringType') { + return @{ value = (Get-MLText $valNode); type = $rawType } + } + $txt = $valNode.InnerText + if (-not $txt) { return @{ value = '_'; type = $rawType } } + if ($vType -eq 'boolean') { return @{ value = ($txt -eq 'true'); type = $rawType } } + if ($vType -eq 'decimal') { + if ($txt -match '^-?\d+$') { return @{ value = [int]$txt; type = $rawType } } + return @{ value = [double]$txt; type = $rawType } + } + return @{ value = $txt; type = $rawType } +} + +# Convert filter item node → shorthand string или object form (рекурсивно для групп). +function Build-FilterItem { + param($itemNode, [string]$loc) + $xtype = Get-LocalXsiType $itemNode + if ($xtype -eq 'FilterItemGroup') { + $gt = Get-Text $itemNode "dcsset:groupType" + $groupName = switch ($gt) { 'OrGroup' { 'Or' } 'NotGroup' { 'Not' } default { 'And' } } + $items = @() + foreach ($c in $itemNode.SelectNodes("dcsset:item", $ns)) { + $bi = (Build-FilterItem -itemNode $c -loc "$loc/item") + if ($null -ne $bi) { $items += $bi } + } + $gObj = [ordered]@{ group = $groupName; items = $items } + $gPresNode = $itemNode.SelectSingleNode("dcsset:presentation", $ns) + if ($gPresNode) { + $gPres = Get-MLText $gPresNode + if (-not $gPres) { $gPres = $gPresNode.InnerText } + if ($gPres) { $gObj['presentation'] = $gPres } + } + $gVMNode = $itemNode.SelectSingleNode("dcsset:viewMode", $ns) + if ($gVMNode) { $gObj['viewMode'] = $gVMNode.InnerText } + $gUSID = Get-Text $itemNode "dcsset:userSettingID" + if ($gUSID) { $gObj['userSettingID'] = 'auto' } + $gUSPN = $itemNode.SelectSingleNode("dcsset:userSettingPresentation", $ns) + if ($gUSPN) { + $gUSP = Get-PresText $gUSPN + if ($gUSP) { $gObj['userSettingPresentation'] = $gUSP } + } + return $gObj + } + if ($xtype -ne 'FilterItemComparison') { + [Console]::Error.WriteLine("form-decompile: пропущен фильтр неизвестного типа '$xtype' (path: $loc)") + return $null + } + $leftNode = $itemNode.SelectSingleNode("dcsset:left", $ns) + $field = if ($leftNode) { $leftNode.InnerText } else { $null } + $ct = Get-Text $itemNode "dcsset:comparisonType" + $op = $script:filterOpMap[$ct] + if (-not $op) { $op = $ct } + + $rightNodes = @($itemNode.SelectNodes("dcsset:right", $ns)) + $value = $null + $valueIsArrayFlag = $false + $valueTypeAttr = $null + if ($rightNodes.Count -eq 1) { + $rn = $rightNodes[0] + if ((Get-LocalXsiType $rn) -eq 'ValueListType') { + $value = @() + $valueIsArrayFlag = $true + } else { + $vt = Get-FilterValueWithType $rn + $value = $vt.value + $autoDetectsDTV = ($vt.type -eq 'dcscor:DesignTimeValue') -and ` + ("$($vt.value)" -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') + if ($vt.type -and $vt.type -notmatch '^xs:' -and -not $autoDetectsDTV) { + $valueTypeAttr = $vt.type + } + } + } elseif ($rightNodes.Count -gt 1) { + $arr = @() + $rawTypes = @() + foreach ($rn in $rightNodes) { + $arr += (Get-FilterValue $rn) + $rawTypes += $rn.GetAttribute("type", $NS_XSI) + } + $value = $arr + $valueIsArrayFlag = $true + $uniqTypes = @($rawTypes | Sort-Object -Unique) + if ($uniqTypes.Count -eq 1 -and $uniqTypes[0]) { + $autoDetectsDTV = ($uniqTypes[0] -eq 'dcscor:DesignTimeValue') -and ` + ($arr.Count -gt 0) -and ` + (@($arr | Where-Object { "$_" -notmatch '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.' }).Count -eq 0) + if (-not $autoDetectsDTV) { + $valueTypeAttr = $uniqTypes[0] + } + } + } + + $use = Get-Text $itemNode "dcsset:use" + $userId = Get-Text $itemNode "dcsset:userSettingID" + $vmNode = $itemNode.SelectSingleNode("dcsset:viewMode", $ns) + $viewMode = if ($vmNode) { $vmNode.InnerText } else { $null } + $userPresNode = $itemNode.SelectSingleNode("dcsset:userSettingPresentation", $ns) + $fiPresNode = $itemNode.SelectSingleNode("dcsset:presentation", $ns) + $fiPres = $null + if ($fiPresNode) { + $fiPres = Get-MLText $fiPresNode + if (-not $fiPres) { $fiPres = $fiPresNode.InnerText } + } + + $flags = @() + if ($use -eq 'false') { $flags += '@off' } + if ($userId) { $flags += '@user' } + if ($viewMode -eq 'QuickAccess') { $flags += '@quickAccess' } + elseif ($viewMode -eq 'Inaccessible') { $flags += '@inaccessible' } + elseif ($viewMode -eq 'Normal') { $flags += '@normal' } + + $noValueOps = @('filled','notFilled') + + if ($userPresNode -or $valueIsArrayFlag -or $valueTypeAttr -or $fiPres) { + $obj = [ordered]@{ field = $field; op = $op } + if ($op -notin $noValueOps -and $null -ne $value) { + if ($valueIsArrayFlag) { + $arrAsList = New-Object System.Collections.ArrayList + foreach ($vv in @($value)) { [void]$arrAsList.Add($vv) } + $obj['value'] = $arrAsList + } else { + $obj['value'] = $value + } + } + if ($valueTypeAttr) { $obj['valueType'] = $valueTypeAttr } + if ($use -eq 'false') { $obj['use'] = $false } + if ($userId) { $obj['userSettingID'] = 'auto' } + if ($fiPres) { $obj['presentation'] = $fiPres } + if ($viewMode) { $obj['viewMode'] = $viewMode } + if ($userPresNode) { $obj['userSettingPresentation'] = Get-PresText $userPresNode } + return $obj + } + + $s = $field + if ($op -in $noValueOps) { + $s += " $op" + } else { + $vDisplay = '_' + if ($null -ne $value) { + if ($value -is [bool]) { $vDisplay = if ($value) { 'true' } else { 'false' } } + elseif ("$value" -ne '') { $vDisplay = "$value" } + } + $s += " $op $vDisplay" + } + if ($flags) { $s += ' ' + ($flags -join ' ') } + return $s +} + +# Рекурсивный хелпер одного элемента selection (для conditionalAppearance). +function Build-SelectionItem { + param($item, [string]$loc) + $xt = Get-LocalXsiType $item + if (-not $xt) { + $fName = Get-Text $item "dcsset:field" + if ($fName) { return $fName } + $fieldEl = $item.SelectSingleNode("dcsset:field", $ns) + if ($fieldEl) { return 'Auto' } + } + switch ($xt) { + 'SelectedItemAuto' { + $useV = Get-Text $item "dcsset:use" + if ($useV -eq 'false') { + return [ordered]@{ auto = $true; use = $false } + } + return 'Auto' + } + 'SelectedItemField' { + $fName = Get-Text $item "dcsset:field" + $titleNode = $item.SelectSingleNode("dcsset:lwsTitle", $ns) + $title = Get-MLText $titleNode + $vmN = $item.SelectSingleNode("dcsset:viewMode", $ns) + $useV = Get-Text $item "dcsset:use" + $useFalse = ($useV -eq 'false') + if ($title -or $vmN -or $useFalse) { + $obj = [ordered]@{ field = $fName } + if ($useFalse) { $obj['use'] = $false } + if ($title) { $obj['title'] = $title } + if ($vmN) { $obj['viewMode'] = $vmN.InnerText } + return $obj + } + return $fName + } + 'SelectedItemFolder' { + $titleNode = $item.SelectSingleNode("dcsset:lwsTitle", $ns) + $folderTitle = Get-MLText $titleNode + $inner = @() + foreach ($sub in $item.SelectNodes("dcsset:item", $ns)) { + $bi = (Build-SelectionItem -item $sub -loc "$loc/folder") + if ($null -ne $bi) { $inner += $bi } + } + $entry = [ordered]@{ folder = $folderTitle; items = $inner } + $folderField = Get-Text $item "dcsset:field" + if ($folderField) { $entry['field'] = $folderField } + $plN = $item.SelectSingleNode("dcsset:placement", $ns) + if ($plN -and $plN.InnerText -and $plN.InnerText -ne 'Auto') { + $entry['placement'] = $plN.InnerText + } + return $entry + } + default { + [Console]::Error.WriteLine("form-decompile: пропущен элемент selection неизвестного типа '$xt' (path: $loc)") + return $null + } + } +} + +# Build selection items array (для conditionalAppearance). +function Build-Selection { + param($selNode, [string]$loc) + if (-not $selNode) { return @() } + $out = @() + foreach ($it in $selNode.SelectNodes("dcsset:item", $ns)) { + $bi = (Build-SelectionItem -item $it -loc $loc) + if ($null -ne $bi) { $out += $bi } + } + return ,$out +} + +# Build order items array. +function Build-Order { + param($ordNode, [string]$loc) + if (-not $ordNode) { return @() } + $out = @() + foreach ($it in $ordNode.SelectNodes("dcsset:item", $ns)) { + $xt = Get-LocalXsiType $it + switch ($xt) { + 'OrderItemAuto' { $out += 'Auto' } + 'OrderItemField' { + $fn = Get-Text $it "dcsset:field" + $ot = Get-Text $it "dcsset:orderType" + $vmN = $it.SelectSingleNode("dcsset:viewMode", $ns) + $useV = Get-Text $it "dcsset:use" + $useFalse = ($useV -eq 'false') + if ($vmN -or $useFalse) { + $obj = [ordered]@{ field = $fn } + if ($useFalse) { $obj['use'] = $false } + if ($ot -eq 'Desc') { $obj['direction'] = 'desc' } + if ($vmN) { $obj['viewMode'] = $vmN.InnerText } + $out += $obj + } else { + if ($ot -eq 'Desc') { $out += "$fn desc" } else { $out += $fn } + } + } + default { + [Console]::Error.WriteLine("form-decompile: пропущен элемент сортировки неизвестного типа '$xt' (path: $loc)") + } + } + } + return ,$out +} + +# Build appearance dict из (Line/Font/multilang/nested items). +function Get-SettingsAppearance { + param($appNode) + if (-not $appNode) { return $null } + $dict = [ordered]@{} + foreach ($it in $appNode.SelectNodes("dcscor:item", $ns)) { + $pName = Get-Text $it "dcscor:parameter" + $val = $it.SelectSingleNode("dcscor:value", $ns) + if (-not $pName -or -not $val) { continue } + $rawVal = Read-AppearanceValueNode $val + $useV = Get-Text $it "dcscor:use" + $nestedItems = [ordered]@{} + foreach ($sub in $it.SelectNodes("dcscor:item", $ns)) { + $subName = Get-Text $sub "dcscor:parameter" + $subVal = $sub.SelectSingleNode("dcscor:value", $ns) + if (-not $subName) { continue } + $subRaw = Read-AppearanceValueNode $subVal + $subUse = Get-Text $sub "dcscor:use" + $subEntry = [ordered]@{ value = $subRaw } + if ($subUse -eq 'false') { $subEntry['use'] = $false } + $nestedItems[$subName] = $subEntry + } + $valIsLine = ($rawVal -is [System.Collections.IDictionary]) -and $rawVal.Contains('@type') -and ($rawVal['@type'] -eq 'Line') + if ($valIsLine) { + if ($useV -eq 'false') { $rawVal['use'] = $false } + if ($nestedItems.Count -gt 0) { $rawVal['items'] = $nestedItems } + $dict[$pName] = $rawVal + } elseif (($useV -eq 'false') -or ($nestedItems.Count -gt 0)) { + $wrap = [ordered]@{ value = $rawVal } + if ($useV -eq 'false') { $wrap['use'] = $false } + if ($nestedItems.Count -gt 0) { $wrap['items'] = $nestedItems } + $dict[$pName] = $wrap + } else { + $dict[$pName] = $rawVal + } + } + return $dict +} + +# Build conditionalAppearance array. +function Build-ConditionalAppearance { + param($caNode, [string]$loc) + if (-not $caNode) { return @() } + $out = @() + $i = 0 + foreach ($it in $caNode.SelectNodes("dcsset:item", $ns)) { + $entry = [ordered]@{} + $scopeNode = $it.SelectSingleNode("dcsset:scope", $ns) + if ($scopeNode -and $scopeNode.HasChildNodes) { + [Console]::Error.WriteLine("form-decompile: conditionalAppearance item имеет scope — не воспроизводится в DSL (path: $loc/$i/scope)") + } + $selNode = $it.SelectSingleNode("dcsset:selection", $ns) + if ($selNode -and $selNode.SelectNodes("dcsset:item", $ns).Count -gt 0) { + $entry['selection'] = Build-Selection -selNode $selNode -loc "$loc/$i/selection" + } + $filterNode = $it.SelectSingleNode("dcsset:filter", $ns) + if ($filterNode -and $filterNode.SelectNodes("dcsset:item", $ns).Count -gt 0) { + $f = @() + foreach ($fc in $filterNode.SelectNodes("dcsset:item", $ns)) { + $bi = (Build-FilterItem -itemNode $fc -loc "$loc/$i/filter") + if ($null -ne $bi) { $f += $bi } + } + $entry['filter'] = $f + } + $appNode = $it.SelectSingleNode("dcsset:appearance", $ns) + $ap = Get-SettingsAppearance $appNode + if ($ap -and $ap.Count -gt 0) { $entry['appearance'] = $ap } + $presNode = $it.SelectSingleNode("dcsset:presentation", $ns) + if ($presNode) { + $pres = Get-MLText $presNode + if (-not $pres) { $pres = $presNode.InnerText } + if ($pres) { $entry['presentation'] = $pres } + } + $vmN = $it.SelectSingleNode("dcsset:viewMode", $ns) + if ($vmN) { $entry['viewMode'] = $vmN.InnerText } + $usid = Get-Text $it "dcsset:userSettingID" + if ($usid) { $entry['userSettingID'] = 'auto' } + $uspN = $it.SelectSingleNode("dcsset:userSettingPresentation", $ns) + if ($uspN) { + $usp = Get-PresText $uspN + if ($usp) { $entry['userSettingPresentation'] = $usp } + } + $useV = Get-Text $it "dcsset:use" + if ($useV -eq 'false') { $entry['use'] = $false } + $useInDontUse = @() + foreach ($ch in $it.ChildNodes) { + if ($ch.NodeType -ne 'Element' -or $ch.NamespaceURI -ne $NS_DCSSET) { continue } + if ($ch.LocalName -match '^useIn(.+)$' -and $ch.InnerText -eq 'DontUse') { + $shortName = ($matches[1]).Substring(0, 1).ToLower() + ($matches[1]).Substring(1) + $useInDontUse += $shortName + } + } + if ($useInDontUse.Count -gt 0) { $entry['useInDontUse'] = $useInDontUse } + $out += $entry + $i++ + } + return ,$out +} + # Общие layout-свойства → в $obj (симметрично Emit-Layout компилятора). # Вызывается один раз для любого элемента. Height тут — пиксельная высота # (); Table хранит высоту в строках () и ловит её сам. @@ -614,6 +1141,58 @@ if ($attrsNode) { } if ($cols.Count -gt 0) { $ao['columns'] = @($cols) } } + # Settings динамического списка + $setNode = $a.SelectSingleNode("lf:Settings", $ns) + if ($setNode) { + $so = [ordered]@{} + $mt = Get-Child $setNode 'MainTable'; if ($mt) { $so['mainTable'] = $mt } + $qtNode = $setNode.SelectSingleNode("lf:QueryText", $ns) + if ($qtNode -and $qtNode.InnerText) { $so['query'] = Maybe-ExternalizeQuery -queryText $qtNode.InnerText -listName "$($ao['name'])" } + # DynamicDataRead: дефолт true → эмитим только false + if ((Get-Child $setNode 'DynamicDataRead') -eq 'false') { $so['dynamicDataRead'] = $false } + # Явные поля набора (редко, ~4.5%) — захват только при наличии Field + $fieldNodes = @($setNode.SelectNodes("lf:Field", $ns)) + if ($fieldNodes.Count -gt 0) { + $fields = New-Object System.Collections.ArrayList + foreach ($fn in $fieldNodes) { + $fo = [ordered]@{} + $fld = Get-Child $fn 'field' + $dp = Get-Child $fn 'dataPath' + if ($fld) { $fo['field'] = $fld } + if ($dp -and $dp -ne $fld) { $fo['dataPath'] = $dp } + $ftn = $fn.SelectSingleNode("dcssch:title", $ns) + if ($ftn) { $t = Get-LangText $ftn; if ($null -ne $t) { $fo['title'] = $t } } + [void]$fields.Add($fo) + } + $so['fields'] = @($fields) + } + # ListSettings: пустой скелет (только viewMode+GUID) опускаем — компилятор + # регенерит каноничный скелет. Захватываем только контейнеры с реальными + # dcsset:item (filter/order/conditionalAppearance) в формат компилятора. + $lsNode = $setNode.SelectSingleNode("lf:ListSettings", $ns) + if ($lsNode) { + $fNode = $lsNode.SelectSingleNode("dcsset:filter", $ns) + if ($fNode -and $fNode.SelectSingleNode("dcsset:item", $ns)) { + $flt = @() + foreach ($fc in $fNode.SelectNodes("dcsset:item", $ns)) { + $bi = (Build-FilterItem -itemNode $fc -loc "settings/filter") + if ($null -ne $bi) { $flt += $bi } + } + if ($flt.Count -gt 0) { $so['filter'] = @($flt) } + } + $oNode = $lsNode.SelectSingleNode("dcsset:order", $ns) + if ($oNode -and $oNode.SelectSingleNode("dcsset:item", $ns)) { + $ord = Build-Order -ordNode $oNode -loc "settings/order" + if (@($ord).Count -gt 0) { $so['order'] = @($ord) } + } + $caNode = $lsNode.SelectSingleNode("dcsset:conditionalAppearance", $ns) + if ($caNode -and $caNode.SelectSingleNode("dcsset:item", $ns)) { + $ca = Build-ConditionalAppearance -caNode $caNode -loc "settings/conditionalAppearance" + if (@($ca).Count -gt 0) { $so['conditionalAppearance'] = @($ca) } + } + } + if ($so.Count -gt 0) { $ao['settings'] = $so } + } [void]$attrs.Add($ao) } if ($attrs.Count -gt 0) { $dsl['attributes'] = @($attrs) } @@ -654,6 +1233,7 @@ if ($cmdsNode) { $json = ConvertTo-CompactJson -obj $dsl if ($OutputPath) { [System.IO.File]::WriteAllText($OutputPath, $json, (New-Object System.Text.UTF8Encoding($false))) + Save-QueryFiles Write-Host "form-decompile: $OutputPath" } else { Write-Output $json diff --git a/docs/form-dsl-spec.md b/docs/form-dsl-spec.md index 5edd8de4..899a46a5 100644 --- a/docs/form-dsl-spec.md +++ b/docs/form-dsl-spec.md @@ -500,6 +500,56 @@ Pages поддерживает `pagesRepresentation`: `None`, `TabsOnTop`, `Tabs | `savedData` | bool | Сохраняемые данные | | `fillChecking` | string | `Show`, `DontShow` | | `columns` | array | Колонки для ValueTable/ValueTree | +| `settings` | object | Настройки динамического списка (только `type: "DynamicList"`) | + +### settings — динамический список + +Для реквизита `type: "DynamicList"` объект `settings` описывает источник данных и настройки компоновщика (`ListSettings`). + +```json +{ "name": "Список", "type": "DynamicList", "main": true, + "settings": { + "mainTable": "Catalog.Контрагенты", + "query": "@Список.sql", + "dynamicDataRead": false, + "fields": [ { "field": "Отложен", "title": "Отложен" } ] + } } +``` + +| Ключ | Тип | Описание | +|------|-----|----------| +| `mainTable` | string | Основная таблица. Принимает рус-имена метаданных (`Справочник.X` → `Catalog.X`) | +| `query` | string | Текст запроса (`ManualQuery=true`). Поддерживает `@file.sql` (путь относительно JSON) | +| `dynamicDataRead` | bool | Динамическое считывание. **Умолчание `true`** — указывать только для отключения (`false`) | +| `fields` | array | Явные поля набора (редко): `{ field, dataPath?, title? }` — для переопределения заголовка. Обычно поля выводятся из запроса автоматически | +| `order` | array | Сортировка списка (см. ниже) | +| `filter` | array | Отбор списка (грамматика как в СКД) | +| `conditionalAppearance` | array | Условное оформление списка (грамматика как в СКД) | + +`ManualQuery` выводится из наличия `query` — отдельным ключом не задаётся. + +Пустой блок настроек компоновщика (`ListSettings`) генерируется автоматически (каноничный скелет платформы); указывать ничего не нужно. + +#### order / filter / conditionalAppearance + +Грамматика этих ключей идентична настройкам СКД — см. [skd-dsl-spec.md](skd-dsl-spec.md) (разделы filter / order / conditionalAppearance). Кратко: + +```json +"settings": { + "mainTable": "Catalog.Контрагенты", + "order": [ "Дата desc", "Наименование", "Auto" ], + "filter": [ "Организация = _ @off @user", "Сумма > 1000" ], + "conditionalAppearance": [ + { "filter": ["Просрочено = true"], "appearance": { "ЦветТекста": "web:Red" } } + ] +} +``` + +- **order** — строка `"Поле"` (asc) / `"Поле desc"` (синонимы `убыв`/`desc`, `возр`/`asc`) / `"Auto"`, либо объект `{ field, direction?, use?, viewMode? }`. +- **filter** — shorthand `"Поле оператор значение @флаги"` (`@off`, `@user`, `@quickAccess`, `@normal`, `@inaccessible`; `_` = пусто) или объект `{ field, op, value?, use?, userSettingID? }` или группа `{ group: "And"|"Or"|"Not", items: [...] }`. +- **conditionalAppearance** — объект `{ selection?, filter?, appearance?, presentation?, viewMode?, userSettingID?, use? }`. `appearance` — словарь «параметр: значение» платформы (`ЦветТекста`, `ЦветФона`, `Шрифт` и т.п.). + +`userSettingID: "auto"` → платформа сгенерирует идентификатор пользовательской настройки. Пустые контейнеры (без правил) эмитируются автоматически. --- diff --git a/tests/skills/cases/form-compile-from-object/snapshots/accumreg-list-simple/AccumulationRegisters/ДенежныеСредства/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile-from-object/snapshots/accumreg-list-simple/AccumulationRegisters/ДенежныеСредства/Forms/ФормаСписка/Ext/Form.xml index ffdc59aa..1d09406c 100644 --- a/tests/skills/cases/form-compile-from-object/snapshots/accumreg-list-simple/AccumulationRegisters/ДенежныеСредства/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile-from-object/snapshots/accumreg-list-simple/AccumulationRegisters/ДенежныеСредства/Forms/ФормаСписка/Ext/Form.xml @@ -92,9 +92,25 @@ true - AccumulationRegister.ДенежныеСредства false true + AccumulationRegister.ДенежныеСредства + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile-from-object/snapshots/catalog-list-simple/Catalogs/Валюты/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile-from-object/snapshots/catalog-list-simple/Catalogs/Валюты/Forms/ФормаСписка/Ext/Form.xml index 65f871bb..890e2bae 100644 --- a/tests/skills/cases/form-compile-from-object/snapshots/catalog-list-simple/Catalogs/Валюты/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile-from-object/snapshots/catalog-list-simple/Catalogs/Валюты/Forms/ФормаСписка/Ext/Form.xml @@ -105,9 +105,25 @@ true - Catalog.Валюты false true + Catalog.Валюты + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile-from-object/snapshots/chartofaccounts-list-simple/ChartsOfAccounts/Хозрасчетный/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile-from-object/snapshots/chartofaccounts-list-simple/ChartsOfAccounts/Хозрасчетный/Forms/ФормаСписка/Ext/Form.xml index 8845e471..a3f78aed 100644 --- a/tests/skills/cases/form-compile-from-object/snapshots/chartofaccounts-list-simple/ChartsOfAccounts/Хозрасчетный/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile-from-object/snapshots/chartofaccounts-list-simple/ChartsOfAccounts/Хозрасчетный/Forms/ФормаСписка/Ext/Form.xml @@ -73,9 +73,25 @@ true - ChartOfAccounts.Хозрасчетный false true + ChartOfAccounts.Хозрасчетный + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile-from-object/snapshots/document-list-medium/Documents/АктВыполненныхВнутреннихРабот/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile-from-object/snapshots/document-list-medium/Documents/АктВыполненныхВнутреннихРабот/Forms/ФормаСписка/Ext/Form.xml index 1622e9fe..85965504 100644 --- a/tests/skills/cases/form-compile-from-object/snapshots/document-list-medium/Documents/АктВыполненныхВнутреннихРабот/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile-from-object/snapshots/document-list-medium/Documents/АктВыполненныхВнутреннихРабот/Forms/ФормаСписка/Ext/Form.xml @@ -125,9 +125,25 @@ true - Document.АктВыполненныхВнутреннихРабот false true + Document.АктВыполненныхВнутреннихРабот + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile-from-object/snapshots/inforeg-list-periodic/InformationRegisters/ЦеныНоменклатуры/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile-from-object/snapshots/inforeg-list-periodic/InformationRegisters/ЦеныНоменклатуры/Forms/ФормаСписка/Ext/Form.xml index cbf8a2ce..0f2dac91 100644 --- a/tests/skills/cases/form-compile-from-object/snapshots/inforeg-list-periodic/InformationRegisters/ЦеныНоменклатуры/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile-from-object/snapshots/inforeg-list-periodic/InformationRegisters/ЦеныНоменклатуры/Forms/ФормаСписка/Ext/Form.xml @@ -82,9 +82,25 @@ true - InformationRegister.ЦеныНоменклатуры false true + InformationRegister.ЦеныНоменклатуры + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile/dynamic-list-form.json b/tests/skills/cases/form-compile/dynamic-list-form.json index 7ada47e1..5d80f9e1 100644 --- a/tests/skills/cases/form-compile/dynamic-list-form.json +++ b/tests/skills/cases/form-compile/dynamic-list-form.json @@ -16,7 +16,12 @@ "input": { "title": "Товары", "attributes": [ - { "name": "Список", "type": "DynamicList", "settings": { "mainTable": "Catalog.Товары", "dynamicDataRead": true } } + { "name": "Список", "type": "DynamicList", "settings": { + "mainTable": "Catalog.Товары", "dynamicDataRead": true, + "order": [ "Description", "Code desc" ], + "filter": [ "Артикул = _ @off @user" ], + "conditionalAppearance": [ { "filter": ["Артикул = _"], "appearance": { "ЦветТекста": "web:Red" } } ] + } } ], "elements": [ { "table": "Список", "path": "Список", "columns": [ diff --git a/tests/skills/cases/form-compile/snapshots/auto-cmd-bar/Catalogs/Бригады/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile/snapshots/auto-cmd-bar/Catalogs/Бригады/Forms/ФормаСписка/Ext/Form.xml index 45ab3419..beec2dda 100644 --- a/tests/skills/cases/form-compile/snapshots/auto-cmd-bar/Catalogs/Бригады/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile/snapshots/auto-cmd-bar/Catalogs/Бригады/Forms/ФормаСписка/Ext/Form.xml @@ -66,9 +66,25 @@ true - Catalog.Бригады false true + Catalog.Бригады + + + Normal + UUID-001 + + + Normal + UUID-002 + + + Normal + UUID-003 + + Normal + UUID-004 + diff --git a/tests/skills/cases/form-compile/snapshots/dynamic-list-form/Catalogs/Товары/Forms/ФормаСписка/Ext/Form.xml b/tests/skills/cases/form-compile/snapshots/dynamic-list-form/Catalogs/Товары/Forms/ФормаСписка/Ext/Form.xml index 9979f860..1f7522a6 100644 --- a/tests/skills/cases/form-compile/snapshots/dynamic-list-form/Catalogs/Товары/Forms/ФормаСписка/Ext/Form.xml +++ b/tests/skills/cases/form-compile/snapshots/dynamic-list-form/Catalogs/Товары/Forms/ФормаСписка/Ext/Form.xml @@ -62,9 +62,54 @@ true - Catalog.Товары false true + Catalog.Товары + + + + false + Артикул + Equal + UUID-001 + + Normal + UUID-002 + + + + Description + Asc + + + Code + Desc + + Normal + UUID-003 + + + + + + + Артикул + Equal + + + + + ЦветТекста + web:Red + + + + Normal + UUID-004 + + Normal + UUID-005 +