|
|
|
@@ -0,0 +1,882 @@
|
|
|
|
|
"""HTML-прототип управляемой формы 1С из Form.xml.
|
|
|
|
|
|
|
|
|
|
Версия скрипта/модуля: см. __version__.
|
|
|
|
|
Автор: Michael BAG <mk@p7net.ru>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import html
|
|
|
|
|
import json
|
|
|
|
|
import xml.etree.ElementTree as ET
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
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"
|
|
|
|
|
__author__ = "Michael BAG <mk@p7net.ru>"
|
|
|
|
|
__email__ = "mk@p7net.ru"
|
|
|
|
|
|
|
|
|
|
# Русские/английские имена типов → каталог выгрузки
|
|
|
|
|
_TYPE_ALIASES: dict[str, str] = {
|
|
|
|
|
"справочник": "Catalogs",
|
|
|
|
|
"справочники": "Catalogs",
|
|
|
|
|
"catalog": "Catalogs",
|
|
|
|
|
"catalogs": "Catalogs",
|
|
|
|
|
"документ": "Documents",
|
|
|
|
|
"документы": "Documents",
|
|
|
|
|
"document": "Documents",
|
|
|
|
|
"documents": "Documents",
|
|
|
|
|
"журналдокументов": "DocumentJournals",
|
|
|
|
|
"documentjournal": "DocumentJournals",
|
|
|
|
|
"перечисление": "Enums",
|
|
|
|
|
"перечисления": "Enums",
|
|
|
|
|
"enum": "Enums",
|
|
|
|
|
"отчет": "Reports",
|
|
|
|
|
"отчёт": "Reports",
|
|
|
|
|
"отчеты": "Reports",
|
|
|
|
|
"отчёты": "Reports",
|
|
|
|
|
"report": "Reports",
|
|
|
|
|
"обработка": "DataProcessors",
|
|
|
|
|
"обработки": "DataProcessors",
|
|
|
|
|
"dataprocessor": "DataProcessors",
|
|
|
|
|
"регистрсведений": "InformationRegisters",
|
|
|
|
|
"informationregister": "InformationRegisters",
|
|
|
|
|
"регистрнакопления": "AccumulationRegisters",
|
|
|
|
|
"accumulationregister": "AccumulationRegisters",
|
|
|
|
|
"регистрбухгалтерии": "AccountingRegisters",
|
|
|
|
|
"accountingregister": "AccountingRegisters",
|
|
|
|
|
"регистррасчета": "CalculationRegisters",
|
|
|
|
|
"регистррасчёта": "CalculationRegisters",
|
|
|
|
|
"calculationregister": "CalculationRegisters",
|
|
|
|
|
"планвидовхарактеристик": "ChartsOfCharacteristicTypes",
|
|
|
|
|
"chartofcharacteristictypes": "ChartsOfCharacteristicTypes",
|
|
|
|
|
"плансчетов": "ChartsOfAccounts",
|
|
|
|
|
"chartofaccounts": "ChartsOfAccounts",
|
|
|
|
|
"планвидоврасчета": "ChartsOfCalculationTypes",
|
|
|
|
|
"планвидоврасчёта": "ChartsOfCalculationTypes",
|
|
|
|
|
"chartofcalculationtypes": "ChartsOfCalculationTypes",
|
|
|
|
|
"бизнеспроцесс": "BusinessProcesses",
|
|
|
|
|
"businessprocess": "BusinessProcesses",
|
|
|
|
|
"задача": "Tasks",
|
|
|
|
|
"task": "Tasks",
|
|
|
|
|
"планобмена": "ExchangePlans",
|
|
|
|
|
"exchangeplan": "ExchangePlans",
|
|
|
|
|
"хранилищенастроек": "SettingsStorages",
|
|
|
|
|
"settingsstorage": "SettingsStorages",
|
|
|
|
|
"общие": "CommonForms",
|
|
|
|
|
"общаяформа": "CommonForms",
|
|
|
|
|
"общиеформы": "CommonForms",
|
|
|
|
|
"commonform": "CommonForms",
|
|
|
|
|
"commonforms": "CommonForms",
|
|
|
|
|
"константа": "Constants",
|
|
|
|
|
"constant": "Constants",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _dir_name in DIR_TO_LINK_TYPE:
|
|
|
|
|
_TYPE_ALIASES[_dir_name.lower()] = _dir_name
|
|
|
|
|
for _link, _dir_name in LINK_TYPE_TO_DIR.items():
|
|
|
|
|
_TYPE_ALIASES[_link.lower()] = _dir_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
UI_TAGS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"Pages",
|
|
|
|
|
"Page",
|
|
|
|
|
"UsualGroup",
|
|
|
|
|
"InputField",
|
|
|
|
|
"CheckBoxField",
|
|
|
|
|
"LabelField",
|
|
|
|
|
"LabelDecoration",
|
|
|
|
|
"PictureDecoration",
|
|
|
|
|
"Button",
|
|
|
|
|
"Table",
|
|
|
|
|
"RadioButtonField",
|
|
|
|
|
"PictureField",
|
|
|
|
|
"HTMLDocumentField",
|
|
|
|
|
"SpreadSheetDocumentField",
|
|
|
|
|
"FormattedDocumentField",
|
|
|
|
|
"TextDocumentField",
|
|
|
|
|
"ColumnGroup",
|
|
|
|
|
"ButtonGroup",
|
|
|
|
|
"Popup",
|
|
|
|
|
"PeriodField",
|
|
|
|
|
"CalendarField",
|
|
|
|
|
"ProgressBarField",
|
|
|
|
|
"TrackBarField",
|
|
|
|
|
"ChartField",
|
|
|
|
|
"CommandBar",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
SKIP_TAGS = frozenset(
|
|
|
|
|
{
|
|
|
|
|
"ExtendedTooltip",
|
|
|
|
|
"ContextMenu",
|
|
|
|
|
"SearchStringAddition",
|
|
|
|
|
"ViewStatusAddition",
|
|
|
|
|
"SearchControlAddition",
|
|
|
|
|
"Events",
|
|
|
|
|
"Attributes",
|
|
|
|
|
"Commands",
|
|
|
|
|
"Parameters",
|
|
|
|
|
"CommandSet",
|
|
|
|
|
"AutoCommandBar",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def local(tag: str) -> str:
|
|
|
|
|
return tag.split("}")[-1] if "}" in tag else tag
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_text(el: ET.Element, name: str, default: str = "") -> str:
|
|
|
|
|
for c in el:
|
|
|
|
|
if local(c.tag) == name:
|
|
|
|
|
return (c.text or default).strip()
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_bool(el: ET.Element, name: str, default: bool = True) -> bool:
|
|
|
|
|
raw = get_text(el, name, "")
|
|
|
|
|
if not raw:
|
|
|
|
|
return default
|
|
|
|
|
return raw.lower() not in ("false", "0")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def esc(text: str) -> str:
|
|
|
|
|
return html.escape(text or "", quote=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_type_key(raw: str) -> str:
|
|
|
|
|
key = raw.strip().replace(" ", "").replace("_", "").replace("-", "")
|
|
|
|
|
return key.casefold()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_type_dir(type_name: str) -> str:
|
|
|
|
|
mapped = _TYPE_ALIASES.get(normalize_type_key(type_name))
|
|
|
|
|
if not mapped:
|
|
|
|
|
raise ResolveError(
|
|
|
|
|
f"неизвестный тип объекта: {type_name!r} "
|
|
|
|
|
"(например Справочник, Документ, Общие, Catalog, CommonForm)"
|
|
|
|
|
)
|
|
|
|
|
return mapped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_object_ref(object_ref: str) -> tuple[str, str | None]:
|
|
|
|
|
"""
|
|
|
|
|
Справочник.Номенклатура → (Catalogs, Номенклатура)
|
|
|
|
|
Общие → (CommonForms, None)
|
|
|
|
|
Catalog.Номенклатура → (Catalogs, Номенклатура)
|
|
|
|
|
"""
|
|
|
|
|
text = (object_ref or "").strip()
|
|
|
|
|
if not text:
|
|
|
|
|
raise ResolveError("не указан объект (-o / --object)")
|
|
|
|
|
|
|
|
|
|
if "." in text:
|
|
|
|
|
type_part, name_part = text.split(".", 1)
|
|
|
|
|
type_dir = resolve_type_dir(type_part)
|
|
|
|
|
name = name_part.strip()
|
|
|
|
|
if not name:
|
|
|
|
|
raise ResolveError(f"пустое имя объекта в {object_ref!r}")
|
|
|
|
|
return type_dir, name
|
|
|
|
|
|
|
|
|
|
type_dir = resolve_type_dir(text)
|
|
|
|
|
if type_dir != "CommonForms":
|
|
|
|
|
raise ResolveError(
|
|
|
|
|
f"для типа {text!r} укажите имя: {text}.ИмяОбъекта "
|
|
|
|
|
"(для общих форм достаточно: Общие)"
|
|
|
|
|
)
|
|
|
|
|
return type_dir, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def default_search_roots(config_path: Path | None = None) -> list[Path]:
|
|
|
|
|
roots: list[Path] = [Path.cwd()]
|
|
|
|
|
tool_root = Path(__file__).resolve().parent.parent
|
|
|
|
|
roots.append(tool_root)
|
|
|
|
|
roots.append(tool_root.parent) # tools/
|
|
|
|
|
roots.append(tool_root.parent.parent) # monorepo
|
|
|
|
|
if config_path is not None:
|
|
|
|
|
roots.insert(0, config_path.parent.resolve())
|
|
|
|
|
seen: set[Path] = set()
|
|
|
|
|
out: list[Path] = []
|
|
|
|
|
for r in roots:
|
|
|
|
|
r = r.resolve()
|
|
|
|
|
if r not in seen:
|
|
|
|
|
seen.add(r)
|
|
|
|
|
out.append(r)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_base_dir(base: str, search_roots: list[Path] | None = None) -> Path:
|
|
|
|
|
"""-b имя_каталога или путь к конфигурации / src."""
|
|
|
|
|
raw = Path(base).expanduser()
|
|
|
|
|
candidates: list[Path] = []
|
|
|
|
|
if raw.is_absolute():
|
|
|
|
|
candidates.append(raw)
|
|
|
|
|
else:
|
|
|
|
|
roots = search_roots or default_search_roots()
|
|
|
|
|
candidates.append(Path.cwd() / raw)
|
|
|
|
|
for root in roots:
|
|
|
|
|
candidates.append(root / raw)
|
|
|
|
|
|
|
|
|
|
tried: list[str] = []
|
|
|
|
|
for cand in candidates:
|
|
|
|
|
tried.append(str(cand))
|
|
|
|
|
if not cand.exists():
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
return resolve_config_root(cand)
|
|
|
|
|
except ResolveError:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
raise ResolveError(
|
|
|
|
|
f"конфигурация {base!r} не найдена. Проверено:\n - " + "\n - ".join(tried)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_form_xml(
|
|
|
|
|
*,
|
|
|
|
|
base: str,
|
|
|
|
|
object_ref: str,
|
|
|
|
|
form_name: str,
|
|
|
|
|
search_roots: list[Path] | None = None,
|
|
|
|
|
) -> tuple[Path, Path, str, str, str]:
|
|
|
|
|
"""
|
|
|
|
|
Returns:
|
|
|
|
|
form_xml, config_root, base_name, type_dir, object_name
|
|
|
|
|
"""
|
|
|
|
|
form_name = (form_name or "").strip()
|
|
|
|
|
if not form_name:
|
|
|
|
|
raise ResolveError("не указано имя формы (-f / --form)")
|
|
|
|
|
|
|
|
|
|
config_root = resolve_base_dir(base, search_roots)
|
|
|
|
|
src = resolve_src_root(config_root)
|
|
|
|
|
type_dir, object_name = parse_object_ref(object_ref)
|
|
|
|
|
|
|
|
|
|
if type_dir == "CommonForms":
|
|
|
|
|
obj = object_name or form_name
|
|
|
|
|
if object_name and object_name != form_name:
|
|
|
|
|
raise ResolveError(
|
|
|
|
|
f"для общей формы имя объекта ({object_name}) "
|
|
|
|
|
f"и формы ({form_name}) должны совпадать"
|
|
|
|
|
)
|
|
|
|
|
form_xml = src / "CommonForms" / form_name / "Ext" / "Form.xml"
|
|
|
|
|
object_name = obj
|
|
|
|
|
else:
|
|
|
|
|
assert object_name is not None
|
|
|
|
|
form_xml = src / type_dir / object_name / "Forms" / form_name / "Ext" / "Form.xml"
|
|
|
|
|
|
|
|
|
|
if not form_xml.is_file():
|
|
|
|
|
raise ResolveError(f"Form.xml не найден: {form_xml}")
|
|
|
|
|
|
|
|
|
|
return form_xml, config_root, config_root.name, type_dir, object_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def type_dir_label(type_dir: str) -> str:
|
|
|
|
|
return DIR_TO_RU.get(type_dir, type_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_output_path(
|
|
|
|
|
out_path: Path,
|
|
|
|
|
*,
|
|
|
|
|
base_name: str,
|
|
|
|
|
type_dir: str,
|
|
|
|
|
object_name: str,
|
|
|
|
|
form_name: str,
|
|
|
|
|
) -> Path:
|
|
|
|
|
"""out_path / confa / тип / объект / форма.html"""
|
|
|
|
|
return (
|
|
|
|
|
Path(out_path)
|
|
|
|
|
/ base_name
|
|
|
|
|
/ type_dir_label(type_dir)
|
|
|
|
|
/ object_name
|
|
|
|
|
/ f"{form_name}.html"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_settings_file(path: Path) -> dict[str, Any]:
|
|
|
|
|
path = Path(path).expanduser().resolve()
|
|
|
|
|
if not path.is_file():
|
|
|
|
|
raise ResolveError(f"файл настроек не найден: {path}")
|
|
|
|
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
raise ResolveError(f"ожидался JSON-объект в {path}")
|
|
|
|
|
data["_config_path"] = str(path)
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_node(el: ET.Element) -> dict[str, Any] | None:
|
|
|
|
|
tag = local(el.tag)
|
|
|
|
|
if tag in SKIP_TAGS:
|
|
|
|
|
return None
|
|
|
|
|
if tag == "ChildItems":
|
|
|
|
|
children = []
|
|
|
|
|
for c in el:
|
|
|
|
|
node = parse_node(c)
|
|
|
|
|
if node is not None:
|
|
|
|
|
children.append(node)
|
|
|
|
|
return {"tag": "ChildItems", "children": children}
|
|
|
|
|
|
|
|
|
|
if tag not in UI_TAGS:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
node: dict[str, Any] = {
|
|
|
|
|
"tag": tag,
|
|
|
|
|
"name": el.get("name") or "",
|
|
|
|
|
"id": el.get("id") or "",
|
|
|
|
|
"title": get_title(el),
|
|
|
|
|
"visible": get_bool(el, "Visible", True),
|
|
|
|
|
"enabled": get_bool(el, "Enabled", True),
|
|
|
|
|
"read_only": get_bool(el, "ReadOnly", False),
|
|
|
|
|
"data_path": get_text(el, "DataPath"),
|
|
|
|
|
"group": get_text(el, "Group", "Vertical"),
|
|
|
|
|
"representation": get_text(el, "Representation", "None"),
|
|
|
|
|
"behavior": get_text(el, "Behavior", "Usual"),
|
|
|
|
|
"show_title": get_bool(el, "ShowTitle", True),
|
|
|
|
|
"united": get_bool(el, "United", True),
|
|
|
|
|
"check_box_type": get_text(el, "CheckBoxType", "Auto"),
|
|
|
|
|
"children": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for c in el:
|
|
|
|
|
if local(c.tag) == "ChildItems":
|
|
|
|
|
for grandchild in c:
|
|
|
|
|
child = parse_node(grandchild)
|
|
|
|
|
if child is not None:
|
|
|
|
|
node["children"].append(child)
|
|
|
|
|
elif local(c.tag) in UI_TAGS:
|
|
|
|
|
child = parse_node(c)
|
|
|
|
|
if child is not None:
|
|
|
|
|
node["children"].append(child)
|
|
|
|
|
|
|
|
|
|
return node
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def infer_title_from_path(path: Path) -> str:
|
|
|
|
|
parts = list(path.parts)
|
|
|
|
|
try:
|
|
|
|
|
forms_idx = parts.index("Forms")
|
|
|
|
|
form_name = parts[forms_idx + 1] if forms_idx + 1 < len(parts) else ""
|
|
|
|
|
object_name = parts[forms_idx - 1] if forms_idx > 0 else ""
|
|
|
|
|
if object_name and form_name:
|
|
|
|
|
return f"{object_name} · {form_name}"
|
|
|
|
|
return form_name or object_name or path.stem
|
|
|
|
|
except ValueError:
|
|
|
|
|
try:
|
|
|
|
|
idx = parts.index("CommonForms")
|
|
|
|
|
name = parts[idx + 1] if idx + 1 < len(parts) else path.stem
|
|
|
|
|
return f"Общая форма · {name}"
|
|
|
|
|
except ValueError:
|
|
|
|
|
return path.stem
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_form(path: Path) -> dict[str, Any]:
|
|
|
|
|
root = ET.parse(path).getroot()
|
|
|
|
|
form_title = ""
|
|
|
|
|
for c in root:
|
|
|
|
|
if local(c.tag) == "Title":
|
|
|
|
|
for item in c.iter():
|
|
|
|
|
if local(item.tag) == "content" and item.text:
|
|
|
|
|
form_title = item.text.strip()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
children: list[dict[str, Any]] = []
|
|
|
|
|
for c in root:
|
|
|
|
|
if local(c.tag) == "ChildItems":
|
|
|
|
|
for item in c:
|
|
|
|
|
node = parse_node(item)
|
|
|
|
|
if node is not None:
|
|
|
|
|
children.append(node)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"title": form_title or infer_title_from_path(path),
|
|
|
|
|
"source": str(path),
|
|
|
|
|
"children": children,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def label_of(node: dict[str, Any]) -> str:
|
|
|
|
|
return node["title"] or node["name"] or node["tag"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_field(node: dict[str, Any]) -> str:
|
|
|
|
|
tag = node["tag"]
|
|
|
|
|
label = esc(label_of(node))
|
|
|
|
|
name = esc(node["name"])
|
|
|
|
|
dp = esc(node["data_path"])
|
|
|
|
|
hidden = "" if node["visible"] else " is-hidden"
|
|
|
|
|
meta = f'data-name="{name}" data-type="{esc(tag)}" data-path="{dp}"'
|
|
|
|
|
ro = " readonly" if node["read_only"] else ""
|
|
|
|
|
dis = " disabled" if not node["enabled"] else ""
|
|
|
|
|
|
|
|
|
|
if tag == "CheckBoxField":
|
|
|
|
|
return (
|
|
|
|
|
f'<label class="field checkbox{hidden}" {meta}>'
|
|
|
|
|
f'<input type="checkbox"{ro}{dis}> <span class="caption">{label}</span>'
|
|
|
|
|
f'<span class="meta">{name}</span></label>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag == "RadioButtonField":
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="field radio{hidden}" {meta}>'
|
|
|
|
|
f'<div class="caption">{label}</div>'
|
|
|
|
|
f'<div class="radio-row">'
|
|
|
|
|
f'<label><input type="radio" name="{name}" checked{dis}> Вариант 1</label>'
|
|
|
|
|
f'<label><input type="radio" name="{name}"{dis}> Вариант 2</label>'
|
|
|
|
|
f"</div>"
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag == "LabelField":
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="field label-field{hidden}" {meta}>'
|
|
|
|
|
f'<div class="caption">{label}</div>'
|
|
|
|
|
f'<div class="value-box">—</div>'
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag == "LabelDecoration":
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="decoration label{hidden}" {meta}>'
|
|
|
|
|
f'{label}<span class="meta">{name}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag in ("PictureDecoration", "PictureField"):
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="decoration picture{hidden}" {meta}>'
|
|
|
|
|
f'<div class="pic-stub">🖼</div>'
|
|
|
|
|
f'<span class="caption">{label}</span>'
|
|
|
|
|
f'<span class="meta">{name}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag == "Button":
|
|
|
|
|
return (
|
|
|
|
|
f'<button type="button" class="btn{hidden}" {meta}{dis}>{label}'
|
|
|
|
|
f'<span class="meta">{name}</span></button>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag in (
|
|
|
|
|
"HTMLDocumentField",
|
|
|
|
|
"SpreadSheetDocumentField",
|
|
|
|
|
"FormattedDocumentField",
|
|
|
|
|
"TextDocumentField",
|
|
|
|
|
):
|
|
|
|
|
kind = {
|
|
|
|
|
"HTMLDocumentField": "HTML",
|
|
|
|
|
"SpreadSheetDocumentField": "Табличный документ",
|
|
|
|
|
"FormattedDocumentField": "Форматированный документ",
|
|
|
|
|
"TextDocumentField": "Текстовый документ",
|
|
|
|
|
}[tag]
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="field document{hidden}" {meta}>'
|
|
|
|
|
f'<div class="caption">{label}</div>'
|
|
|
|
|
f'<div class="doc-stub">{kind}</div>'
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if tag == "PeriodField":
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="field{hidden}" {meta}>'
|
|
|
|
|
f'<label class="caption">{label}</label>'
|
|
|
|
|
f'<input type="text" value="01.01.2026 — 31.12.2026"{ro}{dis}>'
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="field{hidden}" {meta}>'
|
|
|
|
|
f'<label class="caption">{label}</label>'
|
|
|
|
|
f'<input type="text" placeholder="{dp or name}"{ro}{dis}>'
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_table(node: dict[str, Any]) -> str:
|
|
|
|
|
label = esc(label_of(node))
|
|
|
|
|
name = esc(node["name"])
|
|
|
|
|
dp = esc(node["data_path"])
|
|
|
|
|
hidden = "" if node["visible"] else " is-hidden"
|
|
|
|
|
cols: list[str] = []
|
|
|
|
|
|
|
|
|
|
def collect_columns(children: list[dict[str, Any]]) -> None:
|
|
|
|
|
for ch in children:
|
|
|
|
|
if ch["tag"] == "ColumnGroup":
|
|
|
|
|
collect_columns(ch["children"])
|
|
|
|
|
elif ch["tag"] in (
|
|
|
|
|
"InputField",
|
|
|
|
|
"CheckBoxField",
|
|
|
|
|
"LabelField",
|
|
|
|
|
"PictureField",
|
|
|
|
|
):
|
|
|
|
|
cols.append(label_of(ch) or ch["name"])
|
|
|
|
|
|
|
|
|
|
collect_columns(node["children"])
|
|
|
|
|
if not cols:
|
|
|
|
|
cols = ["Колонка 1", "Колонка 2", "Колонка 3"]
|
|
|
|
|
|
|
|
|
|
head = "".join(f"<th>{esc(c)}</th>" for c in cols)
|
|
|
|
|
empty = "".join("<td></td>" for _ in cols)
|
|
|
|
|
rows = "".join(f"<tr>{empty}</tr>" for _ in range(3))
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="table-wrap{hidden}" data-name="{name}" data-type="Table" data-path="{dp}">'
|
|
|
|
|
f'<div class="caption">{label}</div>'
|
|
|
|
|
f"<table><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table>"
|
|
|
|
|
f'<span class="meta">{name} · {dp}</span></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_group(node: dict[str, Any], depth: int = 0) -> str:
|
|
|
|
|
tag = node["tag"]
|
|
|
|
|
if tag in (
|
|
|
|
|
"InputField",
|
|
|
|
|
"CheckBoxField",
|
|
|
|
|
"LabelField",
|
|
|
|
|
"LabelDecoration",
|
|
|
|
|
"PictureDecoration",
|
|
|
|
|
"PictureField",
|
|
|
|
|
"Button",
|
|
|
|
|
"RadioButtonField",
|
|
|
|
|
"HTMLDocumentField",
|
|
|
|
|
"SpreadSheetDocumentField",
|
|
|
|
|
"FormattedDocumentField",
|
|
|
|
|
"TextDocumentField",
|
|
|
|
|
"PeriodField",
|
|
|
|
|
):
|
|
|
|
|
return render_field(node)
|
|
|
|
|
|
|
|
|
|
if tag == "Table":
|
|
|
|
|
return render_table(node)
|
|
|
|
|
|
|
|
|
|
if tag == "Pages":
|
|
|
|
|
return render_pages(node)
|
|
|
|
|
|
|
|
|
|
if tag == "Page":
|
|
|
|
|
return "".join(render_group(ch, depth + 1) for ch in node["children"])
|
|
|
|
|
|
|
|
|
|
if tag in ("ButtonGroup", "Popup", "CommandBar", "ColumnGroup"):
|
|
|
|
|
body = "".join(render_group(ch, depth + 1) for ch in node["children"])
|
|
|
|
|
cls = "button-group" if tag == "ButtonGroup" else "nested"
|
|
|
|
|
hidden = "" if node["visible"] else " is-hidden"
|
|
|
|
|
return f'<div class="{cls}{hidden}">{body}</div>'
|
|
|
|
|
|
|
|
|
|
orient = (node["group"] or "Vertical").lower()
|
|
|
|
|
is_horizontal = "horizontal" in orient
|
|
|
|
|
behavior_raw = node["behavior"] or "Usual"
|
|
|
|
|
collapsible = behavior_raw in ("Collapsible", "Pop-up", "PopUp")
|
|
|
|
|
if not collapsible and (node["name"] or "").startswith("Сворачиваемая"):
|
|
|
|
|
collapsible = True
|
|
|
|
|
|
|
|
|
|
hidden = "" if node["visible"] else " is-hidden"
|
|
|
|
|
title = label_of(node)
|
|
|
|
|
show_title = node["show_title"] and bool(title)
|
|
|
|
|
name = esc(node["name"])
|
|
|
|
|
cls = "group horizontal" if is_horizontal else "group vertical"
|
|
|
|
|
if collapsible:
|
|
|
|
|
cls += " collapsible open"
|
|
|
|
|
|
|
|
|
|
inner = "".join(render_group(ch, depth + 1) for ch in node["children"])
|
|
|
|
|
if collapsible and show_title:
|
|
|
|
|
return (
|
|
|
|
|
f'<details class="{cls}{hidden}" open '
|
|
|
|
|
f'data-name="{name}" data-type="UsualGroup">'
|
|
|
|
|
f"<summary><span>{esc(title)}</span>"
|
|
|
|
|
f'<span class="meta">{name}</span></summary>'
|
|
|
|
|
f'<div class="group-body">{inner}</div></details>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
title_html = (
|
|
|
|
|
f'<div class="group-title">{esc(title)}'
|
|
|
|
|
f'<span class="meta">{name}</span></div>'
|
|
|
|
|
if show_title
|
|
|
|
|
else f'<span class="meta only">{name}</span>'
|
|
|
|
|
)
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="{cls}{hidden}" data-name="{name}" data-type="UsualGroup">'
|
|
|
|
|
f'{title_html}<div class="group-body">{inner}</div></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_pages(node: dict[str, Any]) -> str:
|
|
|
|
|
pages = [ch for ch in node["children"] if ch["tag"] == "Page"]
|
|
|
|
|
if not pages:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
name = esc(node["name"])
|
|
|
|
|
tabs = []
|
|
|
|
|
panels = []
|
|
|
|
|
for i, page in enumerate(pages):
|
|
|
|
|
pid = f"{name}-{i}"
|
|
|
|
|
active = " active" if i == 0 else ""
|
|
|
|
|
hidden = "" if page["visible"] else " is-hidden"
|
|
|
|
|
tabs.append(
|
|
|
|
|
f'<button type="button" class="tab{active}{hidden}" data-tab="{esc(pid)}" '
|
|
|
|
|
f'data-name="{esc(page["name"])}">{esc(label_of(page))}</button>'
|
|
|
|
|
)
|
|
|
|
|
body = "".join(render_group(ch, 1) for ch in page["children"])
|
|
|
|
|
panels.append(
|
|
|
|
|
f'<div class="tab-panel{active}{hidden}" id="{esc(pid)}" '
|
|
|
|
|
f'data-name="{esc(page["name"])}" data-type="Page">{body}</div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
f'<div class="pages" data-name="{name}" data-type="Pages">'
|
|
|
|
|
f'<div class="tabs">{"".join(tabs)}</div>'
|
|
|
|
|
f'<div class="tab-panels">{"".join(panels)}</div></div>'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_html(form: dict[str, Any], *, generator_version: 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__)
|
|
|
|
|
return f"""<!DOCTYPE html>
|
|
|
|
|
<html lang="ru">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<meta name="generator" content="form_xml_to_html {ver}">
|
|
|
|
|
<meta name="author" content="{author}">
|
|
|
|
|
<title>Прототип: {title}</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {{
|
|
|
|
|
--bg: #f0f0f0;
|
|
|
|
|
--panel: #ffffff;
|
|
|
|
|
--border: #c8c8c8;
|
|
|
|
|
--muted: #6b6b6b;
|
|
|
|
|
--accent: #3a7fc1;
|
|
|
|
|
--accent-soft: #e8f1fa;
|
|
|
|
|
--title: #2a2a2a;
|
|
|
|
|
--warn-bg: #fff8e1;
|
|
|
|
|
--hidden-bg: repeating-linear-gradient(45deg,#f7f7f7,#f7f7f7 6px,#efefef 6px,#efefef 12px);
|
|
|
|
|
--font: "Segoe UI", "Tahoma", sans-serif;
|
|
|
|
|
}}
|
|
|
|
|
* {{ box-sizing: border-box; }}
|
|
|
|
|
body {{
|
|
|
|
|
margin: 0; font-family: var(--font); background: var(--bg); color: var(--title);
|
|
|
|
|
}}
|
|
|
|
|
.toolbar {{
|
|
|
|
|
position: sticky; top: 0; z-index: 20; display: flex; flex-wrap: wrap; gap: 8px;
|
|
|
|
|
align-items: center; padding: 10px 14px; background: #e8e8e8; border-bottom: 1px solid var(--border);
|
|
|
|
|
}}
|
|
|
|
|
.toolbar h1 {{ font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }}
|
|
|
|
|
.toolbar .src {{ font-size: 11px; color: var(--muted); max-width: 42vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
|
|
|
|
.toolbar label {{ font-size: 12px; display: flex; gap: 6px; align-items: center; }}
|
|
|
|
|
.toolbar input[type="search"] {{
|
|
|
|
|
width: 220px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 3px;
|
|
|
|
|
}}
|
|
|
|
|
.toolbar button, .btn {{
|
|
|
|
|
border: 1px solid var(--border); background: #fafafa; padding: 4px 10px; border-radius: 3px;
|
|
|
|
|
cursor: pointer; font: inherit; font-size: 12px;
|
|
|
|
|
}}
|
|
|
|
|
.toolbar button:hover, .btn:hover {{ background: var(--accent-soft); border-color: var(--accent); }}
|
|
|
|
|
.window {{
|
|
|
|
|
margin: 12px; background: var(--panel); border: 1px solid var(--border); border-radius: 4px;
|
|
|
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.08); min-height: 70vh;
|
|
|
|
|
}}
|
|
|
|
|
.commandbar {{
|
|
|
|
|
display: flex; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); background: #f7f7f7;
|
|
|
|
|
}}
|
|
|
|
|
.content {{ padding: 12px; }}
|
|
|
|
|
.pages {{ display: flex; flex-direction: column; gap: 0; }}
|
|
|
|
|
.tabs {{ display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 10px; }}
|
|
|
|
|
.tab {{
|
|
|
|
|
border: 1px solid transparent; border-bottom: none; background: transparent;
|
|
|
|
|
padding: 7px 14px; cursor: pointer; font: inherit; font-size: 13px; color: var(--muted);
|
|
|
|
|
border-radius: 4px 4px 0 0;
|
|
|
|
|
}}
|
|
|
|
|
.tab.active {{ background: var(--panel); border-color: var(--border); color: var(--title); font-weight: 600; }}
|
|
|
|
|
.tab-panel {{ display: none; }}
|
|
|
|
|
.tab-panel.active {{ display: block; }}
|
|
|
|
|
.group {{
|
|
|
|
|
border: 1px solid transparent; margin: 0 0 8px; padding: 4px;
|
|
|
|
|
}}
|
|
|
|
|
.group.vertical > .group-body {{ display: flex; flex-direction: column; gap: 6px; }}
|
|
|
|
|
.group.horizontal > .group-body {{ display: flex; flex-direction: row; flex-wrap: wrap; gap: 12px; align-items: flex-start; }}
|
|
|
|
|
.group.horizontal > .group-body > * {{ flex: 1 1 320px; min-width: 280px; }}
|
|
|
|
|
.group-title {{
|
|
|
|
|
font-size: 12px; font-weight: 600; color: var(--muted); margin: 2px 0 6px;
|
|
|
|
|
}}
|
|
|
|
|
details.group {{
|
|
|
|
|
border: 1px solid var(--border); border-radius: 4px; background: #fcfcfc; padding: 0;
|
|
|
|
|
}}
|
|
|
|
|
details.group > summary {{
|
|
|
|
|
list-style: none; cursor: pointer; padding: 8px 10px; background: #f3f6f9;
|
|
|
|
|
border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px;
|
|
|
|
|
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
|
|
|
|
}}
|
|
|
|
|
details.group > summary::-webkit-details-marker {{ display: none; }}
|
|
|
|
|
details.group > summary::before {{ content: "▾ "; color: var(--accent); }}
|
|
|
|
|
details.group:not([open]) > summary::before {{ content: "▸ "; }}
|
|
|
|
|
details.group .group-body {{ padding: 10px; }}
|
|
|
|
|
.field {{
|
|
|
|
|
display: grid; grid-template-columns: minmax(140px, 220px) 1fr; gap: 4px 10px;
|
|
|
|
|
align-items: center; padding: 2px 0;
|
|
|
|
|
}}
|
|
|
|
|
.field.checkbox, .field.radio, .decoration, .document, .table-wrap {{
|
|
|
|
|
display: block; grid-template-columns: none;
|
|
|
|
|
}}
|
|
|
|
|
.field .caption, .decoration .caption {{ font-size: 12px; color: #333; }}
|
|
|
|
|
.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; }}
|
|
|
|
|
.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);
|
|
|
|
|
}}
|
|
|
|
|
.doc-stub, .pic-stub {{
|
|
|
|
|
border: 1px dashed var(--border); background: var(--warn-bg); min-height: 80px;
|
|
|
|
|
display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12px;
|
|
|
|
|
border-radius: 3px; margin-top: 4px;
|
|
|
|
|
}}
|
|
|
|
|
.pic-stub {{ width: 96px; height: 96px; font-size: 28px; }}
|
|
|
|
|
.decoration.label {{ font-size: 12px; color: var(--muted); padding: 2px 0; }}
|
|
|
|
|
.table-wrap table {{ width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; }}
|
|
|
|
|
.table-wrap th, .table-wrap td {{ border: 1px solid var(--border); padding: 4px 6px; height: 22px; }}
|
|
|
|
|
.table-wrap th {{ background: #f0f4f8; text-align: left; }}
|
|
|
|
|
.btn {{ display: inline-flex; align-items: center; gap: 8px; margin: 4px 0; }}
|
|
|
|
|
.meta {{
|
|
|
|
|
display: none; font-size: 10px; color: #8a8a8a; font-weight: 400; margin-left: 6px;
|
|
|
|
|
}}
|
|
|
|
|
body.show-meta .meta {{ display: inline; }}
|
|
|
|
|
body.show-meta .meta.only {{ display: block; margin: 0 0 4px; }}
|
|
|
|
|
.is-hidden {{ display: none !important; }}
|
|
|
|
|
body.show-hidden .is-hidden {{
|
|
|
|
|
display: block !important; outline: 1px dashed #e0a800; background: var(--hidden-bg);
|
|
|
|
|
}}
|
|
|
|
|
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; }}
|
|
|
|
|
@media (max-width: 800px) {{
|
|
|
|
|
.field {{ grid-template-columns: 1fr; }}
|
|
|
|
|
.group.horizontal > .group-body > * {{ min-width: 100%; }}
|
|
|
|
|
}}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<h1>{title}</h1>
|
|
|
|
|
<span class="src" title="{source}">{source}</span>
|
|
|
|
|
<input type="search" id="filter" placeholder="Фильтр: имя / заголовок / DataPath">
|
|
|
|
|
<label><input type="checkbox" id="showHidden"> Скрытые</label>
|
|
|
|
|
<label><input type="checkbox" id="showMeta"> Имена элементов</label>
|
|
|
|
|
<button type="button" id="expandAll">Развернуть группы</button>
|
|
|
|
|
<button type="button" id="collapseAll">Свернуть группы</button>
|
|
|
|
|
<span class="stats" id="stats"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="window">
|
|
|
|
|
<div class="commandbar">
|
|
|
|
|
<button type="button" class="btn">Записать и закрыть</button>
|
|
|
|
|
<button type="button" class="btn">Записать</button>
|
|
|
|
|
<button type="button" class="btn">Закрыть</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="content" id="form-root">
|
|
|
|
|
{body}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
(function () {{
|
|
|
|
|
const root = document.getElementById('form-root');
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.pages').forEach(pages => {{
|
|
|
|
|
const tabs = pages.querySelectorAll(':scope > .tabs > .tab');
|
|
|
|
|
const panels = pages.querySelectorAll(':scope > .tab-panels > .tab-panel');
|
|
|
|
|
tabs.forEach(tab => {{
|
|
|
|
|
tab.addEventListener('click', () => {{
|
|
|
|
|
const id = tab.getAttribute('data-tab');
|
|
|
|
|
tabs.forEach(t => t.classList.toggle('active', t === tab));
|
|
|
|
|
panels.forEach(p => p.classList.toggle('active', p.id === id));
|
|
|
|
|
}});
|
|
|
|
|
}});
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
const showHidden = document.getElementById('showHidden');
|
|
|
|
|
const showMeta = document.getElementById('showMeta');
|
|
|
|
|
showHidden.addEventListener('change', () => {{
|
|
|
|
|
document.body.classList.toggle('show-hidden', showHidden.checked);
|
|
|
|
|
}});
|
|
|
|
|
showMeta.addEventListener('change', () => {{
|
|
|
|
|
document.body.classList.toggle('show-meta', showMeta.checked);
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
document.getElementById('expandAll').addEventListener('click', () => {{
|
|
|
|
|
root.querySelectorAll('details.group').forEach(d => d.open = true);
|
|
|
|
|
}});
|
|
|
|
|
document.getElementById('collapseAll').addEventListener('click', () => {{
|
|
|
|
|
root.querySelectorAll('details.group').forEach(d => d.open = false);
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
const filter = document.getElementById('filter');
|
|
|
|
|
const stats = document.getElementById('stats');
|
|
|
|
|
function applyFilter() {{
|
|
|
|
|
const q = filter.value.trim().toLowerCase();
|
|
|
|
|
const nodes = root.querySelectorAll('[data-name]');
|
|
|
|
|
let matched = 0;
|
|
|
|
|
nodes.forEach(el => {{
|
|
|
|
|
el.classList.remove('highlight');
|
|
|
|
|
if (!q) return;
|
|
|
|
|
const hay = [
|
|
|
|
|
el.getAttribute('data-name') || '',
|
|
|
|
|
el.getAttribute('data-path') || '',
|
|
|
|
|
el.getAttribute('data-type') || '',
|
|
|
|
|
el.textContent || ''
|
|
|
|
|
].join(' ').toLowerCase();
|
|
|
|
|
if (hay.includes(q)) {{
|
|
|
|
|
el.classList.add('highlight');
|
|
|
|
|
matched += 1;
|
|
|
|
|
let p = el.parentElement;
|
|
|
|
|
while (p) {{
|
|
|
|
|
if (p.tagName === 'DETAILS') p.open = true;
|
|
|
|
|
if (p.classList && p.classList.contains('tab-panel')) {{
|
|
|
|
|
const id = p.id;
|
|
|
|
|
const pages = p.closest('.pages');
|
|
|
|
|
if (pages) {{
|
|
|
|
|
pages.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.getAttribute('data-tab') === id));
|
|
|
|
|
pages.querySelectorAll('.tab-panel').forEach(tp => tp.classList.toggle('active', tp.id === id));
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
p = p.parentElement;
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
const total = root.querySelectorAll('[data-name]').length;
|
|
|
|
|
stats.textContent = q ? `найдено ${{matched}} / ${{total}}` : `элементов: ${{total}}`;
|
|
|
|
|
}}
|
|
|
|
|
filter.addEventListener('input', applyFilter);
|
|
|
|
|
applyFilter();
|
|
|
|
|
}})();
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_form_prototype(
|
|
|
|
|
*,
|
|
|
|
|
form_xml: Path,
|
|
|
|
|
output_html: Path,
|
|
|
|
|
display_title: str | None = None,
|
|
|
|
|
) -> Path:
|
|
|
|
|
form = parse_form(form_xml)
|
|
|
|
|
if display_title:
|
|
|
|
|
form["title"] = display_title
|
|
|
|
|
html_text = build_html(form)
|
|
|
|
|
output_html = Path(output_html)
|
|
|
|
|
output_html.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
output_html.write_text(html_text, encoding="utf-8")
|
|
|
|
|
return output_html
|