Release 0.4.4: form_xml_to_html v0.2.3 — прототипы форм 1С.
Дерево элементов, ASCII-макет, DataPath · тип в полях, копирование в буфер. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,50 @@
|
|||||||
|
|
||||||
All notable changes to **export_1c_help** are documented in this file.
|
All notable changes to **export_1c_help** are documented in this file.
|
||||||
|
|
||||||
|
## [0.4.4] - 2026-08-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `form_xml_to_html` **v0.2.3**: DataPath · тип — readonly поле (выделение) + кнопка ⎘
|
||||||
|
для копирования в буфер обмена.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.3] - 2026-08-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `form_xml_to_html` **v0.2.2**: очистка форматированных заголовков 1С
|
||||||
|
(`<link>`, `<bgcolorstyle>`, `<img>`) — «Сегменты (Метизы)» вместо сырой разметки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-08-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `form_xml_to_html.py` **v0.2.1**: в `placeholder` полей — DataPath и тип данных
|
||||||
|
(атрибуты формы + метаданные объекта/констант).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-08-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `form_xml_to_html.py` **v0.2.0**:
|
||||||
|
- `-O/--out-file` — файл вывода, если не задан `-p`
|
||||||
|
- `-T/--elements-tree` — текстовое дерево элементов
|
||||||
|
- `-M/--text-maket` — ASCII-макет «как в окне»
|
||||||
|
- `-t/--text-only` — выгрузка текста в `-O` без HTML
|
||||||
|
- без `-O`: `-T`/`-M` идут в консоль; с `-O`: HTML (+ дерево/макет в страницу)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Убран неявный default `out/form-prototypes`: нужен `-p` или `-O` (либо только `-T`/`-M` в консоль).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.4.0] - 2026-08-11
|
## [0.4.0] - 2026-08-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# export_1c_help
|
# export_1c_help
|
||||||
|
|
||||||
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.0**) · [CHANGELOG](CHANGELOG.md)
|
**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.4**) · [CHANGELOG](CHANGELOG.md)
|
||||||
**Лицензия:** [MIT](LICENSE)
|
**Лицензия:** [MIT](LICENSE)
|
||||||
|
|
||||||
Выгрузка встроенной справки **любой** конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в wiki git-репозитория (Gitea / GitLab: `*.wiki.git`).
|
Выгрузка встроенной справки **любой** конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в wiki git-репозитория (Gitea / GitLab: `*.wiki.git`).
|
||||||
|
|
||||||
Дополнительно: публикация произвольных Markdown-статей в wiki через `export_docs.py` (спецификация: [`SPEC_EXPORT_DOCS.md`](SPEC_EXPORT_DOCS.md)).
|
Дополнительно: публикация произвольных Markdown-статей в wiki через `export_docs.py` (спецификация: [`SPEC_EXPORT_DOCS.md`](SPEC_EXPORT_DOCS.md)).
|
||||||
|
|
||||||
Также: HTML-прототипы управляемых форм — `form_xml_to_html.py` (v0.1.0, Michael BAG <mk@p7net.ru>).
|
Также: HTML/текстовые прототипы управляемых форм — `form_xml_to_html.py` (v0.2.3, Michael BAG <mk@p7net.ru>).
|
||||||
|
|
||||||
Зависимости: **Python 3.10+**, только стандартная библиотека.
|
Зависимости: **Python 3.10+**, только стандартная библиотека.
|
||||||
|
|
||||||
@@ -227,29 +227,39 @@ python3 export_1c_help.py convert -c ../../crm3-26 -o ./out/wiki-md-crm3-26 --cl
|
|||||||
|
|
||||||
## HTML-прототипы форм (`form_xml_to_html.py`)
|
## HTML-прототипы форм (`form_xml_to_html.py`)
|
||||||
|
|
||||||
Скрипт **v0.1.0**, автор **Michael BAG** <mk@p7net.ru>.
|
Скрипт **v0.2.3**, автор **Michael BAG** <mk@p7net.ru>.
|
||||||
|
|
||||||
Собирает интерактивный HTML по `Ext/Form.xml` (вкладки, группы, поля, фильтр).
|
Собирает интерактивный HTML и/или текстовое дерево/макет по `Ext/Form.xml`.
|
||||||
|
В `placeholder` полей: DataPath и тип данных.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tools/export_1c_help
|
cd tools/export_1c_help
|
||||||
|
|
||||||
# объект конфигурации
|
# HTML в каталог-структуру
|
||||||
python3 form_xml_to_html.py \
|
python3 form_xml_to_html.py \
|
||||||
-b crm3-26 \
|
-b crm3-26 \
|
||||||
-o Справочник.Номенклатура \
|
-o Справочник.Номенклатура \
|
||||||
-f ФормаЭлемента
|
-f ФормаЭлемента \
|
||||||
|
-p out/form-prototypes
|
||||||
|
|
||||||
# общая форма
|
# HTML в один файл + дерево элементов внутри страницы
|
||||||
python3 form_xml_to_html.py \
|
python3 form_xml_to_html.py \
|
||||||
-b crm3-26.rhana \
|
-b crm3-26.rhana \
|
||||||
-o Общие \
|
-o Общие \
|
||||||
-f РК_ФормаНастроекРана
|
-f РК_ФормаНастроекРана \
|
||||||
|
-O /tmp/РК_ФормаНастроекРана.html \
|
||||||
|
-T
|
||||||
|
|
||||||
# параметры из JSON (форма всегда через -f)
|
# только консоль: дерево и ASCII-макет
|
||||||
python3 form_xml_to_html.py \
|
python3 form_xml_to_html.py \
|
||||||
-c configs/form_prototype.crm3-26.json \
|
-c configs/form_prototype.crm3-26.rhana.json \
|
||||||
-f ФормаЭлемента
|
-f РК_ФормаНастроекРана \
|
||||||
|
-T -M
|
||||||
|
|
||||||
|
# текстовый файл без HTML
|
||||||
|
python3 form_xml_to_html.py \
|
||||||
|
-b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана \
|
||||||
|
-T -M -t -O /tmp/form.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
| Параметр | Описание |
|
| Параметр | Описание |
|
||||||
@@ -257,14 +267,23 @@ python3 form_xml_to_html.py \
|
|||||||
| `-b` / `--base` | имя/путь каталога конфигурации (или её `src/`) |
|
| `-b` / `--base` | имя/путь каталога конфигурации (или её `src/`) |
|
||||||
| `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` |
|
| `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` |
|
||||||
| `-f` / `--form` | имя формы |
|
| `-f` / `--form` | имя формы |
|
||||||
| `-p` / `--out-path` | корень вывода (по умолчанию `out/form-prototypes`) |
|
| `-p` / `--out-path` | корень вывода HTML: `<out>/<конфа>/<тип>/<объект>/<форма>.html` |
|
||||||
| `-c` / `--config` | JSON с `base`, `object`, `out_path`, `search_roots` (без формы) |
|
| `-O` / `--out-file` | файл вывода, если нет `-p` (HTML или текст при `-t`) |
|
||||||
|
| `-T` / `--elements-tree` | дерево элементов |
|
||||||
|
| `-M` / `--text-maket` | текстовый макет «как в окне» |
|
||||||
|
| `-t` / `--text-only` | в `-O` только текст, без HTML |
|
||||||
|
| `-c` / `--config` | JSON с `base`, `object`, `out_path`/`out_file`, `search_roots` (без формы) |
|
||||||
|
|
||||||
Путь к XML собирается автоматически. HTML:
|
Правила вывода:
|
||||||
|
|
||||||
`out/<конфа>/<тип>/<объект>/<форма>.html`
|
- нет `-O` → `-T`/`-M` в **консоль**;
|
||||||
|
- есть `-O` (без `-t`) → **HTML** в файл, при `-T`/`-M` блоки добавляются на страницу;
|
||||||
|
- `-t` + `-O` → текстовый файл как в консоли;
|
||||||
|
- без `-p`/`-O` и без `-T`/`-M` — ошибка (нужен явный вывод).
|
||||||
|
|
||||||
например `out/form-prototypes/crm3-26/Справочники/Номенклатура/ФормаЭлемента.html`.
|
Пример HTML-пути при `-p`:
|
||||||
|
|
||||||
|
`out/form-prototypes/crm3-26/Справочники/Номенклатура/ФормаЭлемента.html`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+590
-18
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -15,7 +16,7 @@ from typing import Any
|
|||||||
from .resolve import ResolveError, resolve_config_root, resolve_src_root
|
from .resolve import ResolveError, resolve_config_root, resolve_src_root
|
||||||
from .types_map import DIR_TO_LINK_TYPE, DIR_TO_RU, LINK_TYPE_TO_DIR
|
from .types_map import DIR_TO_LINK_TYPE, DIR_TO_RU, LINK_TYPE_TO_DIR
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.3"
|
||||||
__author__ = "Michael BAG <mk@p7net.ru>"
|
__author__ = "Michael BAG <mk@p7net.ru>"
|
||||||
__email__ = "mk@p7net.ru"
|
__email__ = "mk@p7net.ru"
|
||||||
|
|
||||||
@@ -146,13 +147,37 @@ def get_bool(el: ET.Element, name: str, default: bool = True) -> bool:
|
|||||||
return raw.lower() not in ("false", "0")
|
return raw.lower() not in ("false", "0")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_v8_formatted(text: str) -> str:
|
||||||
|
"""Убрать разметку форматированной строки 1С (<link>, <bgcolorstyle>, <img>…)."""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
s = html.unescape(text)
|
||||||
|
# самозакрывающиеся (картинки и т.п.)
|
||||||
|
s = re.sub(r"<(?:img|br|hr)\b[^>]*?/?>", "", s, flags=re.I)
|
||||||
|
prev = None
|
||||||
|
while prev != s:
|
||||||
|
prev = s
|
||||||
|
# <tag …>content</> (типичный закрывающий тег платформы)
|
||||||
|
s = re.sub(r"<[A-Za-z][\w:-]*(?:\s[^>]*)?>(.*?)</>", r"\1", s, flags=re.S)
|
||||||
|
# <tag …>content</tag>
|
||||||
|
s = re.sub(
|
||||||
|
r"<([A-Za-z][\w:-]*)(?:\s[^>]*)?>(.*?)</\1\s*>",
|
||||||
|
r"\2",
|
||||||
|
s,
|
||||||
|
flags=re.S | re.I,
|
||||||
|
)
|
||||||
|
s = re.sub(r"<[^>]+>", "", s)
|
||||||
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def get_title(el: ET.Element) -> str:
|
def get_title(el: ET.Element) -> str:
|
||||||
for t in el:
|
for t in el:
|
||||||
if local(t.tag) != "Title":
|
if local(t.tag) != "Title":
|
||||||
continue
|
continue
|
||||||
for item in t.iter():
|
for item in t.iter():
|
||||||
if local(item.tag) == "content" and item.text:
|
if local(item.tag) == "content" and item.text:
|
||||||
return item.text.strip()
|
return strip_v8_formatted(item.text)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -318,6 +343,297 @@ def load_settings_file(path: Path) -> dict[str, Any]:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def format_v8_type(type_el: ET.Element | None) -> str:
|
||||||
|
"""Краткое представление типа из узла <Type> формы/метаданных."""
|
||||||
|
if type_el is None:
|
||||||
|
return ""
|
||||||
|
raw_types: list[str] = []
|
||||||
|
date_fractions = ""
|
||||||
|
for item in type_el.iter():
|
||||||
|
tag = local(item.tag)
|
||||||
|
if tag == "Type" and item.text and item.text.strip():
|
||||||
|
raw_types.append(item.text.strip())
|
||||||
|
elif tag == "DateFractions" and item.text:
|
||||||
|
date_fractions = item.text.strip()
|
||||||
|
if not raw_types:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def one(t: str) -> str:
|
||||||
|
if t == "xs:string":
|
||||||
|
return "Строка"
|
||||||
|
if t == "xs:boolean":
|
||||||
|
return "Булево"
|
||||||
|
if t == "xs:decimal":
|
||||||
|
return "Число"
|
||||||
|
if t == "xs:dateTime":
|
||||||
|
if date_fractions == "Date":
|
||||||
|
return "Дата"
|
||||||
|
if date_fractions == "Time":
|
||||||
|
return "Время"
|
||||||
|
return "ДатаВремя"
|
||||||
|
if t == "v8:UUID":
|
||||||
|
return "УникальныйИдентификатор"
|
||||||
|
if t == "v8:ValueTable":
|
||||||
|
return "ТаблицаЗначений"
|
||||||
|
if t == "v8:ValueListType":
|
||||||
|
return "СписокЗначений"
|
||||||
|
if t.startswith("cfg:"):
|
||||||
|
body = t[4:]
|
||||||
|
repl = (
|
||||||
|
("CatalogRef.", "СправочникСсылка."),
|
||||||
|
("CatalogObject.", "СправочникОбъект."),
|
||||||
|
("DocumentRef.", "ДокументСсылка."),
|
||||||
|
("DocumentObject.", "ДокументОбъект."),
|
||||||
|
("EnumRef.", "ПеречислениеСсылка."),
|
||||||
|
("DataProcessorObject.", "ОбработкаОбъект."),
|
||||||
|
("ReportObject.", "ОтчетОбъект."),
|
||||||
|
("InformationRegisterRecord.", "РегистрСведенийЗапись."),
|
||||||
|
("ConstantsSet", "КонстантыНабор"),
|
||||||
|
)
|
||||||
|
for a, b in repl:
|
||||||
|
if body.startswith(a):
|
||||||
|
return b + body[len(a) :]
|
||||||
|
return body
|
||||||
|
return t
|
||||||
|
|
||||||
|
# убрать пустые/дубликаты, сохранить порядок
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[str] = []
|
||||||
|
for t in raw_types:
|
||||||
|
pretty = one(t)
|
||||||
|
if pretty and pretty not in seen:
|
||||||
|
seen.add(pretty)
|
||||||
|
out.append(pretty)
|
||||||
|
return " | ".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_type_child(el: ET.Element) -> ET.Element | None:
|
||||||
|
for c in el:
|
||||||
|
if local(c.tag) == "Type":
|
||||||
|
return c
|
||||||
|
# MetaDataObject: Type внутри Properties
|
||||||
|
if local(c.tag) == "Properties":
|
||||||
|
for p in c:
|
||||||
|
if local(p.tag) == "Type":
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _props_name(el: ET.Element) -> str:
|
||||||
|
for c in el:
|
||||||
|
if local(c.tag) == "Properties":
|
||||||
|
for p in c:
|
||||||
|
if local(p.tag) == "Name" and p.text:
|
||||||
|
return p.text.strip()
|
||||||
|
if local(c.tag) == "Name" and c.text:
|
||||||
|
return c.text.strip()
|
||||||
|
return el.get("name") or ""
|
||||||
|
|
||||||
|
|
||||||
|
OBJECT_TYPE_TO_DIR: dict[str, str] = {
|
||||||
|
"CatalogObject": "Catalogs",
|
||||||
|
"CatalogRef": "Catalogs",
|
||||||
|
"DocumentObject": "Documents",
|
||||||
|
"DocumentRef": "Documents",
|
||||||
|
"DataProcessorObject": "DataProcessors",
|
||||||
|
"ReportObject": "Reports",
|
||||||
|
"ExchangePlanObject": "ExchangePlans",
|
||||||
|
"BusinessProcessObject": "BusinessProcesses",
|
||||||
|
"TaskObject": "Tasks",
|
||||||
|
"ChartOfCharacteristicTypesObject": "ChartsOfCharacteristicTypes",
|
||||||
|
"ChartOfAccountsObject": "ChartsOfAccounts",
|
||||||
|
"ChartOfCalculationTypesObject": "ChartsOfCalculationTypes",
|
||||||
|
"InformationRegisterRecord": "InformationRegisters",
|
||||||
|
"AccumulationRegisterRecord": "AccumulationRegisters",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _src_root_from_form(form_xml: Path) -> Path | None:
|
||||||
|
"""…/src/DataProcessors/X/Forms/…/Ext/Form.xml → …/src"""
|
||||||
|
parts = list(form_xml.resolve().parts)
|
||||||
|
if "src" in parts:
|
||||||
|
return Path(*parts[: parts.index("src") + 1])
|
||||||
|
# иногда корень выгрузки без src
|
||||||
|
for marker in ("Catalogs", "Documents", "DataProcessors", "CommonForms"):
|
||||||
|
if marker in parts:
|
||||||
|
return Path(*parts[: parts.index(marker)])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_metadata_object_types(src_root: Path, cfg_object_type: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Типы реквизитов/колонок ТЧ объекта метаданных.
|
||||||
|
Ключи: 'Реквизит', 'ТЧ.Реквизит'.
|
||||||
|
"""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
body = cfg_object_type[4:] if cfg_object_type.startswith("cfg:") else cfg_object_type
|
||||||
|
if "." not in body:
|
||||||
|
return result
|
||||||
|
kind, name = body.split(".", 1)
|
||||||
|
folder = OBJECT_TYPE_TO_DIR.get(kind)
|
||||||
|
if not folder:
|
||||||
|
return result
|
||||||
|
meta_path = src_root / folder / f"{name}.xml"
|
||||||
|
if not meta_path.is_file():
|
||||||
|
return result
|
||||||
|
|
||||||
|
root = ET.parse(meta_path).getroot()
|
||||||
|
|
||||||
|
def collect_from_child_objects(container: ET.Element) -> None:
|
||||||
|
for child in container:
|
||||||
|
tag = local(child.tag)
|
||||||
|
if tag == "Attribute":
|
||||||
|
aname = _props_name(child)
|
||||||
|
t = format_v8_type(_find_type_child(child))
|
||||||
|
if aname and t:
|
||||||
|
result[aname] = t
|
||||||
|
elif tag == "TabularSection":
|
||||||
|
ts_name = _props_name(child)
|
||||||
|
for sub in child:
|
||||||
|
if local(sub.tag) == "ChildObjects":
|
||||||
|
for attr in sub:
|
||||||
|
if local(attr.tag) != "Attribute":
|
||||||
|
continue
|
||||||
|
col = _props_name(attr)
|
||||||
|
t = format_v8_type(_find_type_child(attr))
|
||||||
|
if ts_name and col and t:
|
||||||
|
result[f"{ts_name}.{col}"] = t
|
||||||
|
|
||||||
|
for top in root:
|
||||||
|
for mid in top:
|
||||||
|
if local(mid.tag) == "ChildObjects":
|
||||||
|
collect_from_child_objects(mid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _is_direct_ts_attribute(ts_el: ET.Element, attr_el: ET.Element) -> bool:
|
||||||
|
"""Attribute принадлежит этой ТЧ (не используется после упрощения collect)."""
|
||||||
|
parent_map = {c: p for p in ts_el.iter() for c in p}
|
||||||
|
cur: ET.Element | None = attr_el
|
||||||
|
while cur is not None and cur is not ts_el:
|
||||||
|
if local(cur.tag) == "TabularSection" and cur is not ts_el:
|
||||||
|
return False
|
||||||
|
cur = parent_map.get(cur)
|
||||||
|
return cur is ts_el
|
||||||
|
|
||||||
|
|
||||||
|
def parse_form_type_index(root: ET.Element, form_xml: Path) -> dict[str, str]:
|
||||||
|
"""DataPath → тип (из атрибутов формы + метаданных объекта)."""
|
||||||
|
types: dict[str, str] = {}
|
||||||
|
src_root = _src_root_from_form(form_xml)
|
||||||
|
object_attr_names: dict[str, str] = {} # form attr name → cfg object type
|
||||||
|
|
||||||
|
for c in root:
|
||||||
|
if local(c.tag) != "Attributes":
|
||||||
|
continue
|
||||||
|
for attr in c:
|
||||||
|
if local(attr.tag) != "Attribute":
|
||||||
|
continue
|
||||||
|
aname = attr.get("name") or ""
|
||||||
|
type_el = None
|
||||||
|
columns_el = None
|
||||||
|
for ch in attr:
|
||||||
|
tname = local(ch.tag)
|
||||||
|
if tname == "Type":
|
||||||
|
type_el = ch
|
||||||
|
elif tname == "Columns":
|
||||||
|
columns_el = ch
|
||||||
|
type_str = format_v8_type(type_el)
|
||||||
|
if aname and type_str:
|
||||||
|
types[aname] = type_str
|
||||||
|
if type_el is not None:
|
||||||
|
for tnode in type_el.iter():
|
||||||
|
if local(tnode.tag) != "Type" or not tnode.text:
|
||||||
|
continue
|
||||||
|
text = tnode.text.strip()
|
||||||
|
if "Object." in text or text.endswith("Object"):
|
||||||
|
object_attr_names[aname] = text
|
||||||
|
if text == "cfg:ConstantsSet":
|
||||||
|
object_attr_names[aname] = "cfg:ConstantsSet"
|
||||||
|
if columns_el is not None:
|
||||||
|
for col in columns_el:
|
||||||
|
if local(col.tag) != "Column":
|
||||||
|
continue
|
||||||
|
cname = col.get("name") or ""
|
||||||
|
ct = format_v8_type(_find_type_child(col))
|
||||||
|
if aname and cname and ct:
|
||||||
|
types[f"{aname}.{cname}"] = ct
|
||||||
|
|
||||||
|
if src_root is not None:
|
||||||
|
for form_attr, cfg_type in object_attr_names.items():
|
||||||
|
if cfg_type == "cfg:ConstantsSet":
|
||||||
|
const_dir = src_root / "Constants"
|
||||||
|
if const_dir.is_dir():
|
||||||
|
for const_file in const_dir.glob("*.xml"):
|
||||||
|
try:
|
||||||
|
croot = ET.parse(const_file).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
t_el = None
|
||||||
|
for mid in croot:
|
||||||
|
for ch in mid:
|
||||||
|
if local(ch.tag) != "Properties":
|
||||||
|
continue
|
||||||
|
for p in ch:
|
||||||
|
if local(p.tag) == "Type":
|
||||||
|
t_el = p
|
||||||
|
break
|
||||||
|
t = format_v8_type(t_el)
|
||||||
|
if t:
|
||||||
|
types[f"{form_attr}.{const_file.stem}"] = t
|
||||||
|
continue
|
||||||
|
|
||||||
|
meta_types = load_metadata_object_types(src_root, cfg_type)
|
||||||
|
for key, val in meta_types.items():
|
||||||
|
types[f"{form_attr}.{key}"] = val
|
||||||
|
|
||||||
|
return types
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_data_types(nodes: list[dict[str, Any]], type_index: dict[str, str]) -> None:
|
||||||
|
for node in nodes:
|
||||||
|
dp = node.get("data_path") or ""
|
||||||
|
if dp:
|
||||||
|
node["data_type"] = type_index.get(dp, "")
|
||||||
|
children = node.get("children") or []
|
||||||
|
if children:
|
||||||
|
annotate_data_types(children, type_index)
|
||||||
|
|
||||||
|
|
||||||
|
def field_placeholder(node: dict[str, Any]) -> str:
|
||||||
|
"""DataPath + тип для placeholder."""
|
||||||
|
dp = node.get("data_path") or ""
|
||||||
|
dtype = node.get("data_type") or ""
|
||||||
|
name = node.get("name") or ""
|
||||||
|
if dp and dtype:
|
||||||
|
return f"{dp} · {dtype}"
|
||||||
|
if dp:
|
||||||
|
return dp
|
||||||
|
if dtype:
|
||||||
|
return dtype
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def render_copyable_hint(text: str, *, demo: str = "") -> str:
|
||||||
|
"""Readonly строка DataPath · тип + кнопка копирования."""
|
||||||
|
hint = esc(text)
|
||||||
|
if not text:
|
||||||
|
if demo:
|
||||||
|
return (
|
||||||
|
f'<input type="text" class="field-demo" readonly disabled '
|
||||||
|
f'value="{esc(demo)}">'
|
||||||
|
)
|
||||||
|
return '<input type="text" readonly disabled value="">'
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<div class="field-value">'
|
||||||
|
f'<input type="text" class="field-hint" readonly value="{hint}" '
|
||||||
|
f'title="DataPath · тип — выделите или нажмите ⎘">'
|
||||||
|
f'<button type="button" class="btn-copy" data-copy="{hint}" '
|
||||||
|
f'title="Копировать DataPath · тип">⎘</button>'
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
||||||
tag = local(el.tag)
|
tag = local(el.tag)
|
||||||
if tag in SKIP_TAGS:
|
if tag in SKIP_TAGS:
|
||||||
@@ -342,6 +658,7 @@ def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
|||||||
"enabled": get_bool(el, "Enabled", True),
|
"enabled": get_bool(el, "Enabled", True),
|
||||||
"read_only": get_bool(el, "ReadOnly", False),
|
"read_only": get_bool(el, "ReadOnly", False),
|
||||||
"data_path": get_text(el, "DataPath"),
|
"data_path": get_text(el, "DataPath"),
|
||||||
|
"data_type": "",
|
||||||
"group": get_text(el, "Group", "Vertical"),
|
"group": get_text(el, "Group", "Vertical"),
|
||||||
"representation": get_text(el, "Representation", "None"),
|
"representation": get_text(el, "Representation", "None"),
|
||||||
"behavior": get_text(el, "Behavior", "Usual"),
|
"behavior": get_text(el, "Behavior", "Usual"),
|
||||||
@@ -390,7 +707,7 @@ def parse_form(path: Path) -> dict[str, Any]:
|
|||||||
if local(c.tag) == "Title":
|
if local(c.tag) == "Title":
|
||||||
for item in c.iter():
|
for item in c.iter():
|
||||||
if local(item.tag) == "content" and item.text:
|
if local(item.tag) == "content" and item.text:
|
||||||
form_title = item.text.strip()
|
form_title = strip_v8_formatted(item.text)
|
||||||
break
|
break
|
||||||
|
|
||||||
children: list[dict[str, Any]] = []
|
children: list[dict[str, Any]] = []
|
||||||
@@ -401,10 +718,14 @@ def parse_form(path: Path) -> dict[str, Any]:
|
|||||||
if node is not None:
|
if node is not None:
|
||||||
children.append(node)
|
children.append(node)
|
||||||
|
|
||||||
|
type_index = parse_form_type_index(root, path)
|
||||||
|
annotate_data_types(children, type_index)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": form_title or infer_title_from_path(path),
|
"title": form_title or infer_title_from_path(path),
|
||||||
"source": str(path),
|
"source": str(path),
|
||||||
"children": children,
|
"children": children,
|
||||||
|
"type_index": type_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -417,16 +738,24 @@ def render_field(node: dict[str, Any]) -> str:
|
|||||||
label = esc(label_of(node))
|
label = esc(label_of(node))
|
||||||
name = esc(node["name"])
|
name = esc(node["name"])
|
||||||
dp = esc(node["data_path"])
|
dp = esc(node["data_path"])
|
||||||
|
dtype = esc(node.get("data_type") or "")
|
||||||
|
ph = esc(field_placeholder(node))
|
||||||
hidden = "" if node["visible"] else " is-hidden"
|
hidden = "" if node["visible"] else " is-hidden"
|
||||||
meta = f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}"'
|
meta = (
|
||||||
|
f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}" '
|
||||||
|
f'data-value-type="{dtype}"'
|
||||||
|
)
|
||||||
ro = " readonly" if node["read_only"] else ""
|
ro = " readonly" if node["read_only"] else ""
|
||||||
dis = " disabled" if not node["enabled"] else ""
|
dis = " disabled" if not node["enabled"] else ""
|
||||||
|
meta_line = f"{name}" + (f" · {dp}" if dp else "") + (f" · {dtype}" if dtype else "")
|
||||||
|
|
||||||
if tag == "CheckBoxField":
|
if tag == "CheckBoxField":
|
||||||
|
hint = render_copyable_hint(field_placeholder(node)) if ph else ""
|
||||||
return (
|
return (
|
||||||
f'<label class="field checkbox{hidden}" {meta}>'
|
f'<label class="field checkbox{hidden}" {meta}>'
|
||||||
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>'
|
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>'
|
||||||
f'<span class="meta">{name}</span></label>'
|
f'{hint}'
|
||||||
|
f'<span class="meta">{esc(meta_line)}</span></label>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if tag == "RadioButtonField":
|
if tag == "RadioButtonField":
|
||||||
@@ -437,15 +766,15 @@ def render_field(node: dict[str, Any]) -> str:
|
|||||||
f'<label><input type="radio" name="{name}" checked{dis}> Вариант 1</label>'
|
f'<label><input type="radio" name="{name}" checked{dis}> Вариант 1</label>'
|
||||||
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
|
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
|
||||||
f"</div>"
|
f"</div>"
|
||||||
f'<span class="meta">{name} · {dp}</span></div>'
|
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if tag == "LabelField":
|
if tag == "LabelField":
|
||||||
return (
|
return (
|
||||||
f'<div class="field label-field{hidden}" {meta}>'
|
f'<div class="field label-field{hidden}" {meta}>'
|
||||||
f'<div class="caption">{label}</div>'
|
f'<div class="caption">{label}</div>'
|
||||||
f'<div class="value-box">—</div>'
|
f'{render_copyable_hint(field_placeholder(node) or "—")}'
|
||||||
f'<span class="meta">{name} · {dp}</span></div>'
|
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if tag == "LabelDecoration":
|
if tag == "LabelDecoration":
|
||||||
@@ -484,22 +813,22 @@ def render_field(node: dict[str, Any]) -> str:
|
|||||||
f'<div class="field document{hidden}" {meta}>'
|
f'<div class="field document{hidden}" {meta}>'
|
||||||
f'<div class="caption">{label}</div>'
|
f'<div class="caption">{label}</div>'
|
||||||
f'<div class="doc-stub">{kind}</div>'
|
f'<div class="doc-stub">{kind}</div>'
|
||||||
f'<span class="meta">{name} · {dp}</span></div>'
|
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
if tag == "PeriodField":
|
if tag == "PeriodField":
|
||||||
return (
|
return (
|
||||||
f'<div class="field{hidden}" {meta}>'
|
f'<div class="field{hidden}" {meta}>'
|
||||||
f'<label class="caption">{label}</label>'
|
f'<label class="caption">{label}</label>'
|
||||||
f'<input type="text" value="01.01.2026 — 31.12.2026"{ro}{dis}>'
|
f'{render_copyable_hint(ph, demo="01.01.2026 — 31.12.2026")}'
|
||||||
f'<span class="meta">{name} · {dp}</span></div>'
|
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<div class="field{hidden}" {meta}>'
|
f'<div class="field{hidden}" {meta}>'
|
||||||
f'<label class="caption">{label}</label>'
|
f'<label class="caption">{label}</label>'
|
||||||
f'<input type="text" placeholder="{dp or name}"{ro}{dis}>'
|
f'{render_copyable_hint(ph)}'
|
||||||
f'<span class="meta">{name} · {dp}</span></div>'
|
f'<span class="meta">{esc(meta_line)}</span></div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -637,12 +966,194 @@ def render_pages(node: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_html(form: dict[str, Any], *, generator_version: str | None = None) -> str:
|
def _tree_prefix(is_last: bool, ancestors_last: list[bool]) -> str:
|
||||||
|
parts = []
|
||||||
|
for last in ancestors_last:
|
||||||
|
parts.append(" " if last else "│ ")
|
||||||
|
parts.append("└── " if is_last else "├── ")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_tree_label(node: dict[str, Any]) -> str:
|
||||||
|
tag = node["tag"]
|
||||||
|
title = label_of(node)
|
||||||
|
name = node.get("name") or ""
|
||||||
|
dp = node.get("data_path") or ""
|
||||||
|
dtype = node.get("data_type") or ""
|
||||||
|
hidden = "" if node.get("visible", True) else " [скрыт]"
|
||||||
|
if tag in ("UsualGroup", "Pages", "Page", "Table", "ButtonGroup", "Popup", "CommandBar"):
|
||||||
|
return f"{tag} {title}" + (f" ({name})" if name and name != title else "") + hidden
|
||||||
|
extra = ""
|
||||||
|
if dp:
|
||||||
|
extra = f" → {dp}"
|
||||||
|
if dtype:
|
||||||
|
extra += f" · {dtype}"
|
||||||
|
elif dtype:
|
||||||
|
extra = f" · {dtype}"
|
||||||
|
return f"{tag} {title}{extra}{hidden}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_elements_tree(form: dict[str, Any]) -> str:
|
||||||
|
"""Текстовое дерево элементов формы."""
|
||||||
|
lines = [f"{form.get('title') or 'Форма'}", "│"]
|
||||||
|
|
||||||
|
def walk(nodes: list[dict[str, Any]], ancestors_last: list[bool]) -> None:
|
||||||
|
for i, node in enumerate(nodes):
|
||||||
|
is_last = i == len(nodes) - 1
|
||||||
|
lines.append(_tree_prefix(is_last, ancestors_last) + _node_tree_label(node))
|
||||||
|
children = node.get("children") or []
|
||||||
|
if children:
|
||||||
|
walk(children, ancestors_last + [is_last])
|
||||||
|
|
||||||
|
walk(form.get("children") or [], [])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _maket_field_line(node: dict[str, Any], indent: str) -> str:
|
||||||
|
tag = node["tag"]
|
||||||
|
title = label_of(node)
|
||||||
|
hidden = " (скрыто)" if not node.get("visible", True) else ""
|
||||||
|
if tag == "CheckBoxField":
|
||||||
|
return f"{indent}☐ {title}{hidden}"
|
||||||
|
if tag == "RadioButtonField":
|
||||||
|
return f"{indent}○ {title} (○ вариант1 ○ вариант2){hidden}"
|
||||||
|
if tag == "LabelField":
|
||||||
|
return f"{indent}{title}: —{hidden}"
|
||||||
|
if tag == "LabelDecoration":
|
||||||
|
return f"{indent}«{title}»{hidden}"
|
||||||
|
if tag in ("PictureDecoration", "PictureField"):
|
||||||
|
return f"{indent}[картинка] {title}{hidden}"
|
||||||
|
if tag == "Button":
|
||||||
|
return f"{indent}[{title}]{hidden}"
|
||||||
|
if tag == "Table":
|
||||||
|
return f"{indent}┌ таблица: {title} ┐{hidden}"
|
||||||
|
if tag in (
|
||||||
|
"HTMLDocumentField",
|
||||||
|
"SpreadSheetDocumentField",
|
||||||
|
"FormattedDocumentField",
|
||||||
|
"TextDocumentField",
|
||||||
|
):
|
||||||
|
return f"{indent}┌ документ: {title} ┐{hidden}"
|
||||||
|
if tag == "PeriodField":
|
||||||
|
return f"{indent}{title}: [__.__.____ — __.__.____]{hidden}"
|
||||||
|
# InputField / fallback
|
||||||
|
return f"{indent}{title}: [____________________]{hidden}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_text_maket(form: dict[str, Any], *, width: int = 64) -> str:
|
||||||
|
"""ASCII-макет формы «как в окне»."""
|
||||||
|
title = form.get("title") or "Форма"
|
||||||
|
inner_w = max(40, width - 2)
|
||||||
|
top = "┌─ " + title[: inner_w - 3].ljust(inner_w - 3, "─") + "┐"
|
||||||
|
bot = "└" + ("─" * inner_w) + "┘"
|
||||||
|
lines = [
|
||||||
|
top,
|
||||||
|
"│" + " [Записать и закрыть] [Записать] [Закрыть]".ljust(inner_w)[:inner_w] + "│",
|
||||||
|
"│" + ("─" * inner_w) + "│",
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_text(text: str, indent: int = 0) -> None:
|
||||||
|
pad = " " * indent
|
||||||
|
room = max(8, inner_w - len(pad))
|
||||||
|
raw = pad + text
|
||||||
|
while raw:
|
||||||
|
if len(raw) <= inner_w:
|
||||||
|
lines.append("│" + raw.ljust(inner_w) + "│")
|
||||||
|
break
|
||||||
|
cut = raw.rfind(" ", len(pad), inner_w + 1)
|
||||||
|
if cut <= len(pad):
|
||||||
|
cut = inner_w
|
||||||
|
piece = raw[:cut].ljust(inner_w)
|
||||||
|
lines.append("│" + piece + "│")
|
||||||
|
raw = pad + raw[cut:].lstrip()
|
||||||
|
|
||||||
|
def walk(nodes: list[dict[str, Any]], indent: int = 1) -> None:
|
||||||
|
for node in nodes:
|
||||||
|
tag = node["tag"]
|
||||||
|
if tag == "Pages":
|
||||||
|
pages = [ch for ch in node.get("children") or [] if ch["tag"] == "Page"]
|
||||||
|
tabs = " | ".join(label_of(p) for p in pages) or "(страницы)"
|
||||||
|
add_text(f"[{tabs}]", indent)
|
||||||
|
for page in pages:
|
||||||
|
add_text(f"— {label_of(page)} —", indent)
|
||||||
|
walk(page.get("children") or [], indent + 1)
|
||||||
|
continue
|
||||||
|
if tag == "Page":
|
||||||
|
add_text(f"— {label_of(node)} —", indent)
|
||||||
|
walk(node.get("children") or [], indent + 1)
|
||||||
|
continue
|
||||||
|
if tag == "UsualGroup":
|
||||||
|
title_g = label_of(node)
|
||||||
|
show = node.get("show_title", True) and bool(title_g)
|
||||||
|
horiz = "horizontal" in (node.get("group") or "").lower()
|
||||||
|
if show:
|
||||||
|
add_text(("↔ " if horiz else "▾ ") + title_g, indent)
|
||||||
|
walk(node.get("children") or [], indent + (1 if show else 0))
|
||||||
|
continue
|
||||||
|
if tag in ("ButtonGroup", "Popup", "CommandBar", "ColumnGroup"):
|
||||||
|
walk(node.get("children") or [], indent)
|
||||||
|
continue
|
||||||
|
add_text(_maket_field_line(node, "").lstrip(), indent)
|
||||||
|
if tag == "Table" and node.get("children"):
|
||||||
|
# one-line columns hint
|
||||||
|
cols = []
|
||||||
|
for ch in node["children"]:
|
||||||
|
if ch["tag"] == "ColumnGroup":
|
||||||
|
for gch in ch.get("children") or []:
|
||||||
|
cols.append(label_of(gch))
|
||||||
|
else:
|
||||||
|
cols.append(label_of(ch))
|
||||||
|
if cols:
|
||||||
|
add_text(" " + " | ".join(cols[:6]), indent)
|
||||||
|
|
||||||
|
walk(form.get("children") or [])
|
||||||
|
lines.append(bot)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_text_report(
|
||||||
|
form: dict[str, Any],
|
||||||
|
*,
|
||||||
|
include_tree: bool = False,
|
||||||
|
include_maket: bool = False,
|
||||||
|
) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if include_tree:
|
||||||
|
parts.append("## Дерево элементов")
|
||||||
|
parts.append(format_elements_tree(form))
|
||||||
|
if include_maket:
|
||||||
|
if parts:
|
||||||
|
parts.append("")
|
||||||
|
parts.append("## Макет (как в окне)")
|
||||||
|
parts.append(format_text_maket(form))
|
||||||
|
return "\n".join(parts).rstrip() + ("\n" if parts else "")
|
||||||
|
|
||||||
|
|
||||||
|
def build_html(
|
||||||
|
form: dict[str, Any],
|
||||||
|
*,
|
||||||
|
generator_version: str | None = None,
|
||||||
|
elements_tree: str | None = None,
|
||||||
|
text_maket: str | None = None,
|
||||||
|
) -> str:
|
||||||
body = "".join(render_group(ch, 0) for ch in form["children"])
|
body = "".join(render_group(ch, 0) for ch in form["children"])
|
||||||
title = esc(form["title"])
|
title = esc(form["title"])
|
||||||
source = esc(form["source"])
|
source = esc(form["source"])
|
||||||
ver = esc(generator_version or __version__)
|
ver = esc(generator_version or __version__)
|
||||||
author = esc(__author__)
|
author = esc(__author__)
|
||||||
|
extras = ""
|
||||||
|
if elements_tree:
|
||||||
|
extras += (
|
||||||
|
'<details class="text-extra" open>'
|
||||||
|
"<summary>Дерево элементов</summary>"
|
||||||
|
f"<pre>{esc(elements_tree)}</pre></details>\n"
|
||||||
|
)
|
||||||
|
if text_maket:
|
||||||
|
extras += (
|
||||||
|
'<details class="text-extra">'
|
||||||
|
"<summary>Текстовый макет</summary>"
|
||||||
|
f"<pre>{esc(text_maket)}</pre></details>\n"
|
||||||
|
)
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -733,7 +1244,26 @@ details.group .group-body {{ padding: 10px; }}
|
|||||||
.field input[type="text"] {{
|
.field input[type="text"] {{
|
||||||
width: 100%; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px; font: inherit; font-size: 12px;
|
width: 100%; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px; font: inherit; font-size: 12px;
|
||||||
}}
|
}}
|
||||||
.field.checkbox {{ display: flex; align-items: center; gap: 8px; }}
|
.field-value {{
|
||||||
|
display: flex; gap: 4px; align-items: stretch; width: 100%; min-width: 0;
|
||||||
|
}}
|
||||||
|
.field-hint, .field-demo {{
|
||||||
|
flex: 1; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px;
|
||||||
|
font: inherit; font-size: 11px; background: #fafafa;
|
||||||
|
}}
|
||||||
|
.field-hint {{
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
color: #444; user-select: all; cursor: text;
|
||||||
|
}}
|
||||||
|
.field-hint:focus {{ outline: 2px solid var(--accent-soft); border-color: var(--accent); }}
|
||||||
|
.btn-copy {{
|
||||||
|
flex: 0 0 auto; padding: 2px 8px; border: 1px solid var(--border); border-radius: 2px;
|
||||||
|
background: #f5f5f5; cursor: pointer; font: inherit; font-size: 12px; line-height: 1.4;
|
||||||
|
}}
|
||||||
|
.btn-copy:hover {{ background: var(--accent-soft); border-color: var(--accent); }}
|
||||||
|
.btn-copy.copied {{ background: #e8f5e9; border-color: #81c784; }}
|
||||||
|
.field.checkbox {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }}
|
||||||
|
.field.checkbox .field-value {{ flex: 1 1 100%; margin-left: 1.4em; }}
|
||||||
.radio-row {{ display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; margin-top: 4px; }}
|
.radio-row {{ display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; margin-top: 4px; }}
|
||||||
.value-box {{
|
.value-box {{
|
||||||
border: 1px dashed var(--border); padding: 4px 6px; background: #fafafa; font-size: 12px; color: var(--muted);
|
border: 1px dashed var(--border); padding: 4px 6px; background: #fafafa; font-size: 12px; color: var(--muted);
|
||||||
@@ -762,6 +1292,16 @@ body.show-hidden .field.is-hidden {{ display: grid !important; }}
|
|||||||
body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
||||||
.highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }}
|
.highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }}
|
||||||
.stats {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
|
.stats {{ font-size: 11px; color: var(--muted); margin-left: auto; }}
|
||||||
|
.text-extra {{
|
||||||
|
margin: 12px; border: 1px solid var(--border); border-radius: 4px; background: #fafafa;
|
||||||
|
}}
|
||||||
|
.text-extra > summary {{
|
||||||
|
cursor: pointer; padding: 8px 10px; font-weight: 600; font-size: 13px; background: #f3f6f9;
|
||||||
|
}}
|
||||||
|
.text-extra pre {{
|
||||||
|
margin: 0; padding: 10px 12px; overflow: auto; font-size: 12px; line-height: 1.35;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; white-space: pre;
|
||||||
|
}}
|
||||||
@media (max-width: 800px) {{
|
@media (max-width: 800px) {{
|
||||||
.field {{ grid-template-columns: 1fr; }}
|
.field {{ grid-template-columns: 1fr; }}
|
||||||
.group.horizontal > .group-body > * {{ min-width: 100%; }}
|
.group.horizontal > .group-body > * {{ min-width: 100%; }}
|
||||||
@@ -779,7 +1319,7 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
|||||||
<button type="button" id="collapseAll">Свернуть группы</button>
|
<button type="button" id="collapseAll">Свернуть группы</button>
|
||||||
<span class="stats" id="stats"></span>
|
<span class="stats" id="stats"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="window">
|
{extras}<div class="window">
|
||||||
<div class="commandbar">
|
<div class="commandbar">
|
||||||
<button type="button" class="btn">Записать и закрыть</button>
|
<button type="button" class="btn">Записать и закрыть</button>
|
||||||
<button type="button" class="btn">Записать</button>
|
<button type="button" class="btn">Записать</button>
|
||||||
@@ -821,6 +1361,30 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
|||||||
root.querySelectorAll('details.group').forEach(d => d.open = false);
|
root.querySelectorAll('details.group').forEach(d => d.open = false);
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
root.addEventListener('click', (e) => {{
|
||||||
|
const btn = e.target.closest('.btn-copy');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const text = btn.getAttribute('data-copy') || '';
|
||||||
|
const done = () => {{
|
||||||
|
btn.classList.add('copied');
|
||||||
|
btn.setAttribute('title', 'Скопировано');
|
||||||
|
setTimeout(() => {{
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
btn.setAttribute('title', 'Копировать DataPath · тип');
|
||||||
|
}}, 1200);
|
||||||
|
}};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {{
|
||||||
|
navigator.clipboard.writeText(text).then(done).catch(() => {{
|
||||||
|
const inp = btn.previousElementSibling;
|
||||||
|
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
|
||||||
|
}});
|
||||||
|
}} else {{
|
||||||
|
const inp = btn.previousElementSibling;
|
||||||
|
if (inp && inp.select) {{ inp.select(); document.execCommand('copy'); done(); }}
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
|
||||||
const filter = document.getElementById('filter');
|
const filter = document.getElementById('filter');
|
||||||
const stats = document.getElementById('stats');
|
const stats = document.getElementById('stats');
|
||||||
function applyFilter() {{
|
function applyFilter() {{
|
||||||
@@ -868,14 +1432,22 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }}
|
|||||||
|
|
||||||
def export_form_prototype(
|
def export_form_prototype(
|
||||||
*,
|
*,
|
||||||
form_xml: Path,
|
form_xml: Path | None = None,
|
||||||
|
form: dict[str, Any] | None = None,
|
||||||
output_html: Path,
|
output_html: Path,
|
||||||
display_title: str | None = None,
|
display_title: str | None = None,
|
||||||
|
include_elements_tree: bool = False,
|
||||||
|
include_text_maket: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
if form is None:
|
||||||
|
if form_xml is None:
|
||||||
|
raise ValueError("нужен form_xml или form")
|
||||||
form = parse_form(form_xml)
|
form = parse_form(form_xml)
|
||||||
if display_title:
|
if display_title:
|
||||||
form["title"] = display_title
|
form["title"] = display_title
|
||||||
html_text = build_html(form)
|
tree = format_elements_tree(form) if include_elements_tree else None
|
||||||
|
maket = format_text_maket(form) if include_text_maket else None
|
||||||
|
html_text = build_html(form, elements_tree=tree, text_maket=maket)
|
||||||
output_html = Path(output_html)
|
output_html = Path(output_html)
|
||||||
output_html.parent.mkdir(parents=True, exist_ok=True)
|
output_html.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_html.write_text(html_text, encoding="utf-8")
|
output_html.write_text(html_text, encoding="utf-8")
|
||||||
|
|||||||
+108
-22
@@ -1,13 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""CLI: HTML-прототип управляемой формы 1С из Form.xml.
|
"""CLI: HTML/текстовый прототип управляемой формы 1С из Form.xml.
|
||||||
|
|
||||||
Версия: 0.1.0
|
Версия: 0.2.3
|
||||||
Автор: Michael BAG <mk@p7net.ru>
|
Автор: Michael BAG <mk@p7net.ru>
|
||||||
|
|
||||||
Примеры:
|
Примеры:
|
||||||
python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента
|
python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента -p out/form-prototypes
|
||||||
python3 form_xml_to_html.py -c configs/form_prototype.crm3-26.json -f ФормаЭлемента
|
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана -O /tmp/form.html -T
|
||||||
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана
|
python3 form_xml_to_html.py -c configs/form_prototype.crm3-26.rhana.json -f РК_ФормаНастроекРана -T -M
|
||||||
|
python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана -T -M -t -O /tmp/form.txt
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,9 +23,11 @@ from export1c_help.form_prototype import (
|
|||||||
__email__,
|
__email__,
|
||||||
__version__,
|
__version__,
|
||||||
build_output_path,
|
build_output_path,
|
||||||
|
compose_text_report,
|
||||||
default_search_roots,
|
default_search_roots,
|
||||||
export_form_prototype,
|
export_form_prototype,
|
||||||
load_settings_file,
|
load_settings_file,
|
||||||
|
parse_form,
|
||||||
resolve_form_xml,
|
resolve_form_xml,
|
||||||
)
|
)
|
||||||
from export1c_help.resolve import ResolveError
|
from export1c_help.resolve import ResolveError
|
||||||
@@ -34,7 +37,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="form_xml_to_html",
|
prog="form_xml_to_html",
|
||||||
description=(
|
description=(
|
||||||
"Собрать HTML-прототип формы 1С по -b/-o/-f "
|
"HTML/текстовый прототип формы 1С по -b/-o/-f "
|
||||||
f"(v{__version__}, {__author__})"
|
f"(v{__version__}, {__author__})"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -58,14 +61,36 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
"-p",
|
"-p",
|
||||||
"--out-path",
|
"--out-path",
|
||||||
help=(
|
help=(
|
||||||
"Каталог вывода: <out>/<конфа>/<тип>/<объект>/<форма>.html "
|
"Каталог вывода HTML: <out>/<конфа>/<тип>/<объект>/<форма>.html"
|
||||||
"(по умолчанию out/form-prototypes)"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-O",
|
||||||
|
"--out-file",
|
||||||
|
help="Файл вывода (если нет -p): HTML или текст при -t",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-T",
|
||||||
|
"--elements-tree",
|
||||||
|
action="store_true",
|
||||||
|
help="Дерево элементов (консоль / в HTML / в текстовый файл)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-M",
|
||||||
|
"--text-maket",
|
||||||
|
action="store_true",
|
||||||
|
help="Текстовый макет «как в окне» (консоль / в HTML / в текстовый файл)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--text-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Только текст в -O (без HTML); нужны -T и/или -M",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-c",
|
"-c",
|
||||||
"--config",
|
"--config",
|
||||||
help="JSON с параметрами (base, object, out_path, search_roots), без формы",
|
help="JSON с параметрами (base, object, out_path, out_file, search_roots), без формы",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version",
|
"--version",
|
||||||
@@ -75,19 +100,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _resolve_out_path(raw: str | None, *, config_path: Path | None) -> Path:
|
def _resolve_path(raw: str, *, config_path: Path | None) -> Path:
|
||||||
tool_root = Path(__file__).resolve().parent
|
|
||||||
if not raw:
|
|
||||||
return (tool_root / "out" / "form-prototypes").resolve()
|
|
||||||
path = Path(raw).expanduser()
|
path = Path(raw).expanduser()
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
return path.resolve()
|
return path.resolve()
|
||||||
# Относительный путь: от cwd; если запускали с -c — также пробуем от каталога config
|
|
||||||
cwd_path = (Path.cwd() / path).resolve()
|
cwd_path = (Path.cwd() / path).resolve()
|
||||||
if path.parts[0] in (".", "..") or cwd_path.parent.exists():
|
if path.parts[0] in (".", "..") or cwd_path.parent.exists():
|
||||||
return cwd_path
|
return cwd_path
|
||||||
if config_path is not None:
|
if config_path is not None:
|
||||||
return (config_path.parent / path).resolve()
|
return (config_path.parent / path).resolve()
|
||||||
|
tool_root = Path(__file__).resolve().parent
|
||||||
return (tool_root / path).resolve()
|
return (tool_root / path).resolve()
|
||||||
|
|
||||||
|
|
||||||
@@ -107,12 +129,30 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
base = args.base or settings.get("base")
|
base = args.base or settings.get("base")
|
||||||
object_ref = args.object or settings.get("object")
|
object_ref = args.object or settings.get("object")
|
||||||
out_raw = args.out_path or settings.get("out_path") or settings.get("out")
|
out_path_raw = args.out_path or settings.get("out_path")
|
||||||
|
out_file_raw = args.out_file or settings.get("out_file") or settings.get("out")
|
||||||
|
|
||||||
|
want_tree = bool(args.elements_tree)
|
||||||
|
want_maket = bool(args.text_maket)
|
||||||
|
text_only = bool(args.text_only)
|
||||||
|
|
||||||
if not base:
|
if not base:
|
||||||
parser.error("нужен -b/--base или base в -c/--config")
|
parser.error("нужен -b/--base или base в -c/--config")
|
||||||
if not object_ref:
|
if not object_ref:
|
||||||
parser.error("нужен -o/--object или object в -c/--config")
|
parser.error("нужен -o/--object или object в -c/--config")
|
||||||
|
if text_only and not out_file_raw:
|
||||||
|
parser.error("при -t/--text-only нужен -O/--out-file")
|
||||||
|
if text_only and not (want_tree or want_maket):
|
||||||
|
parser.error("при -t/--text-only укажите -T и/или -M")
|
||||||
|
if text_only and out_path_raw:
|
||||||
|
parser.error("при -t/--text-only используйте -O, не -p")
|
||||||
|
|
||||||
|
want_html = not text_only and bool(out_path_raw or out_file_raw)
|
||||||
|
if not want_html and not (want_tree or want_maket):
|
||||||
|
parser.error(
|
||||||
|
"укажите вывод: -p/--out-path или -O/--out-file для HTML, "
|
||||||
|
"либо -T/-M для текста в консоль (или -t -O для текстового файла)"
|
||||||
|
)
|
||||||
|
|
||||||
search_roots = default_search_roots(config_path)
|
search_roots = default_search_roots(config_path)
|
||||||
extra_roots = settings.get("search_roots") or []
|
extra_roots = settings.get("search_roots") or []
|
||||||
@@ -131,9 +171,30 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
form_name=args.form,
|
form_name=args.form,
|
||||||
search_roots=search_roots,
|
search_roots=search_roots,
|
||||||
)
|
)
|
||||||
out_root = _resolve_out_path(
|
form = parse_form(form_xml)
|
||||||
str(out_raw) if out_raw else None, config_path=config_path
|
display_title = f"{object_name} · {args.form.strip()}"
|
||||||
|
form["title"] = display_title
|
||||||
|
|
||||||
|
text_report = compose_text_report(
|
||||||
|
form, include_tree=want_tree, include_maket=want_maket
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Текст в консоль, если -O не задан (и есть что показывать)
|
||||||
|
if not out_file_raw and (want_tree or want_maket) and text_report:
|
||||||
|
sys.stdout.write(text_report)
|
||||||
|
if not text_report.endswith("\n"):
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
|
||||||
|
written: Path | None = None
|
||||||
|
|
||||||
|
if text_only:
|
||||||
|
out_file = _resolve_path(str(out_file_raw), config_path=config_path)
|
||||||
|
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_file.write_text(text_report, encoding="utf-8")
|
||||||
|
written = out_file
|
||||||
|
elif want_html:
|
||||||
|
if out_path_raw:
|
||||||
|
out_root = _resolve_path(str(out_path_raw), config_path=config_path)
|
||||||
output_html = build_output_path(
|
output_html = build_output_path(
|
||||||
out_root,
|
out_root,
|
||||||
base_name=base_name,
|
base_name=base_name,
|
||||||
@@ -141,18 +202,43 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
form_name=args.form.strip(),
|
form_name=args.form.strip(),
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
output_html = _resolve_path(str(out_file_raw), config_path=config_path)
|
||||||
|
# В HTML дерево/макет добавляются при -O (и при -p тоже, если флаги заданы)
|
||||||
|
# По ТЗ: при заданном -O — в HTML; без -O текст в консоль уже выше.
|
||||||
|
embed_tree = want_tree and bool(out_file_raw or out_path_raw)
|
||||||
|
embed_maket = want_maket and bool(out_file_raw or out_path_raw)
|
||||||
|
# Уточнение ТЗ: «Если -O не задан → консоль; иначе → html с деревом».
|
||||||
|
# При только -p без -O: HTML без обязательного встраивания; дерево уже в консоли.
|
||||||
|
if out_file_raw and not out_path_raw:
|
||||||
|
embed_tree = want_tree
|
||||||
|
embed_maket = want_maket
|
||||||
|
elif out_path_raw and not out_file_raw:
|
||||||
|
# -p без -O: HTML базовый; -T/-M уже ушли в консоль
|
||||||
|
embed_tree = False
|
||||||
|
embed_maket = False
|
||||||
|
else:
|
||||||
|
# и -p, и -O: HTML в структуру -p, в файл встраиваем при наличии флагов
|
||||||
|
embed_tree = want_tree
|
||||||
|
embed_maket = want_maket
|
||||||
|
|
||||||
export_form_prototype(
|
export_form_prototype(
|
||||||
form_xml=form_xml,
|
form=form,
|
||||||
output_html=output_html,
|
output_html=output_html,
|
||||||
display_title=f"{object_name} · {args.form.strip()}",
|
display_title=display_title,
|
||||||
|
include_elements_tree=embed_tree,
|
||||||
|
include_text_maket=embed_maket,
|
||||||
)
|
)
|
||||||
|
written = output_html
|
||||||
|
|
||||||
except ResolveError as exc:
|
except ResolveError as exc:
|
||||||
print(f"error: {exc}", file=sys.stderr)
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"OK: {output_html}")
|
if written is not None:
|
||||||
print(f"src: {form_xml}")
|
print(f"OK: {written}", file=sys.stderr)
|
||||||
print(f"form_xml_to_html {__version__} — {__author__}")
|
print(f"src: {form_xml}", file=sys.stderr)
|
||||||
|
print(f"form_xml_to_html {__version__} — {__author__}", file=sys.stderr)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user