Files
export_1c_help/export1c_help/export.py
T

410 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build markdown wiki pages from discovered Help HTML."""
from __future__ import annotations
import json
import re
import shutil
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.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():
for child in out_dir.iterdir():
if child.name == ".git":
continue
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
out_dir.mkdir(parents=True, exist_ok=True)
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)
(out_dir / rec.filename).write_text(md, encoding="utf-8")
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():
shutil.copy2(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]
(out_dir / "Home.md").write_text(
build_home(
written_pages,
titles,
source_label=label,
identity=identity,
life=life,
),
encoding="utf-8",
)
(out_dir / "_Sidebar.md").write_text(
build_sidebar(written_pages, titles),
encoding="utf-8",
)
(out_dir / "History.md").write_text(
build_history(
identity=identity,
life=life,
prev_exports=prev_exports,
),
encoding="utf-8",
)
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],
}
(out_dir / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return stats