diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8363d..7099e26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ 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С + (``, ``, ``) — «Сегменты (Метизы)» вместо сырой разметки. + +--- + +## [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 ### Added diff --git a/README.md b/README.md index 4213966..62d7c12 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # export_1c_help -**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.0**) · [CHANGELOG](CHANGELOG.md) +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.4**) · [CHANGELOG](CHANGELOG.md) **Лицензия:** [MIT](LICENSE) Выгрузка встроенной справки **любой** конфигурации 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)). -Также: HTML-прототипы управляемых форм — `form_xml_to_html.py` (v0.1.0, Michael BAG ). +Также: HTML/текстовые прототипы управляемых форм — `form_xml_to_html.py` (v0.2.3, Michael BAG ). Зависимости: **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`) -Скрипт **v0.1.0**, автор **Michael BAG** . +Скрипт **v0.2.3**, автор **Michael BAG** . -Собирает интерактивный HTML по `Ext/Form.xml` (вкладки, группы, поля, фильтр). +Собирает интерактивный HTML и/или текстовое дерево/макет по `Ext/Form.xml`. +В `placeholder` полей: DataPath и тип данных. ```bash cd tools/export_1c_help -# объект конфигурации +# HTML в каталог-структуру python3 form_xml_to_html.py \ -b crm3-26 \ -o Справочник.Номенклатура \ - -f ФормаЭлемента + -f ФормаЭлемента \ + -p out/form-prototypes -# общая форма +# HTML в один файл + дерево элементов внутри страницы python3 form_xml_to_html.py \ -b crm3-26.rhana \ -o Общие \ - -f РК_ФормаНастроекРана + -f РК_ФормаНастроекРана \ + -O /tmp/РК_ФормаНастроекРана.html \ + -T -# параметры из JSON (форма всегда через -f) +# только консоль: дерево и ASCII-макет python3 form_xml_to_html.py \ - -c configs/form_prototype.crm3-26.json \ - -f ФормаЭлемента + -c configs/form_prototype.crm3-26.rhana.json \ + -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/`) | | `-o` / `--object` | `Справочник.Номенклатура`, `Документ.X`, … или `Общие` | | `-f` / `--form` | имя формы | -| `-p` / `--out-path` | корень вывода (по умолчанию `out/form-prototypes`) | -| `-c` / `--config` | JSON с `base`, `object`, `out_path`, `search_roots` (без формы) | +| `-p` / `--out-path` | корень вывода HTML: `/<конфа>/<тип>/<объект>/<форма>.html` | +| `-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`. --- diff --git a/VERSION b/VERSION index 1d0ba9e..6f2743d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.4 diff --git a/export1c_help/form_prototype.py b/export1c_help/form_prototype.py index 4e9179e..b637bfd 100644 --- a/export1c_help/form_prototype.py +++ b/export1c_help/form_prototype.py @@ -8,6 +8,7 @@ from __future__ import annotations import html import json +import re import xml.etree.ElementTree as ET from pathlib import Path from typing import Any @@ -15,7 +16,7 @@ from typing import Any 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 -__version__ = "0.1.0" +__version__ = "0.2.3" __author__ = "Michael BAG " __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") +def strip_v8_formatted(text: str) -> str: + """Убрать разметку форматированной строки 1С (, , …).""" + 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 + # content (типичный закрывающий тег платформы) + s = re.sub(r"<[A-Za-z][\w:-]*(?:\s[^>]*)?>(.*?)", r"\1", s, flags=re.S) + # content + s = re.sub( + r"<([A-Za-z][\w:-]*)(?:\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: for t in el: if local(t.tag) != "Title": continue for item in t.iter(): if local(item.tag) == "content" and item.text: - return item.text.strip() + return strip_v8_formatted(item.text) return "" @@ -318,6 +343,297 @@ def load_settings_file(path: Path) -> dict[str, Any]: return data +def format_v8_type(type_el: ET.Element | None) -> str: + """Краткое представление типа из узла формы/метаданных.""" + 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'' + ) + return '' + + return ( + f'
' + f'' + f'' + f"
" + ) + def parse_node(el: ET.Element) -> dict[str, Any] | None: tag = local(el.tag) 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), "read_only": get_bool(el, "ReadOnly", False), "data_path": get_text(el, "DataPath"), + "data_type": "", "group": get_text(el, "Group", "Vertical"), "representation": get_text(el, "Representation", "None"), "behavior": get_text(el, "Behavior", "Usual"), @@ -390,7 +707,7 @@ def parse_form(path: Path) -> dict[str, Any]: if local(c.tag) == "Title": for item in c.iter(): if local(item.tag) == "content" and item.text: - form_title = item.text.strip() + form_title = strip_v8_formatted(item.text) break children: list[dict[str, Any]] = [] @@ -401,10 +718,14 @@ def parse_form(path: Path) -> dict[str, Any]: if node is not None: children.append(node) + type_index = parse_form_type_index(root, path) + annotate_data_types(children, type_index) + return { "title": form_title or infer_title_from_path(path), "source": str(path), "children": children, + "type_index": type_index, } @@ -417,16 +738,24 @@ def render_field(node: dict[str, Any]) -> str: label = esc(label_of(node)) name = esc(node["name"]) dp = esc(node["data_path"]) + dtype = esc(node.get("data_type") or "") + ph = esc(field_placeholder(node)) 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 "" dis = " disabled" if not node["enabled"] else "" + meta_line = f"{name}" + (f" · {dp}" if dp else "") + (f" · {dtype}" if dtype else "") if tag == "CheckBoxField": + hint = render_copyable_hint(field_placeholder(node)) if ph else "" return ( f'' + f'{hint}' + f'{esc(meta_line)}' ) if tag == "RadioButtonField": @@ -437,15 +766,15 @@ def render_field(node: dict[str, Any]) -> str: f'' f'' f"" - f'{name} · {dp}' + f'{esc(meta_line)}' ) if tag == "LabelField": return ( f'
' f'
{label}
' - f'
—
' - f'{name} · {dp}
' + f'{render_copyable_hint(field_placeholder(node) or "—")}' + f'{esc(meta_line)}' ) if tag == "LabelDecoration": @@ -484,22 +813,22 @@ def render_field(node: dict[str, Any]) -> str: f'
' f'
{label}
' f'
{kind}
' - f'{name} · {dp}
' + f'{esc(meta_line)}' ) if tag == "PeriodField": return ( f'
' f'' - f'' - f'{name} · {dp}
' + f'{render_copyable_hint(ph, demo="01.01.2026 — 31.12.2026")}' + f'{esc(meta_line)}' ) return ( f'
' f'' - f'' - f'{name} · {dp}
' + f'{render_copyable_hint(ph)}' + f'{esc(meta_line)}' ) @@ -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"]) title = esc(form["title"]) source = esc(form["source"]) ver = esc(generator_version or __version__) author = esc(__author__) + extras = "" + if elements_tree: + extras += ( + '
' + "Дерево элементов" + f"
{esc(elements_tree)}
\n" + ) + if text_maket: + extras += ( + '
' + "Текстовый макет" + f"
{esc(text_maket)}
\n" + ) return f""" @@ -733,7 +1244,26 @@ details.group .group-body {{ padding: 10px; }} .field input[type="text"] {{ 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; }} .value-box {{ 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; }} .highlight {{ outline: 2px solid var(--accent) !important; background: var(--accent-soft) !important; }} .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) {{ .field {{ grid-template-columns: 1fr; }} .group.horizontal > .group-body > * {{ min-width: 100%; }} @@ -779,7 +1319,7 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }} -
+{extras}
@@ -821,6 +1361,30 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }} 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 stats = document.getElementById('stats'); function applyFilter() {{ @@ -868,14 +1432,22 @@ body.show-hidden .field.checkbox.is-hidden {{ display: flex !important; }} def export_form_prototype( *, - form_xml: Path, + form_xml: Path | None = None, + form: dict[str, Any] | None = None, output_html: Path, display_title: str | None = None, + include_elements_tree: bool = False, + include_text_maket: bool = False, ) -> Path: - form = parse_form(form_xml) + if form is None: + if form_xml is None: + raise ValueError("нужен form_xml или form") + form = parse_form(form_xml) if 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.parent.mkdir(parents=True, exist_ok=True) output_html.write_text(html_text, encoding="utf-8") diff --git a/form_xml_to_html.py b/form_xml_to_html.py index cd8f1b6..943fc32 100644 --- a/form_xml_to_html.py +++ b/form_xml_to_html.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 -"""CLI: HTML-прототип управляемой формы 1С из Form.xml. +"""CLI: HTML/текстовый прототип управляемой формы 1С из Form.xml. -Версия: 0.1.0 +Версия: 0.2.3 Автор: Michael BAG Примеры: - python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента - 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 РК_ФормаНастроекРана + python3 form_xml_to_html.py -b crm3-26 -o Справочник.Номенклатура -f ФормаЭлемента -p out/form-prototypes + python3 form_xml_to_html.py -b crm3-26.rhana -o Общие -f РК_ФормаНастроекРана -O /tmp/form.html -T + 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 @@ -22,9 +23,11 @@ from export1c_help.form_prototype import ( __email__, __version__, build_output_path, + compose_text_report, default_search_roots, export_form_prototype, load_settings_file, + parse_form, resolve_form_xml, ) from export1c_help.resolve import ResolveError @@ -34,7 +37,7 @@ def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="form_xml_to_html", description=( - "Собрать HTML-прототип формы 1С по -b/-o/-f " + "HTML/текстовый прототип формы 1С по -b/-o/-f " f"(v{__version__}, {__author__})" ), ) @@ -58,14 +61,36 @@ def _build_parser() -> argparse.ArgumentParser: "-p", "--out-path", help=( - "Каталог вывода: /<конфа>/<тип>/<объект>/<форма>.html " - "(по умолчанию out/form-prototypes)" + "Каталог вывода HTML: /<конфа>/<тип>/<объект>/<форма>.html" ), ) + 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( "-c", "--config", - help="JSON с параметрами (base, object, out_path, search_roots), без формы", + help="JSON с параметрами (base, object, out_path, out_file, search_roots), без формы", ) parser.add_argument( "--version", @@ -75,19 +100,16 @@ def _build_parser() -> argparse.ArgumentParser: return parser -def _resolve_out_path(raw: str | None, *, config_path: Path | None) -> Path: - tool_root = Path(__file__).resolve().parent - if not raw: - return (tool_root / "out" / "form-prototypes").resolve() +def _resolve_path(raw: str, *, config_path: Path | None) -> Path: path = Path(raw).expanduser() if path.is_absolute(): return path.resolve() - # Относительный путь: от cwd; если запускали с -c — также пробуем от каталога config cwd_path = (Path.cwd() / path).resolve() if path.parts[0] in (".", "..") or cwd_path.parent.exists(): return cwd_path if config_path is not None: return (config_path.parent / path).resolve() + tool_root = Path(__file__).resolve().parent return (tool_root / path).resolve() @@ -107,12 +129,30 @@ def main(argv: list[str] | None = None) -> int: base = args.base or settings.get("base") 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: parser.error("нужен -b/--base или base в -c/--config") if not object_ref: 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) extra_roots = settings.get("search_roots") or [] @@ -131,28 +171,74 @@ def main(argv: list[str] | None = None) -> int: form_name=args.form, search_roots=search_roots, ) - out_root = _resolve_out_path( - str(out_raw) if out_raw else None, config_path=config_path - ) - output_html = build_output_path( - out_root, - base_name=base_name, - type_dir=type_dir, - object_name=object_name, - form_name=args.form.strip(), - ) - export_form_prototype( - form_xml=form_xml, - output_html=output_html, - display_title=f"{object_name} · {args.form.strip()}", + form = parse_form(form_xml) + 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( + out_root, + base_name=base_name, + type_dir=type_dir, + object_name=object_name, + 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( + form=form, + output_html=output_html, + display_title=display_title, + include_elements_tree=embed_tree, + include_text_maket=embed_maket, + ) + written = output_html + except ResolveError as exc: print(f"error: {exc}", file=sys.stderr) return 1 - print(f"OK: {output_html}") - print(f"src: {form_xml}") - print(f"form_xml_to_html {__version__} — {__author__}") + if written is not None: + print(f"OK: {written}", file=sys.stderr) + print(f"src: {form_xml}", file=sys.stderr) + print(f"form_xml_to_html {__version__} — {__author__}", file=sys.stderr) return 0