"""Build markdown wiki pages from discovered Help HTML.""" from __future__ import annotations import json import re from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from export1c_help.__version__ import __version__ from export1c_help.config_version import ConfigIdentity, read_config_identity from export1c_help.convert import default_link_rewrite, extract_h1, html_to_markdown from export1c_help.discover import HelpPage, build_link_title_map, discover_help_pages from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text from export1c_help.lifecycle import ( LifecycleResult, PageRecord, content_hash, load_prev_manifest, merge_lifecycle, prev_records, ) from export1c_help.naming import assign_titles from export1c_help.types_map import DIR_TO_RU @dataclass class ExportStats: pages_total: int = 0 pages_written: int = 0 pages_skipped_empty: int = 0 images_copied: int = 0 created_count: int = 0 edited_count: int = 0 unchanged_count: int = 0 deleted_count: int = 0 config_version: str = "unknown" def _is_effectively_empty(md: str) -> bool: plain = re_sub_md(md) return len(plain) < 20 def re_sub_md(md: str) -> str: t = re.sub(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", r"\1", md) t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t) t = re.sub(r"[#*_`|-]", " ", t) t = re.sub(r"\s+", " ", t).strip() return t def _page_footer(rec: PageRecord, *, identity: ConfigIdentity) -> str: display = identity.display_name or "—" vendor = identity.vendor or "—" lines = [ "", "---", "", f"*Конфигурация: **{display}*** ", f"*Версия: **{identity.version}** · разработчик: **{vendor}*** ", f"*Страница создана в: **{rec.created_in}** · изменена в: **{rec.edited_in}*** ", f"*Мета: `{rec.meta_key}` · файл: `{rec.source}`*", "", ] return "\n".join(lines) def build_home( pages: list[HelpPage], titles: dict[str, str], *, source_label: str, identity: ConfigIdentity, life: LifecycleResult, ) -> str: by_type: dict[str, list[HelpPage]] = {} for p in pages: by_type.setdefault(p.type_dir, []).append(p) lines = [ "# Справка конфигурации", "", "Автогенерация из встроенной справки 1С (`Ext/Help/ru.html`).", "", f"- **Конфигурация:** {identity.display_name or '—'}", f"- **Версия (эта выгрузка):** `{identity.version}`", f"- **Разработчик:** {identity.vendor or '—'}", f"- Источник: `{source_label}`", f"- Страниц: **{len(pages)}** " f"(+{life.created_count} / ~{life.edited_count} / ={life.unchanged_count} / -{life.deleted_count})", f"- Удалённых (накопительно): **{len(life.deleted)}** — см. [[History]]", f"- Инструмент: `export_1c_help` v{__version__}", f"- Дата: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}", "", "## Разделы", "", ] for type_dir in sorted(by_type, key=lambda d: DIR_TO_RU.get(d, d)): label = DIR_TO_RU.get(type_dir, type_dir) group = sorted(by_type[type_dir], key=lambda p: titles[p.meta_key].casefold()) lines.append(f"### {label} ({len(group)})") lines.append("") for p in group: title = titles[p.meta_key] lines.append(f"- [[{title}]]") lines.append("") return "\n".join(lines).rstrip() + "\n" def build_sidebar(pages: list[HelpPage], titles: dict[str, str]) -> str: by_type: dict[str, list[HelpPage]] = {} for p in pages: by_type.setdefault(p.type_dir, []).append(p) lines = [ "# Справка", "", "[[Home|Оглавление]]", "[[History|История выгрузок]]", "", ] for type_dir in sorted(by_type, key=lambda d: DIR_TO_RU.get(d, d)): label = DIR_TO_RU.get(type_dir, type_dir) lines.append(f"### {label}") lines.append("") objs = [p for p in by_type[type_dir] if not p.is_form] objs.sort(key=lambda p: titles[p.meta_key].casefold()) for p in objs[:80]: lines.append(f"- [[{titles[p.meta_key]}]]") if len(objs) > 80: lines.append(f"- … ещё {len(objs) - 80} (см. [[Home]])") lines.append("") return "\n".join(lines).rstrip() + "\n" def build_history( *, identity: ConfigIdentity, life: LifecycleResult, prev_exports: list[dict], ) -> str: lines = [ "# История выгрузок справки", "", f"Текущая выгрузка: **{identity.display_name or '—'}** " f"версия **{identity.version}**, разработчик **{identity.vendor or '—'}**.", "", "## Эта выгрузка", "", f"- создано страниц: **{life.created_count}**", f"- изменено: **{life.edited_count}**", f"- без изменений: **{life.unchanged_count}**", f"- удалено в этой версии: **{life.deleted_count}**", f"- всего удалённых (накопительно): **{len(life.deleted)}**", "", ] if life.deleted_count: lines.append("### Удалено в этой версии") lines.append("") for rec in life.deleted: if rec.deleted_in != identity.version: continue lines.append( f"- `{rec.meta_key}` — «{rec.title}» " f"(создана в {rec.created_in}, последняя правка {rec.edited_in})" ) lines.append("") if life.deleted: lines.append("### Все удалённые страницы") lines.append("") lines.append("| Мета | Заголовок | Создана | Изменена | Удалена |") lines.append("|------|-----------|---------|----------|---------|") for rec in life.deleted: lines.append( f"| `{rec.meta_key}` | {rec.title} | {rec.created_in} | " f"{rec.edited_in} | {rec.deleted_in or '—'} |" ) lines.append("") lines.append("## Предыдущие выгрузки") lines.append("") if not prev_exports: lines.append("_Нет сохранённой истории (первая выгрузка с учётом версий)._") lines.append("") else: lines.append("| Конфигурация | Версия | Разработчик | Дата (UTC) | Страниц | + | ~ | - |") lines.append("|--------------|--------|-------------|------------|---------|---|---|---|") for row in prev_exports: lines.append( f"| {row.get('config_display') or row.get('config_name') or '—'} | " f"{row.get('config_version', '?')} | " f"{row.get('config_vendor') or '—'} | " f"{row.get('exported_at', '?')} | " f"{row.get('pages_written', '?')} | {row.get('created', '?')} | " f"{row.get('edited', '?')} | {row.get('deleted', '?')} |" ) lines.append("") return "\n".join(lines).rstrip() + "\n" def export_help( src_root: Path, out_dir: Path, *, include_forms: bool = True, skip_empty: bool = True, clean: bool = False, source_label: str | None = None, prev_manifest_path: Path | None = None, config_version: str | None = None, ) -> ExportStats: """Convert all help pages into a wiki working tree at out_dir.""" src_root = src_root.resolve() out_dir = out_dir.resolve() stats = ExportStats() identity = read_config_identity(src_root) if config_version: identity = ConfigIdentity( version=config_version.strip() or identity.version, name=identity.name, synonym=identity.synonym, vendor=identity.vendor, ) cfg_ver = identity.version stats.config_version = cfg_ver prev_manifest = load_prev_manifest(prev_manifest_path) if prev_manifest is None and not clean: prev_manifest = load_prev_manifest(out_dir / "manifest.json") previous = prev_records(prev_manifest) pages = discover_help_pages(src_root, include_forms=include_forms) stats.pages_total = len(pages) h1_by_meta: dict[str, str | None] = {} html_cache: dict[str, str] = {} for p in pages: html = p.path.read_text(encoding="utf-8-sig", errors="ignore") html_cache[p.meta_key] = html h1_by_meta[p.meta_key] = extract_h1(html) titles, files = assign_titles(pages, h1_by_meta) link_map = build_link_title_map(pages, titles) if clean and out_dir.exists(): import shutil for child in out_dir.iterdir(): if child.name == ".git" or child.name.startswith("wiki_clone"): # never wipe an in-tree wiki working clone continue if child.is_dir(): shutil.rmtree(child) else: remove_file(child) ensure_dir(out_dir) def rewrite(href: str, text: str) -> str: return default_link_rewrite(href, text, link_map) draft_records: list[PageRecord] = [] body_by_meta: dict[str, str] = {} for p in pages: html = html_cache[p.meta_key] title = titles[p.meta_key] md = html_to_markdown(html, rewrite_link=rewrite, strip_first_h1=title) if skip_empty and _is_effectively_empty(md): stats.pages_skipped_empty += 1 continue body = md.rstrip() + "\n" body_by_meta[p.meta_key] = body draft_records.append( PageRecord( meta_key=p.meta_key, title=title, filename=files[p.meta_key], type_dir=p.type_dir, object=p.object_name, form=p.form_name, source=p.rel_posix, content_hash=content_hash(html), created_in="", edited_in="", ) ) life = merge_lifecycle( current=draft_records, previous=previous, config_version=cfg_ver, ) stats.created_count = life.created_count stats.edited_count = life.edited_count stats.unchanged_count = life.unchanged_count stats.deleted_count = life.deleted_count for rec in life.active: body = body_by_meta[rec.meta_key] md = body.rstrip() + _page_footer(rec, identity=identity) write_text(out_dir / rec.filename, md) stats.pages_written += 1 help_dir = src_root / Path(rec.source).parent if help_dir.is_dir(): for img in help_dir.iterdir(): if img.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}: target = out_dir / img.name if not target.exists(): copy_file(img, target) stats.images_copied += 1 label = source_label or str(src_root) written_keys = {r.meta_key for r in life.active} written_pages = [p for p in pages if p.meta_key in written_keys] prev_exports: list[dict] = [] if prev_manifest: prev_exports = list(prev_manifest.get("exports") or []) # append previous export summary if present at top level if prev_manifest.get("config_version"): prev_exports.insert( 0, { "config_version": prev_manifest.get("config_version"), "config_name": prev_manifest.get("config_name"), "config_display": prev_manifest.get("config_display"), "config_vendor": prev_manifest.get("config_vendor"), "exported_at": prev_manifest.get("exported_at"), "pages_written": prev_manifest.get("pages_written"), "created": prev_manifest.get("created_count"), "edited": prev_manifest.get("edited_count"), "deleted": prev_manifest.get("deleted_count"), }, ) # dedupe consecutive identical deduped: list[dict] = [] for row in prev_exports: if deduped and deduped[-1].get("exported_at") == row.get("exported_at"): continue deduped.append(row) prev_exports = deduped[:50] write_text( out_dir / "Home.md", build_home( written_pages, titles, source_label=label, identity=identity, life=life, ), ) write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles)) write_text( out_dir / "History.md", build_history( identity=identity, life=life, prev_exports=prev_exports, ), ) exported_at = datetime.now(timezone.utc).isoformat() exports = [ { "config_version": cfg_ver, "config_name": identity.name, "config_display": identity.display_name, "config_vendor": identity.vendor, "exported_at": exported_at, "pages_written": stats.pages_written, "created": stats.created_count, "edited": stats.edited_count, "deleted": stats.deleted_count, "tool_version": __version__, }, *prev_exports, ][:50] manifest = { "tool": "export_1c_help", "version": __version__, "config_version": cfg_ver, "config_name": identity.name, "config_display": identity.display_name, "config_vendor": identity.vendor, "source": label, "exported_at": exported_at, "pages_total": stats.pages_total, "pages_written": stats.pages_written, "pages_skipped_empty": stats.pages_skipped_empty, "created_count": stats.created_count, "edited_count": stats.edited_count, "unchanged_count": stats.unchanged_count, "deleted_count": stats.deleted_count, "exports": exports, "pages": [r.to_dict() for r in life.active], "deleted_pages": [r.to_dict() for r in life.deleted], } write_text( out_dir / "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", ) return stats