diff --git a/CHANGELOG.md b/CHANGELOG.md index bf13ca0..a5b07d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to **export_1c_help** are documented in this file. +## [0.2.0] - 2026-07-23 + +### Added + +- Configuration version on each export (`VERSION` / `Configuration.xml`) +- Per-page lifecycle in wiki footer and `manifest.json`: + - `created_in` — версия конфигурации, в которой страница впервые попала в wiki + - `edited_in` — версия, в которой содержимое справки изменилось + - `deleted_in` — версия, в которой страница исчезла из `src` +- `History.md` — сводка выгрузки и накопительный список удалённых страниц +- `push` читает предыдущий `manifest.json` из wiki перед пересборкой +- CLI: `--prev-manifest`, `--config-version` + +### Changed + +- Home / Sidebar показывают версию конфигурации и ссылку на History + ## [0.1.0] - 2026-07-23 ### Status @@ -25,4 +42,5 @@ All notable changes to **export_1c_help** are documented in this file. - Nested subsystem help paths (`Subsystems/A/Subsystems/B/…`) no longer collapse to the parent meta key - Unicode wiki filenames replaced with percent-encoded slugs (Gitea UI returned HTTP 500) +[0.2.0]: https://git.p7net.ru/tools/export_1c_help/releases/tag/v0.2.0 [0.1.0]: https://git.p7net.ru/tools/export_1c_help/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 7b890bf..ebee00a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # export_1c_help -**Версия:** см. [`VERSION`](VERSION) (текущая: **0.1.0**) · [CHANGELOG](CHANGELOG.md) +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.2.0**) · [CHANGELOG](CHANGELOG.md) **Лицензия:** [MIT](LICENSE) Выгрузка встроенной справки конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в [Gitea Wiki](https://docs.gitea.com/usage/wiki). @@ -9,26 +9,66 @@ --- +## Порядок обновления wiki (после доработки справки в конфигурации) + +Инструмент **всегда** пересобирает wiki из всего `src/` (инкрементального патча нет). Частичная правка Help в конфигураторе → полная перевыгрузка `src` → полный `push`. + +1. Доработать справку в конфигураторе (нужные объекты/формы). +2. Выгрузить конфигурацию в git (`crm3-26/`) и закоммитить/запушить, как обычно. +3. В монорепо: `git pull` (актуальный `crm3-26/src`, в т.ч. `VERSION` / `Configuration.xml`). +4. Синхронизировать wiki: + +```bash +cd tools/export_1c_help +python3 export_1c_help.py push \ + -s ../../crm3-26/src \ + --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git \ + -m "sync help after config update" +``` + +Перед записью можно проверить без push: + +```bash +python3 export_1c_help.py push \ + -s ../../crm3-26/src \ + --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git \ + --dry-run +``` + +**Канон текста** — HTML в конфигурации. Ручные правки страниц в wiki сотрутся при следующем `push` (если не указан `--keep-unmanaged`). + +--- + +## Версии конфигурации на страницах + +| Где | Что фиксируется | +|-----|-----------------| +| `Home.md` | версия конфигурации **этой** выгрузки | +| подвал каждой страницы | версия выгрузки; **создана в** / **изменена в** | +| `History.md` | сводка +/−/~ и накопительный список **удалённых** (`deleted_in`) | +| `manifest.json` | те же поля + `content_hash`, история `exports[]` | + +Версия берётся из `/VERSION` или `` в `Configuration.xml` (можно переопределить `--config-version`). + +Жизненный цикл считается относительно предыдущего `manifest.json` в wiki (при `push` подтягивается автоматически). + +--- + ## Быстрый старт ```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 + --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git ``` -Проверка версии: - ```bash python3 export_1c_help.py --version ``` @@ -39,62 +79,30 @@ python3 export_1c_help.py --version ### `convert` -Читает `src/`, пишет Markdown-дерево wiki: - -| Файл | Назначение | -|------|------------| -| `Home.md` | оглавление по типам метаданных | -| `_Sidebar.md` | боковая навигация (объекты без форм) | -| `«Заголовок».md` / `%D0%….md` | страницы справки (кириллица в git — **percent-encoded**, как хранит Gitea) | -| `manifest.json` | карта `meta_key` → title / filename | - | Параметр | Описание | |----------|----------| | `-s` / `--src` | каталог `src` выгрузки конфигурации | | `-o` / `--out` | каталог результата | -| `--objects-only` | без справки форм (`Forms/*/Ext/Help`) | +| `--objects-only` | без справки форм | | `--keep-empty` | не пропускать почти пустые страницы | -| `--clean` | очистить `--out` перед записью (кроме `.git`) | +| `--clean` | очистить `--out` перед записью | +| `--prev-manifest` | предыдущий `manifest.json` (lifecycle) | +| `--config-version` | явная версия конфигурации | ### `push` -Выполняет `convert`, затем `git clone` / обновление wiki-репозитория (обычно `*.wiki.git`), коммит и `git push`. +`prepare wiki` → `convert` (с `manifest.json` из wiki) → `git commit` / `push`. | Параметр | Описание | |----------|----------| -| `--wiki-url` | URL wiki git, напр. `https://git.p7net.ru/1c/crm3_26.wiki.git` | -| `--work-dir` | локальный clone wiki | +| `--wiki-url` | URL `*.wiki.git` | | `--dry-run` | только convert | -| `--keep-unmanaged` | не удалять чужие `.md` в wiki | +| `--keep-unmanaged` | не удалять чужие `.md` | | `-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` +- путь: `tools/export_1c_help/` +- remote: https://git.p7net.ru/tools/export_1c_help.git diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/export1c_help/config_version.py b/export1c_help/config_version.py new file mode 100644 index 0000000..3952e33 --- /dev/null +++ b/export1c_help/config_version.py @@ -0,0 +1,64 @@ +"""Read 1C configuration version from dump tree.""" + +from __future__ import annotations + +import re +from pathlib import Path + +_VERSION_TAG = re.compile(r"([^<]+)") +_NAME_TAG = re.compile( + r"([^<]+)", +) + + +def config_root_from_src(src_root: Path) -> Path: + """src/ → configuration root (parent that usually has VERSION + Configuration.xml).""" + src_root = Path(src_root).resolve() + if src_root.name == "src": + return src_root.parent + return src_root + + +def read_config_version(src_root: Path) -> str: + """ + Resolve configuration version. + + Priority: + 1. /VERSION + 2. /src/Configuration.xml → + 3. /Configuration.xml → + """ + src_root = Path(src_root) + root = config_root_from_src(src_root) + version_file = root / "VERSION" + if version_file.is_file(): + text = version_file.read_text(encoding="utf-8-sig", errors="ignore").strip() + if text: + return text.splitlines()[0].strip() + + for xml_path in ( + root / "src" / "Configuration.xml", + src_root / "Configuration.xml", + root / "Configuration.xml", + ): + if not xml_path.is_file(): + continue + raw = xml_path.read_text(encoding="utf-8-sig", errors="ignore") + m = _VERSION_TAG.search(raw) + if m: + return m.group(1).strip() + + return "unknown" + + +def read_config_name(src_root: Path) -> str | None: + src_root = Path(src_root) + root = config_root_from_src(src_root) + xml_path = root / "src" / "Configuration.xml" + if not xml_path.is_file(): + xml_path = src_root / "Configuration.xml" + if not xml_path.is_file(): + return None + raw = xml_path.read_text(encoding="utf-8-sig", errors="ignore") + m = _NAME_TAG.search(raw) + return m.group(1).strip() if m else None diff --git a/export1c_help/export.py b/export1c_help/export.py index 53c2734..386f4da 100644 --- a/export1c_help/export.py +++ b/export1c_help/export.py @@ -3,16 +3,26 @@ 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 read_config_name, read_config_version 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 -from export1c_help.__version__ import __version__ @dataclass @@ -21,6 +31,11 @@ class ExportStats: 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: @@ -29,8 +44,6 @@ def _is_effectively_empty(md: str) -> bool: 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) @@ -38,18 +51,43 @@ def re_sub_md(md: str) -> str: return t -def build_home(pages: list[HelpPage], titles: dict[str, str], *, source_label: str) -> str: +def _page_footer(rec: PageRecord, *, config_version: str) -> str: + lines = [ + "", + "---", + "", + f"*Конфигурация выгрузки: **{config_version}*** ", + 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, + config_version: str, + config_name: str | None, + life: LifecycleResult, +) -> str: by_type: dict[str, list[HelpPage]] = {} for p in pages: by_type.setdefault(p.type_dir, []).append(p) + name_line = f" (`{config_name}`)" if config_name else "" lines = [ - f"# Справка конфигурации", + "# Справка конфигурации", "", - f"Автогенерация из встроенной справки 1С (`Ext/Help/ru.html`).", + "Автогенерация из встроенной справки 1С (`Ext/Help/ru.html`).", "", + f"- **Версия конфигурации (эта выгрузка):** `{config_version}`{name_line}", f"- Источник: `{source_label}`", - f"- Страниц: **{len(pages)}**", + 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')}", "", @@ -72,12 +110,17 @@ 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|Оглавление]]", ""] + 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("") - # 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]: @@ -88,6 +131,71 @@ def build_sidebar(pages: list[HelpPage], titles: dict[str, str]) -> str: return "\n".join(lines).rstrip() + "\n" +def build_history( + *, + config_version: str, + config_name: str | None, + life: LifecycleResult, + prev_exports: list[dict], +) -> str: + name = f" `{config_name}`" if config_name else "" + lines = [ + "# История выгрузок справки", + "", + f"Текущая выгрузка: конфигурация **{config_version}**{name}.", + "", + "## Эта выгрузка", + "", + 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 != config_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_version', '?')} | {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, @@ -96,12 +204,23 @@ def export_help( 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() + cfg_ver = (config_version or read_config_version(src_root)).strip() or "unknown" + cfg_name = read_config_name(src_root) + 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) @@ -128,7 +247,8 @@ def export_help( def rewrite(href: str, text: str) -> str: return default_link_rewrite(href, text, link_map) - manifest_pages: list[dict] = [] + draft_records: list[PageRecord] = [] + body_by_meta: dict[str, str] = {} for p in pages: html = html_cache[p.meta_key] @@ -137,60 +257,132 @@ def export_help( 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" + 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="", + ) ) - fname = files[p.meta_key] - (out_dir / fname).write_text(md, encoding="utf-8") + 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, config_version=cfg_ver) + (out_dir / rec.filename).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, - } - ) + 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 = {m["meta_key"] for m in manifest_pages} + 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"), + "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), + build_home( + written_pages, + titles, + source_label=label, + config_version=cfg_ver, + config_name=cfg_name, + 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( + config_version=cfg_ver, + config_name=cfg_name, + life=life, + prev_exports=prev_exports, + ), + encoding="utf-8", + ) + + exported_at = datetime.now(timezone.utc).isoformat() + exports = [ + { + "config_version": cfg_ver, + "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": cfg_name, "source": label, - "exported_at": datetime.now(timezone.utc).isoformat(), + "exported_at": exported_at, "pages_total": stats.pages_total, "pages_written": stats.pages_written, "pages_skipped_empty": stats.pages_skipped_empty, - "pages": manifest_pages, + "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", diff --git a/export1c_help/lifecycle.py b/export1c_help/lifecycle.py new file mode 100644 index 0000000..2baf7d3 --- /dev/null +++ b/export1c_help/lifecycle.py @@ -0,0 +1,174 @@ +"""Page lifecycle tracking across wiki exports.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class PageRecord: + meta_key: str + title: str + filename: str + type_dir: str + object: str + form: str | None + source: str + content_hash: str + created_in: str + edited_in: str + deleted_in: str | None = None + status: str = "active" # active | deleted + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "meta_key": self.meta_key, + "title": self.title, + "filename": self.filename, + "type_dir": self.type_dir, + "object": self.object, + "form": self.form, + "source": self.source, + "content_hash": self.content_hash, + "created_in": self.created_in, + "edited_in": self.edited_in, + "status": self.status, + } + if self.deleted_in: + d["deleted_in"] = self.deleted_in + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> PageRecord: + return cls( + meta_key=d["meta_key"], + title=d.get("title") or d["meta_key"], + filename=d.get("filename") or "", + type_dir=d.get("type_dir") or "", + object=d.get("object") or "", + form=d.get("form"), + source=d.get("source") or "", + content_hash=d.get("content_hash") or "", + created_in=d.get("created_in") or d.get("config_version") or "unknown", + edited_in=d.get("edited_in") or d.get("created_in") or "unknown", + deleted_in=d.get("deleted_in"), + status=d.get("status") or ("deleted" if d.get("deleted_in") else "active"), + ) + + +@dataclass +class LifecycleResult: + active: list[PageRecord] = field(default_factory=list) + deleted: list[PageRecord] = field(default_factory=list) + created_count: int = 0 + edited_count: int = 0 + unchanged_count: int = 0 + deleted_count: int = 0 + + +def content_hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16] + + +def load_prev_manifest(path: Path | None) -> dict[str, Any] | None: + if path is None or not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def prev_records(manifest: dict[str, Any] | None) -> dict[str, PageRecord]: + if not manifest: + return {} + out: dict[str, PageRecord] = {} + for key in ("pages", "deleted_pages"): + for item in manifest.get(key) or []: + if not isinstance(item, dict) or "meta_key" not in item: + continue + rec = PageRecord.from_dict(item) + # prefer active over deleted if both present + if rec.meta_key in out and out[rec.meta_key].status == "active": + continue + out[rec.meta_key] = rec + return out + + +def merge_lifecycle( + *, + current: list[PageRecord], + previous: dict[str, PageRecord], + config_version: str, +) -> LifecycleResult: + """ + current — new active pages (created_in/edited_in placeholders ignored; + content_hash must be set). + """ + result = LifecycleResult() + current_keys = {r.meta_key for r in current} + + for cur in current: + old = previous.get(cur.meta_key) + if old is None or old.status == "deleted": + cur.created_in = config_version + cur.edited_in = config_version + cur.deleted_in = None + cur.status = "active" + result.created_count += 1 + elif not old.content_hash: + # legacy manifest without hashes — baseline this export + cur.created_in = ( + old.created_in + if old.created_in and old.created_in != "unknown" + else config_version + ) + cur.edited_in = config_version + cur.deleted_in = None + cur.status = "active" + result.unchanged_count += 1 + elif old.content_hash == cur.content_hash: + cur.created_in = old.created_in + cur.edited_in = old.edited_in + cur.deleted_in = None + cur.status = "active" + result.unchanged_count += 1 + else: + cur.created_in = old.created_in or config_version + cur.edited_in = config_version + cur.deleted_in = None + cur.status = "active" + result.edited_count += 1 + result.active.append(cur) + + for key, old in previous.items(): + if key in current_keys: + continue + if old.status == "deleted" and old.deleted_in: + # already tombstoned — keep + result.deleted.append(old) + continue + tomb = PageRecord( + meta_key=old.meta_key, + title=old.title, + filename=old.filename, + type_dir=old.type_dir, + object=old.object, + form=old.form, + source=old.source, + content_hash=old.content_hash, + created_in=old.created_in, + edited_in=old.edited_in, + deleted_in=config_version, + status="deleted", + ) + result.deleted.append(tomb) + result.deleted_count += 1 + + result.active.sort(key=lambda r: r.meta_key) + result.deleted.sort(key=lambda r: r.meta_key) + return result diff --git a/export1c_help/wiki.py b/export1c_help/wiki.py index 5818058..9776c11 100644 --- a/export1c_help/wiki.py +++ b/export1c_help/wiki.py @@ -26,24 +26,14 @@ def _run(cmd: list[str], *, cwd: Path) -> str: return proc.stdout -def push_wiki( - content_dir: Path, +def prepare_wiki_clone( 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() +) -> Path: + """Clone or reset wiki working tree; return path to existing manifest.json if any.""" 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) @@ -53,9 +43,41 @@ def push_wiki( 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)], + [ + "git", + "clone", + "--branch", + branch, + "--single-branch", + wiki_url, + str(work_dir), + ], cwd=work_dir.parent, ) + return work_dir + + +def push_wiki( + content_dir: Path, + wiki_url: str, + *, + work_dir: Path, + message: str, + branch: str = "main", + keep_unmanaged: bool = False, + already_prepared: bool = False, +) -> None: + """ + Clone/fetch wiki repo into work_dir, replace managed pages, commit, push. + + Managed pages = all files from content_dir (md/json/images). + If keep_unmanaged=False, removes other .md/.json/images before copy. + """ + content_dir = content_dir.resolve() + work_dir = work_dir.resolve() + + if not already_prepared: + prepare_wiki_clone(wiki_url, work_dir, branch=branch) if not keep_unmanaged: for path in work_dir.iterdir(): diff --git a/export_1c_help.py b/export_1c_help.py index 6867f4a..7dcdd4e 100644 --- a/export_1c_help.py +++ b/export_1c_help.py @@ -12,8 +12,9 @@ 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.config_version import read_config_version # noqa: E402 from export1c_help.export import export_help # noqa: E402 -from export1c_help.wiki import WikiPushError, push_wiki # noqa: E402 +from export1c_help.wiki import WikiPushError, prepare_wiki_clone, push_wiki # noqa: E402 def build_parser() -> argparse.ArgumentParser: @@ -29,44 +30,11 @@ def build_parser() -> argparse.ArgumentParser: 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", - ) + _add_convert_args(c) 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) + _add_convert_args(w, require_out=False) w.add_argument( "--wiki-url", required=True, @@ -76,7 +44,7 @@ def build_parser() -> argparse.ArgumentParser: "--work-dir", type=Path, default=None, - help="Локальный clone wiki (по умолчанию: /.wiki_clone)", + help="Локальный clone wiki (по умолчанию: /wiki_clone)", ) w.add_argument( "-o", @@ -85,30 +53,75 @@ def build_parser() -> argparse.ArgumentParser: 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("-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.add_argument("--dry-run", action="store_true", help="Только convert, без git push") w.set_defaults(func=cmd_push) return p +def _add_convert_args(p: argparse.ArgumentParser, *, require_out: bool = True) -> None: + p.add_argument( + "-s", + "--src", + type=Path, + required=True, + help="Каталог src выгрузки конфигурации (…/crm3-26/src)", + ) + if require_out: + p.add_argument( + "-o", + "--out", + type=Path, + required=True, + help="Каталог результата (wiki working tree)", + ) + p.add_argument( + "--objects-only", + action="store_true", + help="Только справка объектов (без Forms/*/Ext/Help)", + ) + p.add_argument( + "--keep-empty", + action="store_true", + help="Не пропускать почти пустые страницы", + ) + p.add_argument( + "--clean", + action="store_true", + help="Очистить --out перед записью (кроме .git)", + ) + p.add_argument("--source-label", default=None, help="Подпись источника в Home.md") + p.add_argument( + "--prev-manifest", + type=Path, + default=None, + help="Предыдущий manifest.json (жизненный цикл страниц)", + ) + p.add_argument( + "--config-version", + default=None, + help="Версия конфигурации (иначе VERSION / Configuration.xml)", + ) + + +def _print_stats(stats, dest: Path) -> None: + print( + f"OK: config={stats.config_version} " + f"written={stats.pages_written} " + f"+{stats.created_count}/~{stats.edited_count}/={stats.unchanged_count}/−{stats.deleted_count} " + f"skipped_empty={stats.pages_skipped_empty} " + f"total={stats.pages_total} " + f"images={stats.images_copied} → {dest}" + ) + + def cmd_convert(args: argparse.Namespace) -> int: stats = export_help( args.src, @@ -117,38 +130,50 @@ def cmd_convert(args: argparse.Namespace) -> int: skip_empty=not args.keep_empty, clean=args.clean, source_label=args.source_label, + prev_manifest_path=args.prev_manifest, + config_version=args.config_version, ) - 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}" - ) + _print_stats(stats, 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") + branch = args.branch + + try: + prepare_wiki_clone(args.wiki_url, work, branch=branch) + except WikiPushError as exc: + print(f"wiki prepare failed:\n{exc}", file=sys.stderr) + return 1 + + prev = args.prev_manifest or (work / "manifest.json") + cfg = args.config_version or read_config_version(args.src) + 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), + source_label=args.source_label or str(args.src), + prev_manifest_path=prev if prev.is_file() else None, + config_version=cfg, ) print( - f"convert: written={stats.pages_written} " - f"skipped_empty={stats.pages_skipped_empty} " - f"total={stats.pages_total} → {out}" + f"convert: config={stats.config_version} " + f"written={stats.pages_written} " + f"+{stats.created_count}/~{stats.edited_count}/={stats.unchanged_count}/−{stats.deleted_count} " + f"→ {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)" + f"export_1c_help v{__version__}: sync help cfg {stats.config_version} " + f"({stats.pages_written} pages, +{stats.created_count}/~{stats.edited_count}/−{stats.deleted_count})" ) try: push_wiki( @@ -156,8 +181,9 @@ def cmd_push(args: argparse.Namespace) -> int: args.wiki_url, work_dir=work, message=message, - branch=args.branch, + branch=branch, keep_unmanaged=args.keep_unmanaged, + already_prepared=True, ) except WikiPushError as exc: print(f"push failed:\n{exc}", file=sys.stderr)