Release 0.5.0: form_wiki_export — выгрузка HTML-форм в Gitea wiki.
Полный обход Form.xml, настраиваемый wiki_prefix, русские типы в именах, ссылки без percent-encoding. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_forms:begin/end -->` (не пересекаются
|
||||
с `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/`
|
||||
|
||||
@@ -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_"
|
||||
}
|
||||
@@ -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_"
|
||||
}
|
||||
@@ -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:begin -->"
|
||||
_EXPORT_FORMS_MARKER_END = "<!-- export_forms: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'<iframe src="{html_name}" width="100%" height="900" '
|
||||
f'style="border:1px solid #ddd; max-width:100%;"></iframe>'
|
||||
),
|
||||
"",
|
||||
]
|
||||
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"<!--\s*export_forms:begin\s*-->.*?<!--\s*export_forms:end\s*-->",
|
||||
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"<!--\s*export_forms:begin\s*-->.*?<!--\s*export_forms:end\s*-->\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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user