Initial release: export 1C Help to Gitea wiki.

Convert Ext/Help/ru.html to Markdown and push to a wiki git repository.
This commit is contained in:
mihailkudravcev
2026-07-23 14:54:04 +03:00
commit aec0407b87
15 changed files with 1339 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
out/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.DS_Store
*.egg-info/
.venv/
+23
View File
@@ -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
+21
View File
@@ -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.
+100
View File
@@ -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`
+1
View File
@@ -0,0 +1 @@
0.1.0
+7
View File
@@ -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__"]
+18
View File
@@ -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"
+279
View File
@@ -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"<h1[^>]*>(.*?)</h1>", 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
+234
View File
@@ -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"<Synonym>\s*<v8:item>\s*<v8:lang>ru</v8:lang>\s*<v8:content>(.*?)</v8:content>",
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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", '"')
.replace("&apos;", "'")
)
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
+199
View File
@@ -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
+106
View File
@@ -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
+73
View File
@@ -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()}
+93
View File
@@ -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)
+176
View File
@@ -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 (по умолчанию: <out>/.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())
+1
View File
@@ -0,0 +1 @@
# Python stdlib only (3.10+)