From aec0407b87d1cbea15f79f54dd1f6ff11be35fe7 Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Thu, 23 Jul 2026 14:54:04 +0300 Subject: [PATCH] Initial release: export 1C Help to Gitea wiki. Convert Ext/Help/ru.html to Markdown and push to a wiki git repository. --- .gitignore | 8 + CHANGELOG.md | 23 +++ LICENSE | 21 +++ README.md | 100 +++++++++++++ VERSION | 1 + export1c_help/__init__.py | 7 + export1c_help/__version__.py | 18 +++ export1c_help/convert.py | 279 +++++++++++++++++++++++++++++++++++ export1c_help/discover.py | 234 +++++++++++++++++++++++++++++ export1c_help/export.py | 199 +++++++++++++++++++++++++ export1c_help/naming.py | 106 +++++++++++++ export1c_help/types_map.py | 73 +++++++++ export1c_help/wiki.py | 93 ++++++++++++ export_1c_help.py | 176 ++++++++++++++++++++++ requirements.txt | 1 + 15 files changed, 1339 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 VERSION create mode 100644 export1c_help/__init__.py create mode 100644 export1c_help/__version__.py create mode 100644 export1c_help/convert.py create mode 100644 export1c_help/discover.py create mode 100644 export1c_help/export.py create mode 100644 export1c_help/naming.py create mode 100644 export1c_help/types_map.py create mode 100644 export1c_help/wiki.py create mode 100644 export_1c_help.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ec40b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +out/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.DS_Store +*.egg-info/ +.venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f620a21 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to **export_1c_help** are documented in this file. + +## [0.1.0] - 2026-07-23 + +### Status + +**In development.** Full dump of configuration Help to Gitea wiki. + +### Added + +- Discovery of `**/Ext/Help/ru.html` in a 1C configuration `src/` tree +- HTML → Markdown converter (stdlib only): headings, lists, bold/italic, links +- Rewrite of 1C help links (`Catalog.X/Help`, `….Form.Y/Help`) to Gitea `[[wiki]]` links +- Unique wiki page titles (H1 / synonym / meta key) with collision suffixes +- Gitea-compatible percent-encoded filenames for Cyrillic titles +- `Home.md` index and `_Sidebar.md` navigation +- `manifest.json` (meta_key → title mapping) +- CLI: `convert`, `push` (clone/commit/push wiki git repo) +- Option `--objects-only` to skip form-level help + +[0.1.0]: https://git.p7net.ru/tools/export_1c_help/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6a3cfa9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 RHANA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54df93f --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# export_1c_help + +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.1.0**) · [CHANGELOG](CHANGELOG.md) +**Лицензия:** [MIT](LICENSE) + +Выгрузка встроенной справки конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в [Gitea Wiki](https://docs.gitea.com/usage/wiki). + +Зависимости: **Python 3.10+**, только стандартная библиотека. + +--- + +## Быстрый старт + +```bash +cd tools/export_1c_help + +# конвертация в каталог +python3 export_1c_help.py convert \ + -s ../../crm3-26/src \ + -o ./out/wiki-md \ + --clean + +# конвертация + push в wiki-репозиторий Gitea +python3 export_1c_help.py push \ + -s ../../crm3-26/src \ + --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git \ + -o ./out/wiki-md +``` + +Проверка версии: + +```bash +python3 export_1c_help.py --version +``` + +--- + +## Команды + +### `convert` + +Читает `src/`, пишет Markdown-дерево wiki: + +| Файл | Назначение | +|------|------------| +| `Home.md` | оглавление по типам метаданных | +| `_Sidebar.md` | боковая навигация (объекты без форм) | +| `«Заголовок».md` / `%D0%….md` | страницы справки (кириллица — percent-encoded, как в Gitea) | +| `manifest.json` | карта `meta_key` → title / filename | + +| Параметр | Описание | +|----------|----------| +| `-s` / `--src` | каталог `src` выгрузки конфигурации | +| `-o` / `--out` | каталог результата | +| `--objects-only` | без справки форм (`Forms/*/Ext/Help`) | +| `--keep-empty` | не пропускать почти пустые страницы | +| `--clean` | очистить `--out` перед записью (кроме `.git`) | + +### `push` + +Выполняет `convert`, затем `git clone` / обновление wiki-репозитория (обычно `*.wiki.git`), коммит и `git push`. + +| Параметр | Описание | +|----------|----------| +| `--wiki-url` | URL wiki git, напр. `https://git.p7net.ru/1c/crm3_26.wiki.git` | +| `--work-dir` | локальный clone wiki | +| `--dry-run` | только convert | +| `--keep-unmanaged` | не удалять чужие `.md` в wiki | +| `-m` / `--message` | сообщение коммита | + +Для `push` нужны права записи в wiki (HTTPS + credential helper / token). + +--- + +## Как устроены ссылки + +В HTML справки 1С: + +```text +Catalog.ВидыНоменклатуры/Help +Document.ЗаказКлиента.Form.ФормаДокумента/Help +``` + +В wiki: + +```markdown +[[Виды номенклатуры]] +[[текст|Виды номенклатуры]] +``` + +Ключ метаданных (`Catalog.ВидыНоменклатуры`) стабилен и пишется в подвал страницы и в `manifest.json`. + +--- + +## Репозиторий + +Отдельный git-репозиторий (подмодуль монорепозитория миграции): + +- путь в дереве проекта: `tools/export_1c_help/` +- remote (ожидаемый): `https://git.p7net.ru/tools/export_1c_help.git` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/export1c_help/__init__.py b/export1c_help/__init__.py new file mode 100644 index 0000000..fe39ba5 --- /dev/null +++ b/export1c_help/__init__.py @@ -0,0 +1,7 @@ +"""Export 1C configuration Help (ru.html) to Markdown / Gitea wiki.""" + +from __future__ import annotations + +from export1c_help.__version__ import __status__, __version__ + +__all__ = ["__version__", "__status__"] diff --git a/export1c_help/__version__.py b/export1c_help/__version__.py new file mode 100644 index 0000000..363425b --- /dev/null +++ b/export1c_help/__version__.py @@ -0,0 +1,18 @@ +"""export_1c_help version.""" + +from __future__ import annotations + +from pathlib import Path + +_VERSION_FILE = Path(__file__).resolve().parent.parent / "VERSION" + + +def _read_version() -> str: + try: + return _VERSION_FILE.read_text(encoding="utf-8").strip() + except OSError: + return "0.0.0" + + +__version__ = _read_version() +__status__ = "in development" diff --git a/export1c_help/convert.py b/export1c_help/convert.py new file mode 100644 index 0000000..4f99fdc --- /dev/null +++ b/export1c_help/convert.py @@ -0,0 +1,279 @@ +"""HTML (1C Help) → Markdown converter (stdlib only).""" + +from __future__ import annotations + +import re +from html.parser import HTMLParser +from typing import Callable +from urllib.parse import unquote + + +_BLOCK_TAGS = frozenset( + {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr", "blockquote", "pre"} +) +_SKIP_TAGS = frozenset({"script", "style", "head", "meta", "link"}) +_HEADER = {"h1": "#", "h2": "##", "h3": "###", "h4": "####", "h5": "#####", "h6": "######"} + + +LinkRewriter = Callable[[str, str], str] + + +class _HelpHTMLParser(HTMLParser): + def __init__(self, rewrite_link: LinkRewriter | None = None) -> None: + super().__init__(convert_charrefs=True) + self.rewrite_link = rewrite_link or (lambda href, text: text) + self.parts: list[str] = [] + self._skip_depth = 0 + self._bold = 0 + self._italic = 0 + self._code = 0 + self._list_stack: list[str] = [] # "ul" | "ol" + self._li_index: list[int] = [] + self._link_href: str | None = None + self._link_text: list[str] = [] + self._in_anchor_name: str | None = None + self.title: str | None = None + self._pending_break = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + tag = tag.lower() + ad = {k.lower(): (v or "") for k, v in attrs} + + if tag in _SKIP_TAGS: + self._skip_depth += 1 + return + if self._skip_depth: + return + + if tag == "br": + self._emit("\n") + return + if tag == "hr": + self._close_inline() + self._emit("\n\n---\n\n") + return + if tag in ("b", "strong"): + self._bold += 1 + self._emit("**") + return + if tag in ("i", "em"): + self._italic += 1 + self._emit("*") + return + if tag in ("code", "tt"): + self._code += 1 + self._emit("`") + return + if tag == "a": + name = ad.get("name") or ad.get("id") + href = ad.get("href", "") + if name and (not href or href.startswith("#")): + # named anchor — keep as HTML comment / empty target for TOC + self._in_anchor_name = name + return + if href.startswith("v8help://"): + return + self._link_href = href + self._link_text = [] + return + if tag in _HEADER: + self._close_inline() + self._ensure_blank() + self._emit(f"{_HEADER[tag]} ") + return + if tag == "p": + self._close_inline() + self._ensure_blank() + return + if tag == "ul": + self._close_inline() + self._ensure_blank() + self._list_stack.append("ul") + self._li_index.append(0) + return + if tag == "ol": + self._close_inline() + self._ensure_blank() + self._list_stack.append("ol") + self._li_index.append(0) + return + if tag == "li": + self._close_inline() + self._emit("\n") + depth = max(len(self._list_stack) - 1, 0) + indent = " " * depth + if self._list_stack and self._list_stack[-1] == "ol": + self._li_index[-1] += 1 + self._emit(f"{indent}{self._li_index[-1]}. ") + else: + self._emit(f"{indent}- ") + return + if tag in ("table", "tbody", "thead"): + self._close_inline() + self._ensure_blank() + return + if tag == "tr": + self._emit("\n") + return + if tag in ("td", "th"): + self._emit(" | ") + return + + def handle_endtag(self, tag: str) -> None: + tag = tag.lower() + if tag in _SKIP_TAGS: + if self._skip_depth: + self._skip_depth -= 1 + return + if self._skip_depth: + return + + if tag in ("b", "strong") and self._bold: + self._emit("**") + self._bold -= 1 + return + if tag in ("i", "em") and self._italic: + self._emit("*") + self._italic -= 1 + return + if tag in ("code", "tt") and self._code: + self._emit("`") + self._code -= 1 + return + if tag == "a": + if self._in_anchor_name is not None: + self._in_anchor_name = None + return + if self._link_href is None: + return + text = "".join(self._link_text).strip() + href = self._link_href + self._link_href = None + self._link_text = [] + if not text and not href: + return + self._emit(self.rewrite_link(href, text or href)) + return + if tag in _HEADER: + # capture first h1 as title + # title extracted later from markdown + self._emit("\n\n") + return + if tag in ("p", "div"): + self._emit("\n\n") + return + if tag in ("ul", "ol"): + if self._list_stack: + self._list_stack.pop() + if self._li_index: + self._li_index.pop() + self._emit("\n\n") + return + if tag == "li": + return + + def handle_data(self, data: str) -> None: + if self._skip_depth: + return + if self._link_href is not None: + self._link_text.append(data) + return + # collapse whitespace inside blocks but keep intentional spaces + if not data: + return + self._emit(data) + + def _emit(self, s: str) -> None: + self.parts.append(s) + + def _ensure_blank(self) -> None: + text = "".join(self.parts) + if not text.endswith("\n\n"): + if text.endswith("\n"): + self.parts.append("\n") + elif text: + self.parts.append("\n\n") + + def _close_inline(self) -> None: + while self._bold: + self.parts.append("**") + self._bold -= 1 + while self._italic: + self.parts.append("*") + self._italic -= 1 + while self._code: + self.parts.append("`") + self._code -= 1 + + +def _normalize_md(text: str) -> str: + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = re.sub(r"[ \t]+\n", "\n", text) + text = re.sub(r"\n{3,}", "\n\n", text) + # fix bold/italic glued spaces + text = re.sub(r"\*\*\s+\*\*", "", text) + return text.strip() + "\n" + + +def extract_h1(html: str) -> str | None: + m = re.search(r"]*>(.*?)", html, flags=re.IGNORECASE | re.DOTALL) + if not m: + return None + raw = re.sub(r"<[^>]+>", "", m.group(1)) + raw = re.sub(r"\s+", " ", raw).strip() + return raw or None + + +def html_to_markdown( + html: str, + *, + rewrite_link: LinkRewriter | None = None, + strip_first_h1: str | None = None, +) -> str: + """Convert 1C help HTML to GitHub/Gitea-flavoured Markdown.""" + parser = _HelpHTMLParser(rewrite_link=rewrite_link) + parser.feed(html) + parser.close() + md = _normalize_md("".join(parser.parts)) + + if strip_first_h1: + # remove leading "# Title\n" if it matches page title + esc = re.escape(strip_first_h1.strip()) + md = re.sub(rf"^#\s+{esc}\s*\n+", "", md, count=1, flags=re.IGNORECASE) + + # drop empty ITS-only stubs that are just TOC junk + md = re.sub(r"\n\|\s*\n", "\n", md) + return _normalize_md(md) + + +def default_link_rewrite(href: str, text: str, meta_to_title: dict[str, str]) -> str: + """Rewrite href into markdown / wiki link.""" + href = href.strip() + text = text.strip() or href + + if href.startswith("#"): + # keep plain text for in-page TOC (anchors rarely useful in wiki MD) + return text + + if href.startswith(("http://", "https://", "mailto:")): + return f"[{text}]({href})" + + # Catalog.X/Help or Catalog.X.Form.Y/Help[#anchor] + m = re.match( + r"^([A-Za-z]+(?:\.[^/#\s]+)+)/Help(?:#(.*))?$", + unquote(href), + ) + if m: + meta_key = m.group(1) + title = meta_to_title.get(meta_key) + if title: + if text == title or text == meta_key: + return f"[[{title}]]" + return f"[[{text}|{title}]]" + return f"{text} (`{meta_key}`)" + + # relative image or unknown + if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I): + return f"![{text}]({href})" + + return text diff --git a/export1c_help/discover.py b/export1c_help/discover.py new file mode 100644 index 0000000..af454bd --- /dev/null +++ b/export1c_help/discover.py @@ -0,0 +1,234 @@ +"""Discover Ext/Help/ru.html pages in a 1C configuration dump.""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path + +from export1c_help.types_map import DIR_TO_LINK_TYPE + +_SYN_RE = re.compile( + r"\s*\s*ru\s*(.*?)", + re.DOTALL, +) + + +@dataclass(frozen=True) +class HelpPage: + """One Help/ru.html page.""" + + path: Path + src_root: Path + type_dir: str + object_name: str + form_name: str | None + meta_key: str + synonym: str + link_aliases: tuple[str, ...] = () + + @property + def is_form(self) -> bool: + return self.form_name is not None + + @property + def rel_posix(self) -> str: + return self.path.relative_to(self.src_root).as_posix() + + +def _xml_unescape(s: str) -> str: + return ( + s.replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", '"') + .replace("'", "'") + ) + + +def _read_ru_synonym(xml_path: Path) -> str | None: + if not xml_path.is_file(): + return None + try: + text = xml_path.read_text(encoding="utf-8-sig", errors="ignore") + except OSError: + return None + m = _SYN_RE.search(text) + if m: + return _xml_unescape(m.group(1).strip()) + try: + root = ET.parse(xml_path).getroot() + except ET.ParseError: + return None + for el in root.iter(): + if el.tag.endswith("content") and el.text and el.text.strip(): + return el.text.strip() + return None + + +def _subsystem_chain(parts: tuple[str, ...]) -> list[str] | None: + """ + From relative parts ending with Ext/Help/ru.html build subsystem name chain. + + Subsystems/A/Ext/Help/ru.html → [A] + Subsystems/A/Subsystems/B/Ext/Help/ru.html → [A, B] + """ + if parts[0] != "Subsystems": + return None + body = parts[1:-3] # drop Ext/Help/ru.html + names: list[str] = [] + i = 0 + while i < len(body): + if i == 0: + names.append(body[i]) + i += 1 + continue + if body[i] == "Subsystems" and i + 1 < len(body): + names.append(body[i + 1]) + i += 2 + continue + return None + return names or None + + +def _subsystem_xml(src_root: Path, chain: list[str]) -> Path: + # Subsystems/A.xml or Subsystems/A/Subsystems/B.xml + path = src_root / "Subsystems" + for idx, name in enumerate(chain): + if idx == 0: + candidate = path / f"{name}.xml" + path = path / name + else: + candidate = path / "Subsystems" / f"{name}.xml" + path = path / "Subsystems" / name + return candidate + + +def _parse_help_path(src_root: Path, html_path: Path) -> HelpPage | None: + try: + rel = html_path.relative_to(src_root) + except ValueError: + return None + parts = rel.parts + if len(parts) < 4 or parts[-1] != "ru.html" or parts[-2] != "Help" or parts[-3] != "Ext": + return None + + type_dir = parts[0] + link_type = DIR_TO_LINK_TYPE.get(type_dir) + if not link_type: + return None + + form_name: str | None = None + aliases: list[str] = [] + + if type_dir == "Subsystems": + chain = _subsystem_chain(parts) + if not chain: + return None + object_name = chain[-1] + meta_key = "Subsystem." + ".Subsystem.".join(chain) + # 1C help links usually use the leaf name only + aliases.append(f"Subsystem.{object_name}") + syn = _read_ru_synonym(_subsystem_xml(src_root, chain)) or object_name + return HelpPage( + path=html_path, + src_root=src_root, + type_dir=type_dir, + object_name=object_name, + form_name=None, + meta_key=meta_key, + synonym=syn, + link_aliases=tuple(aliases), + ) + + if type_dir == "CommonForms": + object_name = parts[1] + meta_key = f"CommonForm.{object_name}" + syn = _read_ru_synonym(src_root / "CommonForms" / f"{object_name}.xml") or object_name + return HelpPage( + path=html_path, + src_root=src_root, + type_dir=type_dir, + object_name=object_name, + form_name=None, + meta_key=meta_key, + synonym=syn, + ) + + if "Forms" in parts: + try: + forms_idx = parts.index("Forms") + except ValueError: + return None + object_name = parts[1] + form_name = parts[forms_idx + 1] + meta_key = f"{link_type}.{object_name}.Form.{form_name}" + form_xml = src_root / type_dir / object_name / "Forms" / f"{form_name}.xml" + syn = _read_ru_synonym(form_xml) or form_name + obj_syn = _read_ru_synonym(src_root / type_dir / f"{object_name}.xml") + if obj_syn: + syn = f"{obj_syn} ({syn})" + return HelpPage( + path=html_path, + src_root=src_root, + type_dir=type_dir, + object_name=object_name, + form_name=form_name, + meta_key=meta_key, + synonym=syn, + ) + + object_name = parts[1] + meta_key = f"{link_type}.{object_name}" + syn = _read_ru_synonym(src_root / type_dir / f"{object_name}.xml") or object_name + return HelpPage( + path=html_path, + src_root=src_root, + type_dir=type_dir, + object_name=object_name, + form_name=None, + meta_key=meta_key, + synonym=syn, + ) + + +def discover_help_pages( + src_root: Path, + *, + include_forms: bool = True, +) -> list[HelpPage]: + """Find all Help/ru.html under src_root.""" + src_root = src_root.resolve() + pages: list[HelpPage] = [] + seen_meta: set[str] = set() + for html in sorted(src_root.rglob("Ext/Help/ru.html")): + page = _parse_help_path(src_root, html) + if page is None: + continue + if page.is_form and not include_forms: + continue + if page.meta_key in seen_meta: + # should not happen; keep first + continue + seen_meta.add(page.meta_key) + pages.append(page) + return pages + + +def build_link_title_map(pages: list[HelpPage], titles: dict[str, str]) -> dict[str, str]: + """ + meta_key / alias → wiki title for href rewrite. + + Leaf aliases (Subsystem.X) are registered only when unique. + """ + result: dict[str, str] = {} + alias_owners: dict[str, list[str]] = {} + for p in pages: + result[p.meta_key] = titles[p.meta_key] + for alias in p.link_aliases: + alias_owners.setdefault(alias, []).append(p.meta_key) + for alias, owners in alias_owners.items(): + if len(owners) == 1: + result[alias] = titles[owners[0]] + return result diff --git a/export1c_help/export.py b/export1c_help/export.py new file mode 100644 index 0000000..53c2734 --- /dev/null +++ b/export1c_help/export.py @@ -0,0 +1,199 @@ +"""Build markdown wiki pages from discovered Help HTML.""" + +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +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.naming import assign_titles +from export1c_help.types_map import DIR_TO_RU +from export1c_help.__version__ import __version__ + + +@dataclass +class ExportStats: + pages_total: int = 0 + pages_written: int = 0 + pages_skipped_empty: int = 0 + images_copied: int = 0 + + +def _is_effectively_empty(md: str) -> bool: + plain = re_sub_md(md) + return len(plain) < 20 + + +def re_sub_md(md: str) -> str: + import re + + 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 build_home(pages: list[HelpPage], titles: dict[str, str], *, source_label: str) -> str: + by_type: dict[str, list[HelpPage]] = {} + for p in pages: + by_type.setdefault(p.type_dir, []).append(p) + + lines = [ + f"# Справка конфигурации", + "", + f"Автогенерация из встроенной справки 1С (`Ext/Help/ru.html`).", + "", + f"- Источник: `{source_label}`", + f"- Страниц: **{len(pages)}**", + 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|Оглавление]]", ""] + 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("") + # only object-level in sidebar to keep it usable + 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 export_help( + src_root: Path, + out_dir: Path, + *, + include_forms: bool = True, + skip_empty: bool = True, + clean: bool = False, + source_label: 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() + + 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) + + manifest_pages: list[dict] = [] + + 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 + + # footer + md = ( + md.rstrip() + + "\n\n---\n\n" + + f"*Мета: `{p.meta_key}` · файл: `{p.rel_posix}`*\n" + ) + + fname = files[p.meta_key] + (out_dir / fname).write_text(md, encoding="utf-8") + stats.pages_written += 1 + + # copy sibling images + help_dir = p.path.parent + 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 + + manifest_pages.append( + { + "meta_key": p.meta_key, + "title": title, + "filename": fname, + "type_dir": p.type_dir, + "object": p.object_name, + "form": p.form_name, + "source": p.rel_posix, + } + ) + + label = source_label or str(src_root) + written_keys = {m["meta_key"] for m in manifest_pages} + written_pages = [p for p in pages if p.meta_key in written_keys] + (out_dir / "Home.md").write_text( + build_home(written_pages, titles, source_label=label), + encoding="utf-8", + ) + (out_dir / "_Sidebar.md").write_text( + build_sidebar(written_pages, titles), + encoding="utf-8", + ) + + manifest = { + "tool": "export_1c_help", + "version": __version__, + "source": label, + "exported_at": datetime.now(timezone.utc).isoformat(), + "pages_total": stats.pages_total, + "pages_written": stats.pages_written, + "pages_skipped_empty": stats.pages_skipped_empty, + "pages": manifest_pages, + } + (out_dir / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return stats diff --git a/export1c_help/naming.py b/export1c_help/naming.py new file mode 100644 index 0000000..20d684d --- /dev/null +++ b/export1c_help/naming.py @@ -0,0 +1,106 @@ +"""Wiki page titles and Gitea filename encoding.""" + +from __future__ import annotations + +import hashlib +import re +from urllib.parse import quote, unquote + +from export1c_help.discover import HelpPage + +_INVALID = re.compile(r'[\\/:*?"<>|#\[\]]+') +_SPACES = re.compile(r"\s+") +_MAX_NAME_BYTES = 180 + + +def sanitize_title(title: str) -> str: + title = _SPACES.sub(" ", title).strip() + title = _INVALID.sub(" ", title) + title = _SPACES.sub(" ", title).strip() + return title or "untitled" + + +def _truncate_utf8(s: str, max_bytes: int) -> str: + raw = s.encode("utf-8") + if len(raw) <= max_bytes: + return s + raw = raw[:max_bytes] + while raw: + try: + return raw.decode("utf-8").rstrip("- ") + except UnicodeDecodeError: + raw = raw[:-1] + return "page" + + +def _short_hash(s: str) -> str: + return hashlib.sha1(s.encode("utf-8")).hexdigest()[:8] + + +def wiki_filename(title: str, *, meta_key: str | None = None) -> str: + """Unicode wiki slug (spaces → «-»).""" + slug = sanitize_title(title).replace(" ", "-") + # reserve room for optional -xxxxxxxx suffix + slug = _truncate_utf8(slug, _MAX_NAME_BYTES) + return f"{slug}.md" + + +def wiki_filename_gitea_legacy(title: str) -> str: + slug = sanitize_title(title).replace(" ", "-") + slug = _truncate_utf8(slug, 80) + return quote(slug, safe="-_.") + ".md" + + +def decode_wiki_filename(name: str) -> str: + base = name[:-3] if name.endswith(".md") else name + return unquote(base) + + +def assign_titles( + pages: list[HelpPage], + h1_by_meta: dict[str, str | None], +) -> tuple[dict[str, str], dict[str, str]]: + """ + Returns (meta_key → title, meta_key → filename). + + Filenames are unique even after UTF-8 truncation. + """ + used_title: set[str] = set() + used_file: set[str] = set() + titles: dict[str, str] = {} + files: dict[str, str] = {} + + for p in pages: + h1 = h1_by_meta.get(p.meta_key) + base = sanitize_title(h1 or p.synonym or p.meta_key) + title = base + n = 2 + while title.casefold() in used_title: + if p.is_form and p.form_name and n == 2: + title = sanitize_title(f"{base} — {p.form_name}") + elif n == 2: + title = sanitize_title(f"{base} — {p.object_name}") + else: + title = sanitize_title(f"{base} ({n})") + n += 1 + if n > 5000: + title = sanitize_title(p.meta_key) + break + + fname = wiki_filename(title) + if fname.casefold() in used_file: + stem = fname[:-3] + stem = _truncate_utf8(stem, _MAX_NAME_BYTES - 9) + fname = f"{stem}-{_short_hash(p.meta_key)}.md" + # last-resort unique + guard = 0 + while fname.casefold() in used_file: + fname = f"page-{_short_hash(p.meta_key + str(guard))}.md" + guard += 1 + + used_title.add(title.casefold()) + used_file.add(fname.casefold()) + titles[p.meta_key] = title + files[p.meta_key] = fname + + return titles, files diff --git a/export1c_help/types_map.py b/export1c_help/types_map.py new file mode 100644 index 0000000..79a91ab --- /dev/null +++ b/export1c_help/types_map.py @@ -0,0 +1,73 @@ +"""Mapping between 1C export folders and help link type prefixes.""" + +from __future__ import annotations + +# Folder under src/ → type used in v8 help links (Catalog.X/Help) +DIR_TO_LINK_TYPE: dict[str, str] = { + "Catalogs": "Catalog", + "Documents": "Document", + "DocumentJournals": "DocumentJournal", + "Enums": "Enum", + "Reports": "Report", + "DataProcessors": "DataProcessor", + "InformationRegisters": "InformationRegister", + "AccumulationRegisters": "AccumulationRegister", + "AccountingRegisters": "AccountingRegister", + "CalculationRegisters": "CalculationRegister", + "ChartsOfCharacteristicTypes": "ChartOfCharacteristicTypes", + "ChartsOfAccounts": "ChartOfAccounts", + "ChartsOfCalculationTypes": "ChartOfCalculationTypes", + "BusinessProcesses": "BusinessProcess", + "Tasks": "Task", + "ExchangePlans": "ExchangePlan", + "FilterCriteria": "FilterCriterion", + "SettingsStorages": "SettingsStorage", + "CommonForms": "CommonForm", + "CommonCommands": "CommonCommand", + "CommonModules": "CommonModule", + "CommonTemplates": "CommonTemplate", + "CommonPictures": "CommonPicture", + "XDTOPackages": "XDTOPackage", + "WebServices": "WebService", + "HTTPServices": "HTTPService", + "WSReferences": "WSReference", + "Styles": "Style", + "Languages": "Language", + "FunctionalOptions": "FunctionalOption", + "FunctionalOptionsParameters": "FunctionalOptionsParameter", + "DefinedTypes": "DefinedType", + "SessionParameters": "SessionParameter", + "Constants": "Constant", + "Sequences": "Sequence", + "Subsystems": "Subsystem", + "Roles": "Role", + "Interfaces": "Interface", + "EventSubscriptions": "EventSubscription", + "ScheduledJobs": "ScheduledJob", +} + +DIR_TO_RU: dict[str, str] = { + "Catalogs": "Справочники", + "Documents": "Документы", + "DocumentJournals": "Журналы документов", + "Enums": "Перечисления", + "Reports": "Отчёты", + "DataProcessors": "Обработки", + "InformationRegisters": "Регистры сведений", + "AccumulationRegisters": "Регистры накопления", + "AccountingRegisters": "Регистры бухгалтерии", + "CalculationRegisters": "Регистры расчёта", + "ChartsOfCharacteristicTypes": "Планы видов характеристик", + "ChartsOfAccounts": "Планы счетов", + "ChartsOfCalculationTypes": "Планы видов расчёта", + "BusinessProcesses": "Бизнес-процессы", + "Tasks": "Задачи", + "ExchangePlans": "Планы обмена", + "FilterCriteria": "Критерии отбора", + "SettingsStorages": "Хранилища настроек", + "CommonForms": "Общие формы", + "CommonCommands": "Общие команды", + "Subsystems": "Подсистемы", +} + +LINK_TYPE_TO_DIR: dict[str, str] = {v: k for k, v in DIR_TO_LINK_TYPE.items()} diff --git a/export1c_help/wiki.py b/export1c_help/wiki.py new file mode 100644 index 0000000..5818058 --- /dev/null +++ b/export1c_help/wiki.py @@ -0,0 +1,93 @@ +"""Push generated wiki tree into a Gitea wiki git repository.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +class WikiPushError(RuntimeError): + pass + + +def _run(cmd: list[str], *, cwd: Path) -> str: + proc = subprocess.run( + cmd, + cwd=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 push_wiki( + content_dir: Path, + wiki_url: str, + *, + work_dir: Path, + message: str, + branch: str = "main", + keep_unmanaged: bool = False, +) -> None: + """ + Clone/fetch wiki repo into work_dir, replace managed pages, commit, push. + + Managed pages = all *.md from content_dir plus images and manifest.json. + If keep_unmanaged=False, removes other *.md (except maybe custom) before copy. + """ + content_dir = content_dir.resolve() + work_dir = work_dir.resolve() + + if work_dir.exists() and (work_dir / ".git").exists(): + _run(["git", "fetch", "origin"], cwd=work_dir) + _run(["git", "checkout", branch], cwd=work_dir) + _run(["git", "reset", "--hard", f"origin/{branch}"], cwd=work_dir) + else: + if work_dir.exists(): + shutil.rmtree(work_dir) + work_dir.parent.mkdir(parents=True, exist_ok=True) + _run( + ["git", "clone", "--branch", branch, "--single-branch", wiki_url, str(work_dir)], + cwd=work_dir.parent, + ) + + if not keep_unmanaged: + for path in work_dir.iterdir(): + if path.name == ".git": + continue + if path.is_file() and path.suffix.lower() in {".md", ".json"}: + path.unlink() + elif path.is_file() and path.suffix.lower() in { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".bmp", + ".webp", + }: + path.unlink() + + for src in content_dir.iterdir(): + if src.name == ".git": + continue + dst = work_dir / src.name + if src.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + + _run(["git", "add", "-A"], cwd=work_dir) + status = _run(["git", "status", "--porcelain"], cwd=work_dir) + if not status.strip(): + return + + _run(["git", "commit", "-m", message], cwd=work_dir) + _run(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir) diff --git a/export_1c_help.py b/export_1c_help.py new file mode 100644 index 0000000..6867f4a --- /dev/null +++ b/export_1c_help.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""CLI: export 1C Help (ru.html) to Markdown / Gitea wiki.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +TOOL_ROOT = Path(__file__).resolve().parent +if str(TOOL_ROOT) not in sys.path: + sys.path.insert(0, str(TOOL_ROOT)) + +from export1c_help import __status__, __version__ # noqa: E402 +from export1c_help.export import export_help # noqa: E402 +from export1c_help.wiki import WikiPushError, push_wiki # noqa: E402 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="export_1c_help", + description=( + f"Выгрузка встроенной справки 1С (Ext/Help/ru.html) в Markdown / Gitea wiki. " + f"v{__version__} ({__status__})." + ), + ) + p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + + sub = p.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("convert", help="Конвертация Help → Markdown в каталог") + c.add_argument( + "-s", + "--src", + type=Path, + required=True, + help="Каталог src выгрузки конфигурации (…/crm3-26/src)", + ) + c.add_argument( + "-o", + "--out", + type=Path, + required=True, + help="Каталог результата (wiki working tree)", + ) + c.add_argument( + "--objects-only", + action="store_true", + help="Только справка объектов (без Forms/*/Ext/Help)", + ) + c.add_argument( + "--keep-empty", + action="store_true", + help="Не пропускать почти пустые страницы", + ) + c.add_argument( + "--clean", + action="store_true", + help="Очистить --out перед записью (кроме .git)", + ) + c.add_argument( + "--source-label", + default=None, + help="Подпись источника в Home.md", + ) + c.set_defaults(func=cmd_convert) + + w = sub.add_parser("push", help="convert + push в Gitea wiki (.wiki.git)") + w.add_argument("-s", "--src", type=Path, required=True) + w.add_argument( + "--wiki-url", + required=True, + help="URL wiki-репозитория, напр. https://git.p7net.ru/1c/crm3_26.wiki.git", + ) + w.add_argument( + "--work-dir", + type=Path, + default=None, + help="Локальный clone wiki (по умолчанию: /.wiki_clone)", + ) + w.add_argument( + "-o", + "--out", + type=Path, + default=None, + help="Промежуточный каталог MD (по умолчанию: ./out/wiki-md)", + ) + w.add_argument("--objects-only", action="store_true") + w.add_argument("--keep-empty", action="store_true") + w.add_argument( + "--keep-unmanaged", + action="store_true", + help="Не удалять чужие .md в wiki перед копированием", + ) + w.add_argument( + "-m", + "--message", + default=None, + help="Сообщение коммита wiki", + ) + w.add_argument("--branch", default="main") + w.add_argument( + "--dry-run", + action="store_true", + help="Только convert, без git push", + ) + w.set_defaults(func=cmd_push) + + return p + + +def cmd_convert(args: argparse.Namespace) -> int: + stats = export_help( + args.src, + args.out, + include_forms=not args.objects_only, + skip_empty=not args.keep_empty, + clean=args.clean, + source_label=args.source_label, + ) + print( + f"OK: written={stats.pages_written} " + f"skipped_empty={stats.pages_skipped_empty} " + f"total={stats.pages_total} " + f"images={stats.images_copied} → {args.out}" + ) + return 0 + + +def cmd_push(args: argparse.Namespace) -> int: + out = args.out or (TOOL_ROOT / "out" / "wiki-md") + work = args.work_dir or (out.parent / "wiki_clone") + stats = export_help( + args.src, + out, + include_forms=not args.objects_only, + skip_empty=not args.keep_empty, + clean=True, + source_label=str(args.src), + ) + print( + f"convert: written={stats.pages_written} " + f"skipped_empty={stats.pages_skipped_empty} " + f"total={stats.pages_total} → {out}" + ) + if args.dry_run: + print("dry-run: push skipped") + return 0 + + message = args.message or ( + f"export_1c_help v{__version__}: sync help ({stats.pages_written} pages)" + ) + try: + push_wiki( + out, + args.wiki_url, + work_dir=work, + message=message, + branch=args.branch, + keep_unmanaged=args.keep_unmanaged, + ) + except WikiPushError as exc: + print(f"push failed:\n{exc}", file=sys.stderr) + return 1 + print(f"OK: pushed → {args.wiki_url}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c81b44d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# Python stdlib only (3.10+)