7dd699dab5
Co-authored-by: Cursor <cursoragent@cursor.com>
417 lines
15 KiB
Python
417 lines
15 KiB
Python
"""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_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],
|
||
files: 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}]({files[p.meta_key]})")
|
||
lines.append("")
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def build_sidebar(
|
||
pages: list[HelpPage],
|
||
titles: dict[str, str],
|
||
files: dict[str, str],
|
||
) -> str:
|
||
by_type: dict[str, list[HelpPage]] = {}
|
||
for p in pages:
|
||
by_type.setdefault(p.type_dir, []).append(p)
|
||
lines = [
|
||
"# Справка",
|
||
"",
|
||
"[Оглавление](Home.md)",
|
||
"[История выгрузок](History.md)",
|
||
"",
|
||
]
|
||
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]}]({files[p.meta_key]})")
|
||
if len(objs) > 80:
|
||
lines.append(f"- … ещё {len(objs) - 80} (см. [Оглавление](Home.md))")
|
||
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_map(pages, titles, files)
|
||
|
||
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_body = html_to_markdown(html, rewrite_link=rewrite, strip_first_h1=title)
|
||
md = f"# {title}\n\n{md_body.lstrip()}"
|
||
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,
|
||
files,
|
||
source_label=label,
|
||
identity=identity,
|
||
life=life,
|
||
),
|
||
)
|
||
write_text(out_dir / "_Sidebar.md", build_sidebar(written_pages, titles, files))
|
||
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
|