feat(form-decompile,form-compile): унификация событий элементов на events-мапу (кластер Events DSL)

Несогласованность DSL: события ФОРМЫ описывались интуитивной мапой
events:{Событие:Обработчик}, а события ЭЛЕМЕНТА — двумя сущностями
on:[...] + handlers:{...}. Два способа для одного понятия путали модель.

Унифицировано на единую мапу events:{Событие:ИмяОбработчика} на форме И
элементах (как form-level). Декомпилятор эмитит только её, с явными именами
обработчиков (прозрачно, консистентно с form-level).

Компилятор (ps1+py):
- Emit-Events читает events-мапу (основной формат); значение null/"" →
  имя по конвенции ИмяЭлемента+суффикс (прощающий fallback).
- legacy on/handlers по-прежнему принимаются ради совместимости (не эмитятся).
- choiceButton: проверка StartChoice через оба формата (Test-ElementEvent).
- events добавлен в whitelist ключей элемента.

Декомпилятор: Get-Events → упорядоченная мапа {Событие:Обработчик} в порядке
документа; убраны on/handlers и инверсия авто-имён.

spec/SKILL.md: events как единственный рекомендованный формат, on/handlers
помечены legacy. В SKILL.md только явные имена (null-сахар — деталь spec,
инструкцию не раздуваем).

Корпус acc_8.3.24: 190 элементов в 114/400 форм теряли Events до фикса (баг
on/handlers разобран отдельным коммитом). Раундтрип 2.17: Events ушли из топа
LOST, match 4→6, 0 compile-fail. Регресс ps+py 32/32, снэпшот events (добавлен
блок Events у поля с переименованным обработчиком) сертифицирован в 1С 8.3.24.

Follow-up: form-edit использует расширенный on с {event,callType} —
унификация отдельным решением (см. BACKLOG).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-05 12:46:03 +03:00
parent a38874280c
commit 4c2c72abce
7 changed files with 127 additions and 82 deletions
+5 -7
View File
@@ -88,10 +88,9 @@ powershell.exe -NoProfile -File "${CLAUDE_SKILL_DIR}/scripts/form-compile.ps1" -
| `visible: false` | Скрыть (синоним: `hidden: true`) |
| `enabled: false` | Сделать недоступным (синоним: `disabled: true`) |
| `readOnly: true` | Только чтение |
| `on: [...]` | События с автоименованием обработчиков |
| `handlers: {...}` | Явное задание имён обработчиков: `{"OnChange": "МоёИмя"}` |
| `events: {...}` | Обработчики событий: `{ "OnChange": "ИмяОбработчика" }`. Тот же формат, что у событий формы |
### Допустимые имена событий (`on`)
### Допустимые имена событий (`events`)
Компилятор предупреждает о неизвестных событиях. Имена регистрозависимы — используйте точно как указано.
@@ -440,7 +439,7 @@ PictureField, привязанный к булеву/числу, рисует и
"events": { "OnCreateAtServer": "ПриСозданииНаСервере" },
"elements": [
{ "group": "horizontal", "name": "ГруппаФайл", "children": [
{ "input": "ИмяФайла", "path": "ИмяФайла", "title": "Файл", "inputHint": "Выберите файл...", "choiceButton": true, "on": ["StartChoice"] },
{ "input": "ИмяФайла", "path": "ИмяФайла", "title": "Файл", "inputHint": "Выберите файл...", "choiceButton": true, "events": { "StartChoice": "ИмяФайлаНачалоВыбора" } },
{ "check": "ПерваяСтрокаЗаголовок", "path": "ПерваяСтрокаЗаголовок" }
]},
{ "input": "Результат", "path": "Результат", "multiLine": true, "height": 8, "readOnly": true, "title": "Лог" },
@@ -500,8 +499,8 @@ PictureField, привязанный к булеву/числу, рисует и
"title": "Просмотр данных",
"elements": [
{ "group": "horizontal", "name": "Фильтр", "children": [
{ "input": "Период", "path": "Период", "on": ["OnChange"] },
{ "input": "Организация", "path": "Организация", "on": ["OnChange"] }
{ "input": "Период", "path": "Период", "events": { "OnChange": "ПериодПриИзменении" } },
{ "input": "Организация", "path": "Организация", "events": { "OnChange": "ОрганизацияПриИзменении" } }
]},
{ "table": "Данные", "path": "Данные", "changeRowSet": true, "columns": [
{ "input": "Дата", "path": "Данные.Дата" },
@@ -525,7 +524,6 @@ PictureField, привязанный к булеву/числу, рисует и
## Автогенерация
- **Companion-элементы**: ContextMenu, ExtendedTooltip и др. создаются автоматически
- **Обработчики событий**: `"on": ["OnChange"]``ОрганизацияПриИзменении`
- **Namespace**: все 17 namespace-деклараций
- **ID**: последовательная нумерация, AutoCommandBar = id="-1"
- **Unknown keys**: выводится предупреждение о нераспознанных ключах
@@ -1,4 +1,4 @@
# form-compile v1.31 — Compile 1C managed form from JSON or object metadata
# form-compile v1.32 — Compile 1C managed form from JSON or object metadata
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
param(
[string]$JsonPath,
@@ -1803,30 +1803,57 @@ $script:knownEvents = @{
}
$script:knownFormEvents = @("OnCreateAtServer","OnOpen","BeforeClose","OnClose","NotificationProcessing","ChoiceProcessing","OnReadAtServer","AfterWriteAtServer","BeforeWriteAtServer","AfterWrite","BeforeWrite","OnWriteAtServer","FillCheckProcessingAtServer","OnLoadDataFromSettingsAtServer","BeforeLoadDataFromSettingsAtServer","OnSaveDataInSettingsAtServer","ExternalEvent","OnReopen","Opening")
# Собрать упорядоченный список событий элемента (имя, обработчик) из DSL.
# Основной формат: $el.events = { Событие: ИмяОбработчика } (null/"" → авто-имя по конвенции).
# Legacy (принимается ради совместимости): $el.on (массив) + $el.handlers (переопределение имён).
function Get-EventPairs {
param($el, [string]$elementName)
$pairs = New-Object System.Collections.ArrayList
if ($el.events) {
foreach ($p in $el.events.PSObject.Properties) {
$h = "$($p.Value)"
if ([string]::IsNullOrEmpty($h)) { $h = Get-HandlerName -elementName $elementName -eventName $p.Name }
[void]$pairs.Add([pscustomobject]@{ name = $p.Name; handler = $h })
}
} elseif ($el.on) {
foreach ($evt in $el.on) {
$evtName = "$evt"
$h = if ($el.handlers -and $el.handlers.$evtName) { "$($el.handlers.$evtName)" } else { Get-HandlerName -elementName $elementName -eventName $evtName }
[void]$pairs.Add([pscustomobject]@{ name = $evtName; handler = $h })
}
}
return $pairs
}
# Проверить, подключено ли событие к элементу (в любом из форматов).
function Test-ElementEvent {
param($el, [string]$eventName)
if ($el.events) {
foreach ($p in $el.events.PSObject.Properties) { if ($p.Name -eq $eventName) { return $true } }
}
if ($el.on -contains $eventName) { return $true }
return $false
}
function Emit-Events {
param($el, [string]$elementName, [string]$indent, [string]$typeKey)
if (-not $el.on) { return }
$pairs = Get-EventPairs -el $el -elementName $elementName
if ($pairs.Count -eq 0) { return }
# Validate event names
if ($typeKey -and $script:knownEvents.ContainsKey($typeKey)) {
$allowed = $script:knownEvents[$typeKey]
foreach ($evt in $el.on) {
if ($allowed.Count -gt 0 -and $allowed -notcontains "$evt") {
Write-Host "[WARN] Unknown event '$evt' for $typeKey '$elementName'. Known: $($allowed -join ', ')"
foreach ($pr in $pairs) {
if ($allowed.Count -gt 0 -and $allowed -notcontains "$($pr.name)") {
Write-Host "[WARN] Unknown event '$($pr.name)' for $typeKey '$elementName'. Known: $($allowed -join ', ')"
}
}
}
X "$indent<Events>"
foreach ($evt in $el.on) {
$evtName = "$evt"
$handler = if ($el.handlers -and $el.handlers.$evtName) {
"$($el.handlers.$evtName)"
} else {
Get-HandlerName -elementName $elementName -eventName $evtName
}
X "$indent`t<Event name=`"$evtName`">$handler</Event>"
foreach ($pr in $pairs) {
X "$indent`t<Event name=`"$($pr.name)`">$($pr.handler)</Event>"
}
X "$indent</Events>"
}
@@ -1932,8 +1959,8 @@ function Emit-Element {
"name"=1;"path"=1;"title"=1
# visibility & state
"visible"=1;"hidden"=1;"enabled"=1;"disabled"=1;"readOnly"=1;"userVisible"=1
# events
"on"=1;"handlers"=1
# events ("events" — основной формат; on/handlers — legacy, принимаются ради совместимости)
"events"=1;"on"=1;"handlers"=1
# layout
"titleLocation"=1;"representation"=1;"width"=1;"height"=1
"horizontalStretch"=1;"verticalStretch"=1;"autoMaxWidth"=1;"autoMaxHeight"=1
@@ -2218,7 +2245,7 @@ function Emit-Input {
if ($el.multiLine -eq $true) { X "$inner<MultiLine>true</MultiLine>" }
if ($el.passwordMode -eq $true) { X "$inner<PasswordMode>true</PasswordMode>" }
if ($el.choiceButton -eq $false) { X "$inner<ChoiceButton>false</ChoiceButton>" }
elseif ($el.choiceButton -eq $true -and ($el.on -contains 'StartChoice')) { X "$inner<ChoiceButton>true</ChoiceButton>" }
elseif ($el.choiceButton -eq $true -and (Test-ElementEvent $el 'StartChoice')) { X "$inner<ChoiceButton>true</ChoiceButton>" }
if ($el.clearButton -eq $true) { X "$inner<ClearButton>true</ClearButton>" }
if ($el.spinButton -eq $true) { X "$inner<SpinButton>true</SpinButton>" }
if ($el.dropListButton -eq $true) { X "$inner<DropListButton>true</DropListButton>" }
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# form-compile v1.31 — Compile 1C managed form from JSON or object metadata
# form-compile v1.32 — Compile 1C managed form from JSON or object metadata
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import copy
@@ -1356,7 +1356,7 @@ KNOWN_KEYS = {
"radioButtonType", "choiceList", "columnsCount", "checkBoxType", "editMode",
"name", "path", "title",
"visible", "hidden", "enabled", "disabled", "readOnly", "userVisible",
"on", "handlers",
"events", "on", "handlers",
"titleLocation", "representation", "width", "height",
"horizontalStretch", "verticalStretch", "autoMaxWidth", "autoMaxHeight",
"maxWidth", "maxHeight",
@@ -1533,26 +1533,53 @@ def get_element_name(el, type_key):
return str(el.get(type_key, ''))
# Собрать упорядоченный список событий элемента (имя, обработчик) из DSL.
# Основной формат: el['events'] = { Событие: ИмяОбработчика } (None/"" → авто-имя по конвенции).
# Legacy (принимается ради совместимости): el['on'] (массив) + el['handlers'] (переопределение имён).
def get_event_pairs(el, element_name):
pairs = []
events = el.get('events')
if events:
for ev_name, val in events.items():
handler = '' if val is None else str(val)
if not handler:
handler = get_handler_name(element_name, ev_name)
pairs.append((ev_name, handler))
elif el.get('on'):
handlers = el.get('handlers') or {}
for evt in el['on']:
evt_name = str(evt)
if handlers.get(evt_name):
handler = str(handlers[evt_name])
else:
handler = get_handler_name(element_name, evt_name)
pairs.append((evt_name, handler))
return pairs
# Проверить, подключено ли событие к элементу (в любом из форматов).
def test_element_event(el, event_name):
events = el.get('events')
if events and event_name in events:
return True
return event_name in (el.get('on') or [])
def emit_events(lines, el, element_name, indent, type_key):
if not el.get('on'):
pairs = get_event_pairs(el, element_name)
if not pairs:
return
# Validate event names
if type_key and type_key in KNOWN_EVENTS:
allowed = KNOWN_EVENTS[type_key]
for evt in el['on']:
if allowed and str(evt) not in allowed:
print(f"[WARN] Unknown event '{evt}' for {type_key} '{element_name}'. Known: {', '.join(allowed)}")
for ev_name, _ in pairs:
if allowed and str(ev_name) not in allowed:
print(f"[WARN] Unknown event '{ev_name}' for {type_key} '{element_name}'. Known: {', '.join(allowed)}")
lines.append(f"{indent}<Events>")
for evt in el['on']:
evt_name = str(evt)
handlers = el.get('handlers')
if handlers and handlers.get(evt_name):
handler = str(handlers[evt_name])
else:
handler = get_handler_name(element_name, evt_name)
lines.append(f'{indent}\t<Event name="{evt_name}">{handler}</Event>')
for ev_name, handler in pairs:
lines.append(f'{indent}\t<Event name="{ev_name}">{handler}</Event>')
lines.append(f"{indent}</Events>")
@@ -2015,7 +2042,7 @@ def emit_input(lines, el, name, eid, indent):
lines.append(f'{inner}<PasswordMode>true</PasswordMode>')
if el.get('choiceButton') is False:
lines.append(f'{inner}<ChoiceButton>false</ChoiceButton>')
elif el.get('choiceButton') is True and 'StartChoice' in (el.get('on') or []):
elif el.get('choiceButton') is True and test_element_event(el, 'StartChoice'):
lines.append(f'{inner}<ChoiceButton>true</ChoiceButton>')
if el.get('clearButton') is True:
lines.append(f'{inner}<ClearButton>true</ClearButton>')
@@ -1,4 +1,4 @@
# form-decompile v0.11 — Decompile 1C managed Form.xml to JSON DSL (draft)
# form-decompile v0.12 — Decompile 1C managed Form.xml to JSON DSL (draft)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# ВНИМАНИЕ: раундтрип не гарантируется. Навык исключён из авто-использования моделью.
param(
@@ -212,39 +212,19 @@ function Add-TitleLocation {
elseif ($tl -ne $smartDefault) { $obj['titleLocation'] = $tl.ToLower() }
}
# Суффиксы авто-имён обработчиков (инверсия компилятора)
$HANDLER_SUFFIX = @{
'OnChange'='ПриИзменении'; 'StartChoice'='НачалоВыбора'; 'ChoiceProcessing'='ОбработкаВыбора';
'AutoComplete'='АвтоПодбор'; 'Clearing'='Очистка'; 'Opening'='Открытие'; 'Click'='Нажатие';
'OnActivateRow'='ПриАктивизацииСтроки'; 'BeforeAddRow'='ПередНачаломДобавления';
'BeforeDeleteRow'='ПередУдалением'; 'BeforeRowChange'='ПередНачаломИзменения';
'OnStartEdit'='ПриНачалеРедактирования'; 'OnEndEdit'='ПриОкончанииРедактирования';
'Selection'='ВыборСтроки'; 'OnCurrentPageChange'='ПриСменеСтраницы'; 'TextEditEnd'='ОкончаниеВводаТекста';
'URLProcessing'='ОбработкаНавигационнойСсылки'; 'DragStart'='НачалоПеретаскивания'; 'Drag'='Перетаскивание';
'DragCheck'='ПроверкаПеретаскивания'; 'Drop'='Помещение'; 'AfterDeleteRow'='ПослеУдаления'
}
# Разобрать <Events> элемента → { on:[...], handlers:{...} } с учётом авто-имён
# Разобрать <Events> элемента → упорядоченная мапа { ИмяСобытия: ИмяОбработчика }
# в порядке документа. Имена обработчиков всегда явные (как у событий формы) —
# единый, консистентный с form-level формат. Legacy on/handlers больше не эмитим.
function Get-Events {
param($node, [string]$elName)
$ev = $node.SelectSingleNode("lf:Events", $ns)
if (-not $ev) { return $null }
$on = New-Object System.Collections.ArrayList
$handlers = [ordered]@{}
# `on` — полный список событий в порядке документа (контракт DSL: on = массив имён событий);
# `handlers` — только переопределение имени, когда обработчик не выводится из авто-суффикса.
$events = [ordered]@{}
foreach ($e in @($ev.SelectNodes("lf:Event", $ns))) {
$evName = $e.GetAttribute("name")
$handler = $e.InnerText
$auto = if ($HANDLER_SUFFIX.ContainsKey($evName) -and $elName) { "$elName$($HANDLER_SUFFIX[$evName])" } else { $null }
[void]$on.Add($evName)
if (-not ($auto -and $handler -eq $auto)) { $handlers[$evName] = $handler }
$events[$e.GetAttribute("name")] = $e.InnerText
}
$res = [ordered]@{}
if ($on.Count -gt 0) { $res['on'] = @($on) }
if ($handlers.Count -gt 0) { $res['handlers'] = $handlers }
if ($res.Count -eq 0) { return $null }
return $res
if ($events.Count -eq 0) { return $null }
return $events
}
# Общие свойства элемента (visible/enabled/readonly/title/events) → в hash
@@ -260,10 +240,7 @@ function Add-CommonProps {
# formatted у LabelDecoration выводится компилятором из hyperlink — отдельный ключ не нужен (#16 хвост)
}
$ev = Get-Events $node $elName
if ($ev) {
if ($ev.Contains('on')) { $obj['on'] = $ev['on'] }
if ($ev.Contains('handlers')) { $obj['handlers'] = $ev['handlers'] }
}
if ($ev) { $obj['events'] = $ev }
}
# --- 3. Type decompile (inverse of Emit-Type) ---
+22 -9
View File
@@ -79,7 +79,7 @@
}
```
Ключ — имя события, значение — имя процедуры-обработчика.
Ключ — имя события, значение — имя процедуры-обработчика. **Тот же формат `events` используется и на элементах** (§4.1) — единый способ описания событий во всём DSL.
### Доступные события
@@ -114,8 +114,7 @@
| `hidden` | bool | `true``<Visible>false</Visible>` |
| `disabled` | bool | `true``<Enabled>false</Enabled>` |
| `readOnly` | bool | `true``<ReadOnly>true</ReadOnly>` |
| `on` | string[] | Массив имён событий |
| `handlers` | object | Явные имена обработчиков: `{"OnChange": "МойОбработчик"}` |
| `events` | object | Обработчики событий: `{ "ИмяСобытия": "ИмяОбработчика" }` — тот же формат, что у событий формы (§3). Значение `null` → имя по конвенции (§4.2). См. §4.2 |
### 4.1a. Общие layout-свойства
@@ -136,14 +135,25 @@
| `horizontalAlign` | `<HorizontalAlign>` | `Left`, `Center`, `Right` |
| `skipOnInput` | `<SkipOnInput>` | `true` |
### 4.2. Автоименование обработчиков
### 4.2. События элемента и автоименование обработчиков
При указании `"on"` без `"handlers"` имя обработчика генерируется автоматически:
События элемента описываются мапой `events` (как у формы):
```json
{ "input": "Контрагент", "path": "Объект.Контрагент",
"events": { "OnChange": "КонтрагентПриИзменении" } }
```
<ИмяЭлемента><РусскийСуффикс>
Значение — имя процедуры-обработчика. Если вместо имени указать **`null`**, имя
генерируется автоматически по конвенции 1С `<ИмяЭлемента><РусскийСуффикс>`:
```json
{ "input": "Контрагент", "path": "Объект.Контрагент",
"events": { "OnChange": null } } // → обработчик КонтрагентПриИзменении
```
Суффиксы для авто-имени:
| Событие | Суффикс |
|---------|---------|
| `OnChange` | `ПриИзменении` |
@@ -163,7 +173,10 @@
| `OnCurrentPageChange` | `ПриСменеСтраницы` |
| `TextEditEnd` | `ОкончаниеВводаТекста` |
Пример: элемент `Контрагент` + событие `OnChange` → обработчик `КонтрагентПриИзменении`.
> **Legacy-формат (принимается, но устарел).** Ранее события элемента задавались парой
> `on` (массив имён событий) + `handlers` (переопределение имён): `{ "on": ["OnChange"], "handlers": { … } }`.
> Компилятор по-прежнему его принимает ради совместимости, но рекомендуемый и
> единственный эмитируемый формат — мапа `events`. Новые формы пишите через `events`.
### 4.3. Типы элементов
@@ -184,7 +197,7 @@
#### input — InputField
```json
{ "input": "Организация", "path": "Объект.Организация", "on": ["OnChange"] }
{ "input": "Организация", "path": "Объект.Организация", "events": { "OnChange": "ОрганизацияПриИзменении" } }
```
| Свойство | Тип | Описание |
@@ -211,7 +224,7 @@
#### check — CheckBoxField
```json
{ "check": "ФлагАктивности", "path": "Активен", "on": ["OnChange"] }
{ "check": "ФлагАктивности", "path": "Активен", "events": { "OnChange": "ФлагАктивностиПриИзменении" } }
```
| Свойство | Тип | Описание |
+3 -3
View File
@@ -17,9 +17,9 @@
"title": "События",
"events": { "OnCreateAtServer": "ПриСозданииНаСервере", "OnOpen": "ПриОткрытии" },
"elements": [
{ "input": "Организация", "path": "Организация", "on": ["OnChange", "StartChoice"] },
{ "input": "Период", "path": "Период", "handlers": { "OnChange": "ПериодПриИзменении" } },
{ "label": "Подсказка", "title": "Нажмите для перехода", "hyperlink": true, "on": ["Click"] }
{ "input": "Организация", "path": "Организация", "events": { "OnChange": "ОрганизацияПриИзменении", "StartChoice": "ОрганизацияНачалоВыбора" } },
{ "input": "Период", "path": "Период", "events": { "OnChange": "ПериодПриИзменении" } },
{ "label": "Подсказка", "title": "Нажмите для перехода", "hyperlink": true, "events": { "Click": null } }
],
"attributes": [
{ "name": "Объект", "type": "DataProcessorObject.События", "main": true },
@@ -26,6 +26,9 @@
<DataPath>Период</DataPath>
<ContextMenu name="ПериодКонтекстноеМеню" id="5"/>
<ExtendedTooltip name="ПериодРасширеннаяПодсказка" id="6"/>
<Events>
<Event name="OnChange">ПериодПриИзменении</Event>
</Events>
</InputField>
<LabelDecoration name="Подсказка" id="7">
<Title formatted="true">