From f55c654de29cc0246c367366cd970dac3d385a60 Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Fri, 14 Aug 2026 16:38:04 +0300 Subject: [PATCH] =?UTF-8?q?Release=200.5.0:=20form=5Fwiki=5Fexport=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20?= =?UTF-8?q?HTML-=D1=84=D0=BE=D1=80=D0=BC=20=D0=B2=20Gitea=20wiki.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный обход Form.xml, настраиваемый wiki_prefix, русские типы в именах, ссылки без percent-encoding. Co-authored-by: Cursor --- CHANGELOG.md | 17 + README.md | 60 ++- VERSION | 2 +- configs/form_wiki.crm3-26.json | 7 + configs/form_wiki.crm3-26.rhana.json | 7 + export1c_help/form_wiki_export.py | 636 +++++++++++++++++++++++++++ form_wiki_export.py | 282 ++++++++++++ 7 files changed, 1009 insertions(+), 2 deletions(-) create mode 100644 configs/form_wiki.crm3-26.json create mode 100644 configs/form_wiki.crm3-26.rhana.json create mode 100644 export1c_help/form_wiki_export.py create mode 100644 form_wiki_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7099e26..2654aae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to **export_1c_help** are documented in this file. +## [0.5.0] - 2026-08-14 + +### Added + +- `form_wiki_export.py` — полный обход `**/Ext/Form.xml`, генерация HTML-прототипов + и wiki-страниц с префиксом `form_` для просмотра в Gitea wiki (iframe + вложение). +- Отдельный manifest `manifest.export_forms.json` и маркеры `export_forms:*` в + `Home.md` / `_Sidebar.md` — обновление без затрагивания страниц `export_docs` и справки. +- Конфиг: `configs/form_wiki.crm3-26.json`. +- Префикс имён страниц: `wiki_prefix` в JSON и `--wiki-prefix` в CLI + (по умолчанию `form_`; в примере конфига — `forms_`). +- В именах `form_*` / `forms_*` — русские типы объектов (`Справочники`, `Общие_формы`), + а не английские каталоги выгрузки (`Catalogs`, `CommonForms`). +- Ссылки в `form_INDEX` / страницах форм: кириллица без percent-encoding (`%D0%…`). + +--- + ## [0.4.4] - 2026-08-12 ### Changed diff --git a/README.md b/README.md index 62d7c12..b986548 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # export_1c_help -**Версия:** см. [`VERSION`](VERSION) (текущая: **0.4.4**) · [CHANGELOG](CHANGELOG.md) +**Версия:** см. [`VERSION`](VERSION) (текущая: **0.5.0**) · [CHANGELOG](CHANGELOG.md) **Лицензия:** [MIT](LICENSE) Выгрузка встроенной справки **любой** конфигурации 1С (`**/Ext/Help/ru.html`) в Markdown и публикация в wiki git-репозитория (Gitea / GitLab: `*.wiki.git`). @@ -287,6 +287,64 @@ python3 form_xml_to_html.py \ --- +## Wiki: все формы (`form_wiki_export.py`) + +Полный обход `**/Ext/Form.xml` в одной или нескольких конфигурациях, генерация HTML и +wiki-страниц с настраиваемым префиксом (по умолчанию **`form_`**, в конфиге crm3-26 — +**`forms_`**) для просмотра в Gitea wiki (iframe + HTML-вложение). + +Обновление **не затирает** другие wiki-страницы: используется отдельный manifest +`manifest.export_forms.json` и маркеры `` (не пересекаются +с `export_docs` и справкой `export_1c_help`). + +```bash +cd tools/export_1c_help + +# локальная сборка (без push) +python3 form_wiki_export.py build -b crm3-26.rhana -o out/form-wiki-rhana + +# другой префикс (переопределяет JSON) +python3 form_wiki_export.py build -c configs/form_wiki.crm3-26.json --wiki-prefix form_ + +# сборка + push в wiki (dry-run сначала) +python3 form_wiki_export.py push -c configs/form_wiki.crm3-26.json --dry-run +python3 form_wiki_export.py push -c configs/form_wiki.crm3-26.json + +# только расширение +python3 form_wiki_export.py push -b crm3-26.rhana + +# удалить form_* из wiki +python3 form_wiki_export.py clean --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git +``` + +| Команда | Описание | +|---------|----------| +| `build` | собрать `{prefix}*.md` + `{prefix}*.html` + `{prefix}INDEX.md` локально | +| `push` | `build` + git push в wiki | +| `clean` | удалить ранее опубликованные страницы по manifest | + +Имена страниц: `{prefix}{конфа}__{тип RU}__{объект}__{форма}.md` (для общих форм — +`{prefix}{конфа}__Общие_формы__{форма}.md`). +Тип — русское имя из метаданных (`Справочники`, `Документы`, `Обработки`, …); +пробелы в типе заменяются на `_`. + +Приоритет префикса: `--wiki-prefix` → `wiki_prefix` в JSON → `form_`. +Если в значении нет завершающего `_`, он добавляется (`forms` → `forms_`). + +Конфиг `configs/form_wiki.crm3-26.json`: + +```json +{ + "bases": ["crm3-26"], + "search_roots": ["../.."], + "out_path": "out/form-wiki", + "wiki_url": "https://git.p7net.ru/1c/crm3_26.wiki.git", + "wiki_prefix": "forms_" +} +``` + +--- + ## Репозиторий инструмента - путь в монорепо: `tools/export_1c_help/` diff --git a/VERSION b/VERSION index 6f2743d..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.4 +0.5.0 diff --git a/configs/form_wiki.crm3-26.json b/configs/form_wiki.crm3-26.json new file mode 100644 index 0000000..49ec80e --- /dev/null +++ b/configs/form_wiki.crm3-26.json @@ -0,0 +1,7 @@ +{ + "bases": ["crm3-26"], + "search_roots": ["../.."], + "out_path": "out/form-wiki", + "wiki_url": "https://git.p7net.ru/1c/crm3_26.wiki.git", + "wiki_prefix": "forms_" +} diff --git a/configs/form_wiki.crm3-26.rhana.json b/configs/form_wiki.crm3-26.rhana.json new file mode 100644 index 0000000..8295925 --- /dev/null +++ b/configs/form_wiki.crm3-26.rhana.json @@ -0,0 +1,7 @@ +{ + "bases": ["crm3-26.rhana"], + "search_roots": ["../.."], + "out_path": "out/form-crm326-rhana", + "wiki_url": "https://git.p7net.ru/1c/crm3_26.rhana.wiki.git", + "wiki_prefix": "forms_" +} diff --git a/export1c_help/form_wiki_export.py b/export1c_help/form_wiki_export.py new file mode 100644 index 0000000..a795b07 --- /dev/null +++ b/export1c_help/form_wiki_export.py @@ -0,0 +1,636 @@ +"""Batch export of 1C managed form HTML prototypes to Gitea wiki.""" + +from __future__ import annotations + +import json +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from export1c_help import __version__ +from export1c_help.form_prototype import ( + default_search_roots, + export_form_prototype, + infer_title_from_path, + load_settings_file, + parse_form, + resolve_base_dir, + type_dir_label, +) +from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text +from export1c_help.resolve import ResolveError, resolve_src_root +from export1c_help.wiki import WikiPushError, prepare_wiki_clone + +MANIFEST_NAME = "manifest.export_forms.json" +DEFAULT_WIKI_PREFIX = "form_" +_EXPORT_FORMS_MARKER_BEGIN = "" +_EXPORT_FORMS_MARKER_END = "" +_RESERVED_ROOT_PAGES = {"Home.md", "History.md", "_Sidebar.md"} +_UNSAFE_SLUG_RE = re.compile(r"[^\w.\-]+", re.UNICODE) +_PREFIX_RE = re.compile(r"[^\w.\-]+", re.UNICODE) + + +@dataclass(frozen=True) +class FormRecord: + form_xml: Path + config_root: Path + base_name: str + type_dir: str + object_name: str + form_name: str + stem: str + + +@dataclass(frozen=True) +class FormsWikiStats: + forms_total: int + files_written: int + files_deleted: int + html_files: int + markdown_files: int + skipped: int = 0 + + +def _sanitize_slug_part(value: str) -> str: + text = (value or "").strip() + text = _UNSAFE_SLUG_RE.sub("_", text) + text = re.sub(r"_+", "_", text).strip("_") + return text or "unnamed" + + +def normalize_wiki_prefix(raw: str | None) -> str: + """Префикс имён wiki-страниц; пустой → default. Всегда заканчивается на `_`.""" + text = (raw or "").strip() + if not text: + return DEFAULT_WIKI_PREFIX + text = _PREFIX_RE.sub("_", text) + text = re.sub(r"_+", "_", text) + if not text.endswith("_"): + text += "_" + return text or DEFAULT_WIKI_PREFIX + + +def index_page_name(prefix: str) -> str: + return f"{normalize_wiki_prefix(prefix)}INDEX.md" + + +def form_wiki_stem( + *, + base_name: str, + type_dir: str, + object_name: str, + form_name: str, + prefix: str | None = None, +) -> str: + type_label = type_dir_label(type_dir) + if type_dir == "CommonForms": + parts = [base_name, type_label, form_name] + else: + parts = [base_name, type_label, object_name, form_name] + slug = "__".join(_sanitize_slug_part(p) for p in parts) + return f"{normalize_wiki_prefix(prefix)}{slug}" + + +def decode_form_xml_path( + form_xml: Path, + config_root: Path, + *, + prefix: str | None = None, +) -> FormRecord | None: + src = resolve_src_root(config_root) + try: + rel = form_xml.resolve().relative_to(src.resolve()) + except ValueError: + return None + + parts = rel.parts + if len(parts) < 4 or parts[-1] != "Form.xml" or parts[-2] != "Ext": + return None + + base_name = config_root.name + if parts[0] == "CommonForms" and len(parts) == 4: + form_name = parts[1] + return FormRecord( + form_xml=form_xml.resolve(), + config_root=config_root.resolve(), + base_name=base_name, + type_dir="CommonForms", + object_name=form_name, + form_name=form_name, + stem=form_wiki_stem( + base_name=base_name, + type_dir="CommonForms", + object_name=form_name, + form_name=form_name, + prefix=prefix, + ), + ) + + if "Forms" not in parts: + return None + forms_idx = parts.index("Forms") + if forms_idx < 1 or forms_idx + 3 >= len(parts): + return None + type_dir = parts[0] + object_name = parts[forms_idx - 1] + form_name = parts[forms_idx + 1] + return FormRecord( + form_xml=form_xml.resolve(), + config_root=config_root.resolve(), + base_name=base_name, + type_dir=type_dir, + object_name=object_name, + form_name=form_name, + stem=form_wiki_stem( + base_name=base_name, + type_dir=type_dir, + object_name=object_name, + form_name=form_name, + prefix=prefix, + ), + ) + + +def discover_forms(config_root: Path, *, prefix: str | None = None) -> list[FormRecord]: + src = resolve_src_root(config_root) + records: dict[str, FormRecord] = {} + for form_xml in sorted(src.rglob("Ext/Form.xml")): + if not form_xml.is_file(): + continue + record = decode_form_xml_path(form_xml, config_root, prefix=prefix) + if record is None: + continue + records[record.stem] = record + return sorted(records.values(), key=lambda x: x.stem.casefold()) + + +def discover_forms_for_bases( + bases: Iterable[str], + *, + search_roots: list[Path] | None = None, + prefix: str | None = None, +) -> list[FormRecord]: + all_records: dict[str, FormRecord] = {} + for base in bases: + config_root = resolve_base_dir(str(base), search_roots) + for record in discover_forms(config_root, prefix=prefix): + if record.stem in all_records: + raise ResolveError( + f"дубликат stem {record.stem!r}: " + f"{all_records[record.stem].form_xml} и {record.form_xml}" + ) + all_records[record.stem] = record + return sorted(all_records.values(), key=lambda x: x.stem.casefold()) + + +def _encode_md_target(target: str) -> str: + """Wiki-ссылка: кириллица без percent-encoding, суффикс .md у страниц убираем.""" + t = target.replace("\\", "/") + if t.lower().endswith(".md"): + t = t[:-3] + return t + + +def _object_label(record: FormRecord) -> str: + if record.type_dir == "CommonForms": + return f"Общие · {record.form_name}" + type_ru = type_dir_label(record.type_dir) + return f"{type_ru} · {record.object_name} · {record.form_name}" + + +def render_wiki_error_md(record: FormRecord, *, error: str) -> str: + page_title = f"{record.base_name} · {_object_label(record)}" + lines = [ + f"# {page_title}", + "", + "**Ошибка:** не удалось сгенерировать HTML-прототип.", + "", + f"```text", + error.strip(), + "```", + "", + f"- **Form.xml:** `{record.form_xml.as_posix()}`", + "", + ] + return "\n".join(lines) + + +def render_wiki_md(record: FormRecord, *, html_name: str, title: str) -> str: + page_title = f"{record.base_name} · {_object_label(record)}" + if title and title not in page_title: + display_title = f"{page_title} — {title}" + else: + display_title = page_title + + html_link = _encode_md_target(html_name) + lines = [ + f"# {display_title}", + "", + f"- **Конфигурация:** `{record.base_name}`", + f"- **Объект:** `{_object_label(record)}`", + f"- **Form.xml:** `{record.form_xml.as_posix()}`", + "", + f"[Открыть HTML]({html_link})", + "", + ( + f'' + ), + "", + ] + return "\n".join(lines) + + +def _build_index(records: list[FormRecord]) -> str: + by_base: dict[str, dict[str, dict[str, list[FormRecord]]]] = {} + for record in records: + by_base.setdefault(record.base_name, {}).setdefault(record.type_dir, {}).setdefault( + record.object_name, [] + ).append(record) + + lines = ["# Прототипы управляемых форм 1С", ""] + lines.append( + "HTML-макеты форм из выгрузки конфигурации. " + "Обновляется инструментом `form_wiki_export.py` без затрагивания других wiki-страниц." + ) + lines.append("") + lines.append(f"**Всего форм:** {len(records)}") + lines.append("") + + for base_name in sorted(by_base.keys(), key=str.casefold): + lines.append(f"## {base_name}") + lines.append("") + type_map = by_base[base_name] + for type_dir in sorted(type_map.keys(), key=str.casefold): + type_ru = type_dir_label(type_dir) + lines.append(f"### {type_ru}") + lines.append("") + object_map = type_map[type_dir] + for object_name in sorted(object_map.keys(), key=str.casefold): + group = sorted(object_map[object_name], key=lambda x: x.form_name.casefold()) + if type_dir != "CommonForms": + lines.append(f"#### {object_name}") + lines.append("") + for record in group: + label = record.form_name if type_dir != "CommonForms" else record.form_name + if record.type_dir != "CommonForms": + link_label = f"{object_name} · {label}" + else: + link_label = label + lines.append( + f"- [{link_label}]({_encode_md_target(record.stem + '.md')})" + ) + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def build_forms_wiki_output( + records: list[FormRecord], + *, + out_dir: Path, + clean: bool = True, + prefix: str | None = None, +) -> FormsWikiStats: + out_dir = out_dir.resolve() + if clean and out_dir.exists(): + for child in out_dir.iterdir(): + if child.name == ".git": + continue + if child.is_dir(): + import shutil + + shutil.rmtree(child) + else: + remove_file(child) + ensure_dir(out_dir) + + managed: set[str] = set() + html_count = 0 + md_count = 0 + skipped = 0 + + for idx, record in enumerate(records, start=1): + html_name = f"{record.stem}.html" + md_name = f"{record.stem}.md" + try: + form = parse_form(record.form_xml) + title = form.get("title") or infer_title_from_path(record.form_xml) + export_form_prototype( + form=form, + output_html=out_dir / html_name, + display_title=f"{record.base_name} · {_object_label(record)}", + ) + write_text( + out_dir / md_name, + render_wiki_md(record, html_name=html_name, title=str(title)), + ) + managed.add(html_name) + html_count += 1 + except Exception as exc: + skipped += 1 + print(f" skip: {record.stem} — {exc}", flush=True) + write_text( + out_dir / md_name, + render_wiki_error_md(record, error=str(exc)), + ) + managed.add(md_name) + md_count += 1 + + if idx % 250 == 0 or idx == len(records): + print(f" rendered: {idx}/{len(records)}", flush=True) + + prefix_norm = normalize_wiki_prefix(prefix) + index_page = index_page_name(prefix_norm) + write_text(out_dir / index_page, _build_index(records)) + managed.add(index_page) + + manifest = { + "tool": "form_wiki_export", + "version": __version__, + "forms": len(records), + "prefix": prefix_norm, + "index": index_page, + "files": sorted(managed | {MANIFEST_NAME}), + "bases": sorted({r.base_name for r in records}), + } + write_text(out_dir / MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + + return FormsWikiStats( + forms_total=len(records), + files_written=len(managed) + 1, + files_deleted=0, + html_files=html_count, + markdown_files=md_count + 1, + skipped=skipped, + ) + + +def _merge_forms_marker_block(existing: str, block: str) -> str: + marker_re = re.compile( + r".*?", + flags=re.DOTALL, + ) + new_block = f"{_EXPORT_FORMS_MARKER_BEGIN}\n{block}\n{_EXPORT_FORMS_MARKER_END}" + if marker_re.search(existing): + return marker_re.sub(new_block, existing) + if existing and not existing.endswith("\n"): + existing += "\n" + if existing and not existing.endswith("\n\n"): + existing += "\n" + return existing + "\n" + new_block + "\n" + + +def ensure_forms_navigation( + *, + work_dir: Path, + forms_count: int, + index_page: str | None = None, +) -> None: + home_path = work_dir / "Home.md" + sidebar_path = work_dir / "_Sidebar.md" + + home_text = home_path.read_text(encoding="utf-8", errors="ignore") if home_path.is_file() else "" + sidebar_text = ( + sidebar_path.read_text(encoding="utf-8", errors="ignore") if sidebar_path.is_file() else "" + ) + + index_page = index_page or index_page_name(DEFAULT_WIKI_PREFIX) + index_target = _encode_md_target(index_page) + index_label = index_page[:-3] if index_page.endswith(".md") else index_page + home_block = "\n".join( + [ + "## Прототипы форм", + "", + f"- [{index_label}]({index_target}) — {forms_count} форм", + ] + ) + sidebar_block = "\n".join( + [ + "### Прототипы форм", + "", + f"- [📋 {index_label}]({index_target})", + "", + ] + ) + + write_text(home_path, _merge_forms_marker_block(home_text, home_block)) + write_text(sidebar_path, _merge_forms_marker_block(sidebar_text, sidebar_block)) + + +def _run_git(cmd: list[str], *, cwd: Path) -> str: + proc = subprocess.run(cmd, cwd=str(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 _wiki_clear_index_flags(work_dir: Path, *names: str) -> None: + for name in names: + if not (work_dir / name).is_file(): + continue + _run_git(["git", "update-index", "--no-assume-unchanged", name], cwd=work_dir) + _run_git(["git", "update-index", "--no-skip-worktree", name], cwd=work_dir) + + +def _git_has_staged_changes(work_dir: Path) -> bool: + proc = subprocess.run( + ["git", "diff", "--cached", "--quiet"], + cwd=str(work_dir), + capture_output=True, + text=True, + check=False, + ) + return proc.returncode != 0 + + +def _git_stage_and_ready(work_dir: Path) -> bool: + _wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md") + _run_git(["git", "add", "-A"], cwd=work_dir) + return _git_has_staged_changes(work_dir) + + +def _load_prev_managed(work_dir: Path) -> set[str]: + p = work_dir / MANIFEST_NAME + if not p.is_file(): + return set() + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + files = data.get("files") or [] + return {str(x) for x in files if isinstance(x, str)} + + +def push_forms_wiki( + *, + wiki_url: str, + out_dir: Path, + work_dir: Path, + message: str, + branch: str = "main", + dry_run: bool = False, +) -> FormsWikiStats: + out_dir = out_dir.resolve() + work_dir = work_dir.resolve() + prepare_wiki_clone(wiki_url, work_dir, branch=branch) + + manifest_path = out_dir / MANIFEST_NAME + if not manifest_path.is_file(): + raise WikiPushError(f"missing manifest in output: {manifest_path}") + new_data = json.loads(manifest_path.read_text(encoding="utf-8")) + new_files = {str(x) for x in (new_data.get("files") or [])} + prev_files = _load_prev_managed(work_dir) + + deleted = 0 + for rel in sorted(prev_files - new_files): + if rel in _RESERVED_ROOT_PAGES: + continue + p = work_dir / rel + if p.is_file(): + remove_file(p) + deleted += 1 + + for rel in sorted(new_files): + src = out_dir / rel + if src.is_file(): + copy_file(src, work_dir / rel) + + forms_count = int(new_data.get("forms") or 0) + index_page = str(new_data.get("index") or index_page_name(new_data.get("prefix"))) + ensure_forms_navigation( + work_dir=work_dir, + forms_count=forms_count, + index_page=index_page, + ) + + if dry_run: + _wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md") + _run_git(["git", "add", "-A"], cwd=work_dir) + return FormsWikiStats( + forms_total=forms_count, + files_written=len(new_files), + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + if not _git_stage_and_ready(work_dir): + return FormsWikiStats( + forms_total=forms_count, + files_written=len(new_files), + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + _run_git(["git", "commit", "-m", message], cwd=work_dir) + _run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir) + return FormsWikiStats( + forms_total=forms_count, + files_written=len(new_files), + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + +def clean_forms_wiki( + *, + wiki_url: str, + work_dir: Path, + message: str, + branch: str = "main", + dry_run: bool = False, +) -> FormsWikiStats: + work_dir = work_dir.resolve() + prepare_wiki_clone(wiki_url, work_dir, branch=branch) + + prev_files = _load_prev_managed(work_dir) + deleted = 0 + for rel in sorted(prev_files): + if rel in _RESERVED_ROOT_PAGES: + continue + p = work_dir / rel + if p.is_file(): + remove_file(p) + deleted += 1 + + manifest_path = work_dir / MANIFEST_NAME + if manifest_path.is_file(): + remove_file(manifest_path) + + for name in ("Home.md", "_Sidebar.md"): + p = work_dir / name + if not p.is_file(): + continue + raw = p.read_text(encoding="utf-8", errors="ignore") + cleaned = re.sub( + r".*?\n?", + "", + raw, + flags=re.DOTALL, + ).rstrip() + cleaned = cleaned + "\n" if cleaned else "" + if cleaned != raw: + write_text(p, cleaned) + + if dry_run: + _wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md") + _run_git(["git", "add", "-A"], cwd=work_dir) + return FormsWikiStats( + forms_total=0, + files_written=0, + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + if not _git_stage_and_ready(work_dir): + return FormsWikiStats( + forms_total=0, + files_written=0, + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + _run_git(["git", "commit", "-m", message], cwd=work_dir) + _run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir) + return FormsWikiStats( + forms_total=0, + files_written=0, + files_deleted=deleted, + html_files=0, + markdown_files=0, + ) + + +def load_form_wiki_settings(path: Path) -> dict: + data = load_settings_file(path) + bases = data.get("bases") + if bases is None and data.get("base"): + bases = [data["base"]] + if not isinstance(bases, list) or not bases: + raise ResolveError("в конфиге нужен bases: [...] или base: ...") + data["bases"] = [str(x) for x in bases] + prefix_raw = data.get("wiki_prefix") or data.get("prefix") + data["wiki_prefix"] = normalize_wiki_prefix( + str(prefix_raw) if prefix_raw is not None else None + ) + return data + + +def resolve_search_roots(settings: dict, config_path: Path | None) -> list[Path]: + search_roots = default_search_roots(config_path) + extra_roots = settings.get("search_roots") or [] + if isinstance(extra_roots, list): + base_for_rel = config_path.parent if config_path else Path.cwd() + for item in extra_roots: + p = Path(str(item)).expanduser() + if not p.is_absolute(): + p = (base_for_rel / p).resolve() + search_roots.insert(0, p) + return search_roots diff --git a/form_wiki_export.py b/form_wiki_export.py new file mode 100644 index 0000000..bc15a52 --- /dev/null +++ b/form_wiki_export.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""CLI: batch HTML prototypes of 1C forms → Gitea wiki (form_*.md + attachments). + +Примеры: + python3 form_wiki_export.py push -b crm3-26.rhana --dry-run + python3 form_wiki_export.py push -c configs/form_wiki.crm3-26.json + python3 form_wiki_export.py build -b crm3-26 -b crm3-26.rhana -o out/form-wiki + python3 form_wiki_export.py clean --wiki-url https://git.p7net.ru/1c/crm3_26.wiki.git +""" + +from __future__ import annotations + +import argparse +import json +import re +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.form_wiki_export import ( # noqa: E402 + DEFAULT_WIKI_PREFIX, + build_forms_wiki_output, + clean_forms_wiki, + discover_forms_for_bases, + load_form_wiki_settings, + normalize_wiki_prefix, + push_forms_wiki, + resolve_search_roots, +) +from export1c_help.resolve import ( # noqa: E402 + ResolveError, + resolve_wiki_url, + slug_for_path, + to_wiki_clone_url, +) +from export1c_help.wiki import WikiPushError # noqa: E402 + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="form_wiki_export", + description=( + f"Полный обход Form.xml → HTML + wiki-страницы form_*. " + f"v{__version__} ({__status__})." + ), + ) + p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + sub = p.add_subparsers(dest="cmd", required=True) + + build = sub.add_parser("build", help="Собрать form_*.md/html локально без push") + _add_common_args(build) + build.set_defaults(func=cmd_build) + + push = sub.add_parser("push", help="Собрать и выгрузить form_* в wiki") + _add_common_args(push) + push.add_argument("--dry-run", action="store_true", help="Без git push") + push.set_defaults(func=cmd_push) + + clean = sub.add_parser("clean", help="Удалить form_* из wiki по manifest.export_forms.json") + _add_wiki_args(clean) + clean.add_argument("--dry-run", action="store_true") + clean.set_defaults(func=cmd_clean) + return p + + +def _add_wiki_args(p: argparse.ArgumentParser) -> None: + g = p.add_mutually_exclusive_group(required=False) + g.add_argument("-c", "--config", type=Path, help="JSON: bases, search_roots, wiki_url, out_path, wiki_prefix") + g.add_argument("-b", "--base", action="append", help="Конфигурация (можно несколько раз)") + p.add_argument("--wiki-url", default=None, help="Явный URL wiki git") + p.add_argument( + "--wiki-prefix", + default=None, + help=f"Префикс имён wiki-страниц (по умолчанию {DEFAULT_WIKI_PREFIX!r}; " + "переопределяет wiki_prefix из JSON)", + ) + p.add_argument("--remote", default="origin") + p.add_argument("--work-dir", type=Path, default=None) + p.add_argument("-o", "--out", type=Path, default=None, help="Каталог сборки") + p.add_argument("--branch", default="main") + p.add_argument("-m", "--message", default=None) + + +def _add_common_args(p: argparse.ArgumentParser) -> None: + _add_wiki_args(p) + + +def _load_settings(args: argparse.Namespace, *, require_bases: bool = True) -> tuple[dict, Path | None]: + config_path: Path | None = None + settings: dict = {} + if args.config: + settings = load_form_wiki_settings(Path(args.config)) + config_path = Path(settings["_config_path"]) + bases = args.base or settings.get("bases") or [] + if require_bases and not bases: + raise ResolveError("нужен -b/--base (можно несколько) или bases/base в -c/--config") + settings["bases"] = [str(x) for x in bases] + cli_prefix = getattr(args, "wiki_prefix", None) + if cli_prefix: + settings["wiki_prefix"] = normalize_wiki_prefix(cli_prefix) + else: + settings["wiki_prefix"] = normalize_wiki_prefix(settings.get("wiki_prefix")) + return settings, config_path + + +def _resolve_out_dir(args: argparse.Namespace, settings: dict, slug: str) -> Path: + raw = args.out or settings.get("out_path") or settings.get("out") + if raw: + path = Path(str(raw)).expanduser() + if not path.is_absolute(): + return (Path.cwd() / path).resolve() + return path.resolve() + return (TOOL_ROOT / "out" / f"form-wiki-{slug}").resolve() + + +def _resolve_wiki_url( + args: argparse.Namespace, + settings: dict, + *, + first_base: str, + search_roots: list[Path], +) -> tuple[str, str]: + if args.wiki_url or settings.get("wiki_url"): + wiki_url = to_wiki_clone_url(str(args.wiki_url or settings.get("wiki_url"))) + slug = re.sub(r"[^\w.\-]+", "-", first_base, flags=re.UNICODE).strip("-") or "forms" + return wiki_url, slug + + from export1c_help.form_prototype import resolve_base_dir + + config_root = resolve_base_dir(first_base, search_roots) + wiki_url = resolve_wiki_url(config_root, wiki_url=None, remote=args.remote) + slug = slug_for_path(config_root) + return wiki_url, slug + + +def cmd_build(args: argparse.Namespace) -> int: + try: + settings, config_path = _load_settings(args) + search_roots = resolve_search_roots(settings, config_path) + wiki_url, slug = _resolve_wiki_url( + args, settings, first_base=settings["bases"][0], search_roots=search_roots + ) + records = discover_forms_for_bases( + settings["bases"], search_roots=search_roots, prefix=settings["wiki_prefix"] + ) + except (ResolveError, OSError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + out = _resolve_out_dir(args, settings, slug) + print(f"bases: {', '.join(settings['bases'])}") + print(f"prefix: {settings['wiki_prefix']}") + print(f"forms: {len(records)}") + print(f"out: {out}") + print(f"wiki_url: {wiki_url}") + + stats = build_forms_wiki_output( + records, out_dir=out, clean=True, prefix=settings["wiki_prefix"] + ) + skip_note = f", skipped={stats.skipped}" if stats.skipped else "" + print( + f"OK: built forms={stats.forms_total}, md={stats.markdown_files}, " + f"html={stats.html_files}, files={stats.files_written}{skip_note} → {out}" + ) + return 0 + + +def cmd_push(args: argparse.Namespace) -> int: + try: + settings, config_path = _load_settings(args) + search_roots = resolve_search_roots(settings, config_path) + wiki_url, slug = _resolve_wiki_url( + args, settings, first_base=settings["bases"][0], search_roots=search_roots + ) + records = discover_forms_for_bases( + settings["bases"], search_roots=search_roots, prefix=settings["wiki_prefix"] + ) + except (ResolveError, OSError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + out = _resolve_out_dir(args, settings, slug) + work = (args.work_dir or (TOOL_ROOT / "out" / f"wiki_clone-{slug}")).resolve() + + print(f"bases: {', '.join(settings['bases'])}") + print(f"prefix: {settings['wiki_prefix']}") + print(f"forms: {len(records)}") + print(f"out: {out}") + print(f"work: {work}") + print(f"wiki_url: {wiki_url}") + + build_stats = build_forms_wiki_output( + records, out_dir=out, clean=True, prefix=settings["wiki_prefix"] + ) + skip_note = f", skipped={build_stats.skipped}" if build_stats.skipped else "" + print( + f"build: forms={build_stats.forms_total}, md={build_stats.markdown_files}, " + f"html={build_stats.html_files}, files={build_stats.files_written}{skip_note}" + ) + + msg = args.message or ( + f"form_wiki_export v{__version__}: sync {build_stats.forms_total} forms " + f"({build_stats.files_written} files)" + ) + try: + push_stats = push_forms_wiki( + wiki_url=wiki_url, + out_dir=out, + work_dir=work, + message=msg, + branch=args.branch, + dry_run=args.dry_run, + ) + except WikiPushError as exc: + print(f"push failed:\n{exc}", file=sys.stderr) + return 1 + + if args.dry_run: + print( + f"dry-run: files={push_stats.files_written}, deleted={push_stats.files_deleted}; " + "push skipped" + ) + return 0 + + print( + f"OK: pushed forms={push_stats.forms_total}, files={push_stats.files_written}, " + f"deleted={push_stats.files_deleted} → {wiki_url}" + ) + return 0 + + +def cmd_clean(args: argparse.Namespace) -> int: + try: + if args.wiki_url: + wiki_url = to_wiki_clone_url(args.wiki_url) + slug = "forms" + else: + settings, config_path = _load_settings(args, require_bases=True) + search_roots = resolve_search_roots(settings, config_path) + wiki_url, slug = _resolve_wiki_url( + args, settings, first_base=settings["bases"][0], search_roots=search_roots + ) + except (ResolveError, OSError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + work = (args.work_dir or (TOOL_ROOT / "out" / f"wiki_clone-{slug}")).resolve() + print(f"wiki_url: {wiki_url}") + print(f"work: {work}") + + msg = args.message or f"form_wiki_export v{__version__}: cleanup form_* pages" + try: + stats = clean_forms_wiki( + wiki_url=wiki_url, + work_dir=work, + message=msg, + branch=args.branch, + dry_run=args.dry_run, + ) + except WikiPushError as exc: + print(f"clean failed:\n{exc}", file=sys.stderr) + return 1 + + if args.dry_run: + print(f"dry-run: would delete={stats.files_deleted}; push skipped") + return 0 + print(f"OK: cleaned, deleted={stats.files_deleted} → {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())