Release 0.5.0: form_wiki_export — выгрузка HTML-форм в Gitea wiki.

Полный обход Form.xml, настраиваемый wiki_prefix, русские типы в именах, ссылки без percent-encoding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mihailkudravcev
2026-08-14 16:38:04 +03:00
parent f6003e9ebf
commit f55c654de2
7 changed files with 1009 additions and 2 deletions
+636
View File
@@ -0,0 +1,636 @@
"""Batch export of 1C managed form HTML prototypes to Gitea wiki."""
from __future__ import annotations
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from export1c_help import __version__
from export1c_help.form_prototype import (
default_search_roots,
export_form_prototype,
infer_title_from_path,
load_settings_file,
parse_form,
resolve_base_dir,
type_dir_label,
)
from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text
from export1c_help.resolve import ResolveError, resolve_src_root
from export1c_help.wiki import WikiPushError, prepare_wiki_clone
MANIFEST_NAME = "manifest.export_forms.json"
DEFAULT_WIKI_PREFIX = "form_"
_EXPORT_FORMS_MARKER_BEGIN = "<!-- export_forms:begin -->"
_EXPORT_FORMS_MARKER_END = "<!-- export_forms:end -->"
_RESERVED_ROOT_PAGES = {"Home.md", "History.md", "_Sidebar.md"}
_UNSAFE_SLUG_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
_PREFIX_RE = re.compile(r"[^\w.\-]+", re.UNICODE)
@dataclass(frozen=True)
class FormRecord:
form_xml: Path
config_root: Path
base_name: str
type_dir: str
object_name: str
form_name: str
stem: str
@dataclass(frozen=True)
class FormsWikiStats:
forms_total: int
files_written: int
files_deleted: int
html_files: int
markdown_files: int
skipped: int = 0
def _sanitize_slug_part(value: str) -> str:
text = (value or "").strip()
text = _UNSAFE_SLUG_RE.sub("_", text)
text = re.sub(r"_+", "_", text).strip("_")
return text or "unnamed"
def normalize_wiki_prefix(raw: str | None) -> str:
"""Префикс имён wiki-страниц; пустой → default. Всегда заканчивается на `_`."""
text = (raw or "").strip()
if not text:
return DEFAULT_WIKI_PREFIX
text = _PREFIX_RE.sub("_", text)
text = re.sub(r"_+", "_", text)
if not text.endswith("_"):
text += "_"
return text or DEFAULT_WIKI_PREFIX
def index_page_name(prefix: str) -> str:
return f"{normalize_wiki_prefix(prefix)}INDEX.md"
def form_wiki_stem(
*,
base_name: str,
type_dir: str,
object_name: str,
form_name: str,
prefix: str | None = None,
) -> str:
type_label = type_dir_label(type_dir)
if type_dir == "CommonForms":
parts = [base_name, type_label, form_name]
else:
parts = [base_name, type_label, object_name, form_name]
slug = "__".join(_sanitize_slug_part(p) for p in parts)
return f"{normalize_wiki_prefix(prefix)}{slug}"
def decode_form_xml_path(
form_xml: Path,
config_root: Path,
*,
prefix: str | None = None,
) -> FormRecord | None:
src = resolve_src_root(config_root)
try:
rel = form_xml.resolve().relative_to(src.resolve())
except ValueError:
return None
parts = rel.parts
if len(parts) < 4 or parts[-1] != "Form.xml" or parts[-2] != "Ext":
return None
base_name = config_root.name
if parts[0] == "CommonForms" and len(parts) == 4:
form_name = parts[1]
return FormRecord(
form_xml=form_xml.resolve(),
config_root=config_root.resolve(),
base_name=base_name,
type_dir="CommonForms",
object_name=form_name,
form_name=form_name,
stem=form_wiki_stem(
base_name=base_name,
type_dir="CommonForms",
object_name=form_name,
form_name=form_name,
prefix=prefix,
),
)
if "Forms" not in parts:
return None
forms_idx = parts.index("Forms")
if forms_idx < 1 or forms_idx + 3 >= len(parts):
return None
type_dir = parts[0]
object_name = parts[forms_idx - 1]
form_name = parts[forms_idx + 1]
return FormRecord(
form_xml=form_xml.resolve(),
config_root=config_root.resolve(),
base_name=base_name,
type_dir=type_dir,
object_name=object_name,
form_name=form_name,
stem=form_wiki_stem(
base_name=base_name,
type_dir=type_dir,
object_name=object_name,
form_name=form_name,
prefix=prefix,
),
)
def discover_forms(config_root: Path, *, prefix: str | None = None) -> list[FormRecord]:
src = resolve_src_root(config_root)
records: dict[str, FormRecord] = {}
for form_xml in sorted(src.rglob("Ext/Form.xml")):
if not form_xml.is_file():
continue
record = decode_form_xml_path(form_xml, config_root, prefix=prefix)
if record is None:
continue
records[record.stem] = record
return sorted(records.values(), key=lambda x: x.stem.casefold())
def discover_forms_for_bases(
bases: Iterable[str],
*,
search_roots: list[Path] | None = None,
prefix: str | None = None,
) -> list[FormRecord]:
all_records: dict[str, FormRecord] = {}
for base in bases:
config_root = resolve_base_dir(str(base), search_roots)
for record in discover_forms(config_root, prefix=prefix):
if record.stem in all_records:
raise ResolveError(
f"дубликат stem {record.stem!r}: "
f"{all_records[record.stem].form_xml} и {record.form_xml}"
)
all_records[record.stem] = record
return sorted(all_records.values(), key=lambda x: x.stem.casefold())
def _encode_md_target(target: str) -> str:
"""Wiki-ссылка: кириллица без percent-encoding, суффикс .md у страниц убираем."""
t = target.replace("\\", "/")
if t.lower().endswith(".md"):
t = t[:-3]
return t
def _object_label(record: FormRecord) -> str:
if record.type_dir == "CommonForms":
return f"Общие · {record.form_name}"
type_ru = type_dir_label(record.type_dir)
return f"{type_ru} · {record.object_name} · {record.form_name}"
def render_wiki_error_md(record: FormRecord, *, error: str) -> str:
page_title = f"{record.base_name} · {_object_label(record)}"
lines = [
f"# {page_title}",
"",
"**Ошибка:** не удалось сгенерировать HTML-прототип.",
"",
f"```text",
error.strip(),
"```",
"",
f"- **Form.xml:** `{record.form_xml.as_posix()}`",
"",
]
return "\n".join(lines)
def render_wiki_md(record: FormRecord, *, html_name: str, title: str) -> str:
page_title = f"{record.base_name} · {_object_label(record)}"
if title and title not in page_title:
display_title = f"{page_title} — {title}"
else:
display_title = page_title
html_link = _encode_md_target(html_name)
lines = [
f"# {display_title}",
"",
f"- **Конфигурация:** `{record.base_name}`",
f"- **Объект:** `{_object_label(record)}`",
f"- **Form.xml:** `{record.form_xml.as_posix()}`",
"",
f"[Открыть HTML]({html_link})",
"",
(
f'<iframe src="{html_name}" width="100%" height="900" '
f'style="border:1px solid #ddd; max-width:100%;"></iframe>'
),
"",
]
return "\n".join(lines)
def _build_index(records: list[FormRecord]) -> str:
by_base: dict[str, dict[str, dict[str, list[FormRecord]]]] = {}
for record in records:
by_base.setdefault(record.base_name, {}).setdefault(record.type_dir, {}).setdefault(
record.object_name, []
).append(record)
lines = ["# Прототипы управляемых форм 1С", ""]
lines.append(
"HTML-макеты форм из выгрузки конфигурации. "
"Обновляется инструментом `form_wiki_export.py` без затрагивания других wiki-страниц."
)
lines.append("")
lines.append(f"**Всего форм:** {len(records)}")
lines.append("")
for base_name in sorted(by_base.keys(), key=str.casefold):
lines.append(f"## {base_name}")
lines.append("")
type_map = by_base[base_name]
for type_dir in sorted(type_map.keys(), key=str.casefold):
type_ru = type_dir_label(type_dir)
lines.append(f"### {type_ru}")
lines.append("")
object_map = type_map[type_dir]
for object_name in sorted(object_map.keys(), key=str.casefold):
group = sorted(object_map[object_name], key=lambda x: x.form_name.casefold())
if type_dir != "CommonForms":
lines.append(f"#### {object_name}")
lines.append("")
for record in group:
label = record.form_name if type_dir != "CommonForms" else record.form_name
if record.type_dir != "CommonForms":
link_label = f"{object_name} · {label}"
else:
link_label = label
lines.append(
f"- [{link_label}]({_encode_md_target(record.stem + '.md')})"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def build_forms_wiki_output(
records: list[FormRecord],
*,
out_dir: Path,
clean: bool = True,
prefix: str | None = None,
) -> FormsWikiStats:
out_dir = out_dir.resolve()
if clean and out_dir.exists():
for child in out_dir.iterdir():
if child.name == ".git":
continue
if child.is_dir():
import shutil
shutil.rmtree(child)
else:
remove_file(child)
ensure_dir(out_dir)
managed: set[str] = set()
html_count = 0
md_count = 0
skipped = 0
for idx, record in enumerate(records, start=1):
html_name = f"{record.stem}.html"
md_name = f"{record.stem}.md"
try:
form = parse_form(record.form_xml)
title = form.get("title") or infer_title_from_path(record.form_xml)
export_form_prototype(
form=form,
output_html=out_dir / html_name,
display_title=f"{record.base_name} · {_object_label(record)}",
)
write_text(
out_dir / md_name,
render_wiki_md(record, html_name=html_name, title=str(title)),
)
managed.add(html_name)
html_count += 1
except Exception as exc:
skipped += 1
print(f" skip: {record.stem} — {exc}", flush=True)
write_text(
out_dir / md_name,
render_wiki_error_md(record, error=str(exc)),
)
managed.add(md_name)
md_count += 1
if idx % 250 == 0 or idx == len(records):
print(f" rendered: {idx}/{len(records)}", flush=True)
prefix_norm = normalize_wiki_prefix(prefix)
index_page = index_page_name(prefix_norm)
write_text(out_dir / index_page, _build_index(records))
managed.add(index_page)
manifest = {
"tool": "form_wiki_export",
"version": __version__,
"forms": len(records),
"prefix": prefix_norm,
"index": index_page,
"files": sorted(managed | {MANIFEST_NAME}),
"bases": sorted({r.base_name for r in records}),
}
write_text(out_dir / MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
return FormsWikiStats(
forms_total=len(records),
files_written=len(managed) + 1,
files_deleted=0,
html_files=html_count,
markdown_files=md_count + 1,
skipped=skipped,
)
def _merge_forms_marker_block(existing: str, block: str) -> str:
marker_re = re.compile(
r"<!--\s*export_forms:begin\s*-->.*?<!--\s*export_forms:end\s*-->",
flags=re.DOTALL,
)
new_block = f"{_EXPORT_FORMS_MARKER_BEGIN}\n{block}\n{_EXPORT_FORMS_MARKER_END}"
if marker_re.search(existing):
return marker_re.sub(new_block, existing)
if existing and not existing.endswith("\n"):
existing += "\n"
if existing and not existing.endswith("\n\n"):
existing += "\n"
return existing + "\n" + new_block + "\n"
def ensure_forms_navigation(
*,
work_dir: Path,
forms_count: int,
index_page: str | None = None,
) -> None:
home_path = work_dir / "Home.md"
sidebar_path = work_dir / "_Sidebar.md"
home_text = home_path.read_text(encoding="utf-8", errors="ignore") if home_path.is_file() else ""
sidebar_text = (
sidebar_path.read_text(encoding="utf-8", errors="ignore") if sidebar_path.is_file() else ""
)
index_page = index_page or index_page_name(DEFAULT_WIKI_PREFIX)
index_target = _encode_md_target(index_page)
index_label = index_page[:-3] if index_page.endswith(".md") else index_page
home_block = "\n".join(
[
"## Прототипы форм",
"",
f"- [{index_label}]({index_target}) — {forms_count} форм",
]
)
sidebar_block = "\n".join(
[
"### Прототипы форм",
"",
f"- [📋 {index_label}]({index_target})",
"",
]
)
write_text(home_path, _merge_forms_marker_block(home_text, home_block))
write_text(sidebar_path, _merge_forms_marker_block(sidebar_text, sidebar_block))
def _run_git(cmd: list[str], *, cwd: Path) -> str:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, check=False)
if proc.returncode != 0:
raise WikiPushError(f"$ {' '.join(cmd)}\n{proc.stdout}\n{proc.stderr}".strip())
return proc.stdout
def _wiki_clear_index_flags(work_dir: Path, *names: str) -> None:
for name in names:
if not (work_dir / name).is_file():
continue
_run_git(["git", "update-index", "--no-assume-unchanged", name], cwd=work_dir)
_run_git(["git", "update-index", "--no-skip-worktree", name], cwd=work_dir)
def _git_has_staged_changes(work_dir: Path) -> bool:
proc = subprocess.run(
["git", "diff", "--cached", "--quiet"],
cwd=str(work_dir),
capture_output=True,
text=True,
check=False,
)
return proc.returncode != 0
def _git_stage_and_ready(work_dir: Path) -> bool:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return _git_has_staged_changes(work_dir)
def _load_prev_managed(work_dir: Path) -> set[str]:
p = work_dir / MANIFEST_NAME
if not p.is_file():
return set()
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
files = data.get("files") or []
return {str(x) for x in files if isinstance(x, str)}
def push_forms_wiki(
*,
wiki_url: str,
out_dir: Path,
work_dir: Path,
message: str,
branch: str = "main",
dry_run: bool = False,
) -> FormsWikiStats:
out_dir = out_dir.resolve()
work_dir = work_dir.resolve()
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
manifest_path = out_dir / MANIFEST_NAME
if not manifest_path.is_file():
raise WikiPushError(f"missing manifest in output: {manifest_path}")
new_data = json.loads(manifest_path.read_text(encoding="utf-8"))
new_files = {str(x) for x in (new_data.get("files") or [])}
prev_files = _load_prev_managed(work_dir)
deleted = 0
for rel in sorted(prev_files - new_files):
if rel in _RESERVED_ROOT_PAGES:
continue
p = work_dir / rel
if p.is_file():
remove_file(p)
deleted += 1
for rel in sorted(new_files):
src = out_dir / rel
if src.is_file():
copy_file(src, work_dir / rel)
forms_count = int(new_data.get("forms") or 0)
index_page = str(new_data.get("index") or index_page_name(new_data.get("prefix")))
ensure_forms_navigation(
work_dir=work_dir,
forms_count=forms_count,
index_page=index_page,
)
if dry_run:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return FormsWikiStats(
forms_total=forms_count,
files_written=len(new_files),
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
if not _git_stage_and_ready(work_dir):
return FormsWikiStats(
forms_total=forms_count,
files_written=len(new_files),
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
_run_git(["git", "commit", "-m", message], cwd=work_dir)
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
return FormsWikiStats(
forms_total=forms_count,
files_written=len(new_files),
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
def clean_forms_wiki(
*,
wiki_url: str,
work_dir: Path,
message: str,
branch: str = "main",
dry_run: bool = False,
) -> FormsWikiStats:
work_dir = work_dir.resolve()
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
prev_files = _load_prev_managed(work_dir)
deleted = 0
for rel in sorted(prev_files):
if rel in _RESERVED_ROOT_PAGES:
continue
p = work_dir / rel
if p.is_file():
remove_file(p)
deleted += 1
manifest_path = work_dir / MANIFEST_NAME
if manifest_path.is_file():
remove_file(manifest_path)
for name in ("Home.md", "_Sidebar.md"):
p = work_dir / name
if not p.is_file():
continue
raw = p.read_text(encoding="utf-8", errors="ignore")
cleaned = re.sub(
r"<!--\s*export_forms:begin\s*-->.*?<!--\s*export_forms:end\s*-->\n?",
"",
raw,
flags=re.DOTALL,
).rstrip()
cleaned = cleaned + "\n" if cleaned else ""
if cleaned != raw:
write_text(p, cleaned)
if dry_run:
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
_run_git(["git", "add", "-A"], cwd=work_dir)
return FormsWikiStats(
forms_total=0,
files_written=0,
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
if not _git_stage_and_ready(work_dir):
return FormsWikiStats(
forms_total=0,
files_written=0,
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
_run_git(["git", "commit", "-m", message], cwd=work_dir)
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
return FormsWikiStats(
forms_total=0,
files_written=0,
files_deleted=deleted,
html_files=0,
markdown_files=0,
)
def load_form_wiki_settings(path: Path) -> dict:
data = load_settings_file(path)
bases = data.get("bases")
if bases is None and data.get("base"):
bases = [data["base"]]
if not isinstance(bases, list) or not bases:
raise ResolveError("в конфиге нужен bases: [...] или base: ...")
data["bases"] = [str(x) for x in bases]
prefix_raw = data.get("wiki_prefix") or data.get("prefix")
data["wiki_prefix"] = normalize_wiki_prefix(
str(prefix_raw) if prefix_raw is not None else None
)
return data
def resolve_search_roots(settings: dict, config_path: Path | None) -> list[Path]:
search_roots = default_search_roots(config_path)
extra_roots = settings.get("search_roots") or []
if isinstance(extra_roots, list):
base_for_rel = config_path.parent if config_path else Path.cwd()
for item in extra_roots:
p = Path(str(item)).expanduser()
if not p.is_absolute():
p = (base_for_rel / p).resolve()
search_roots.insert(0, p)
return search_roots