31659d4287
Render _Sidebar entries in DFS order instead of sorting by depth globally. Co-authored-by: Cursor <cursoragent@cursor.com>
912 lines
30 KiB
Python
912 lines
30 KiB
Python
"""Export arbitrary markdown docs to wiki subfolders."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from urllib.parse import quote
|
|
|
|
from export1c_help import __version__
|
|
from export1c_help.fscompat import copy_file, ensure_dir, remove_file, write_text
|
|
from export1c_help.wiki import WikiPushError, prepare_wiki_clone
|
|
|
|
_MD_LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^)]+)(\))")
|
|
_MD_REF_DEF_RE = re.compile(
|
|
r"^(?P<prefix>\s*\[[^\]]+\]:\s*)(?P<target><[^>]+>|\S+)(?P<suffix>.*)$",
|
|
re.MULTILINE,
|
|
)
|
|
_URL_SCHEMES = ("http://", "https://", "mailto:", "ftp://")
|
|
|
|
_EXPORT_DOCS_MARKER_BEGIN = "<!-- export_docs:begin -->"
|
|
_EXPORT_DOCS_MARKER_END = "<!-- export_docs:end -->"
|
|
_RESERVED_ROOT_PAGES = {"Home.md", "History.md", "_Sidebar.md"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RepoLinkContext:
|
|
"""Rewrite links to repo files (outside wiki export) as Gitea src/ URLs."""
|
|
|
|
root: Path
|
|
web_base: str
|
|
branch: str = "main"
|
|
|
|
|
|
def _git_web_base(remote_url: str) -> str:
|
|
u = remote_url.strip().rstrip("/")
|
|
if u.lower().endswith(".git"):
|
|
return u[:-4]
|
|
return u
|
|
|
|
|
|
def _repo_src_url(ctx: RepoLinkContext, abs_path: Path) -> str | None:
|
|
try:
|
|
rel = abs_path.resolve().relative_to(ctx.root.resolve())
|
|
except ValueError:
|
|
return None
|
|
return f"{ctx.web_base.rstrip('/')}/src/branch/{ctx.branch}/{rel.as_posix()}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DocItem:
|
|
src: Path
|
|
src_rel: Path
|
|
dst_rel: Path
|
|
pattern_root: Path
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DocsStats:
|
|
files_total: int
|
|
files_written: int
|
|
files_deleted: int
|
|
markdown_files: int
|
|
attachment_files: int
|
|
folders: dict[str, tuple[int, int]]
|
|
|
|
|
|
def _has_glob(s: str) -> bool:
|
|
return any(ch in s for ch in ("*", "?", "["))
|
|
|
|
|
|
def _static_prefix(pattern: str) -> Path:
|
|
path = Path(pattern)
|
|
parts = list(path.parts)
|
|
keep: list[str] = []
|
|
for part in parts:
|
|
if _has_glob(part):
|
|
break
|
|
keep.append(part)
|
|
if not keep:
|
|
return Path.cwd()
|
|
return Path(*keep)
|
|
|
|
|
|
def _iter_markdown_files(raw_pattern: str) -> list[Path]:
|
|
p = Path(raw_pattern)
|
|
if p.is_file():
|
|
return [p.resolve()] if p.suffix.lower() == ".md" else []
|
|
if p.is_dir():
|
|
return sorted(x.resolve() for x in p.rglob("*.md") if x.is_file())
|
|
matched = [Path(x).resolve() for x in glob.glob(raw_pattern, recursive=True)]
|
|
return sorted(x for x in matched if x.is_file() and x.suffix.lower() == ".md")
|
|
|
|
|
|
def collect_docs(files_patterns: list[str], *, folder: str) -> list[DocItem]:
|
|
if not files_patterns:
|
|
raise ValueError("no --files patterns supplied")
|
|
folder_clean = folder.strip().strip("/").strip("\\")
|
|
dst_root = Path(folder_clean) if folder_clean else Path(".")
|
|
|
|
explicit_files = [
|
|
Path(raw).resolve()
|
|
for raw in files_patterns
|
|
if not _has_glob(raw) and Path(raw).is_file()
|
|
]
|
|
explicit_root: Path | None = None
|
|
if explicit_files:
|
|
common = os.path.commonpath([str(p) for p in explicit_files])
|
|
common_path = Path(common)
|
|
explicit_root = common_path if common_path.is_dir() else common_path.parent
|
|
|
|
items: dict[Path, DocItem] = {}
|
|
for raw in files_patterns:
|
|
raw_path = Path(raw)
|
|
if explicit_root is not None and not _has_glob(raw) and raw_path.is_file():
|
|
root = explicit_root
|
|
else:
|
|
root = _static_prefix(raw).resolve()
|
|
for src in _iter_markdown_files(raw):
|
|
try:
|
|
src_rel = src.relative_to(root)
|
|
except ValueError:
|
|
src_rel = Path(src.name)
|
|
dst_rel = dst_root / src_rel
|
|
items[src] = DocItem(
|
|
src=src,
|
|
src_rel=src_rel,
|
|
dst_rel=dst_rel,
|
|
pattern_root=root,
|
|
)
|
|
return sorted(items.values(), key=lambda x: x.src.as_posix())
|
|
|
|
|
|
def _split_target(value: str) -> tuple[str, str]:
|
|
s = value.strip()
|
|
if not s:
|
|
return "", ""
|
|
if s.startswith("<"):
|
|
idx = s.find(">")
|
|
if idx > 0:
|
|
return s[1:idx], s[idx + 1 :]
|
|
parts = s.split(maxsplit=1)
|
|
if len(parts) == 1:
|
|
return parts[0], ""
|
|
return parts[0], " " + parts[1]
|
|
|
|
|
|
def _rewrite_links(
|
|
text: str,
|
|
*,
|
|
src: Path,
|
|
dst_rel: Path,
|
|
src_to_md_dst: dict[Path, Path],
|
|
map_src_abs_to_dst_rel,
|
|
attachments: set[Path],
|
|
repo: RepoLinkContext | None = None,
|
|
) -> str:
|
|
def repl(m: re.Match[str]) -> str:
|
|
left, raw_target, right = m.group(1), m.group(2), m.group(3)
|
|
target, tail = _split_target(raw_target)
|
|
if not target:
|
|
return m.group(0)
|
|
if target.startswith(_URL_SCHEMES) or target.startswith(("#", "/")):
|
|
return m.group(0)
|
|
|
|
anchor = ""
|
|
base = target
|
|
if "#" in target:
|
|
base, anchor = target.split("#", 1)
|
|
anchor = "#" + anchor
|
|
if not base:
|
|
return m.group(0)
|
|
|
|
cand = (src.parent / base).resolve()
|
|
dst_md_target = src_to_md_dst.get(cand)
|
|
if dst_md_target is None and Path(base).suffix == "":
|
|
dst_md_target = src_to_md_dst.get(cand.with_suffix(".md"))
|
|
|
|
if dst_md_target is not None:
|
|
dst_target_rel = dst_md_target
|
|
else:
|
|
if not cand.is_file():
|
|
return m.group(0)
|
|
if repo is not None:
|
|
src_url = _repo_src_url(repo, cand)
|
|
if src_url:
|
|
return f"{left}{src_url}{anchor}{tail}{right}"
|
|
dst_target_rel = map_src_abs_to_dst_rel(cand)
|
|
if dst_target_rel is None:
|
|
return m.group(0)
|
|
attachments.add(cand)
|
|
|
|
rel = os.path.relpath(
|
|
dst_target_rel.as_posix(), start=dst_rel.parent.as_posix()
|
|
)
|
|
rel = rel.replace("\\", "/")
|
|
return f"{left}{_encode_md_target(rel)}{anchor}{tail}{right}"
|
|
|
|
out = _MD_LINK_RE.sub(repl, text)
|
|
|
|
def repl_def(m: re.Match[str]) -> str:
|
|
prefix = m.group("prefix")
|
|
raw_target = m.group("target")
|
|
suffix = m.group("suffix") or ""
|
|
target = raw_target.strip()
|
|
if target.startswith("<") and target.endswith(">"):
|
|
target = target[1:-1].strip()
|
|
|
|
if not target:
|
|
return m.group(0)
|
|
if target.startswith(_URL_SCHEMES) or target.startswith(("#", "/")):
|
|
return m.group(0)
|
|
if "#" in target:
|
|
base, anchor = target.split("#", 1)
|
|
anchor = "#" + anchor
|
|
else:
|
|
base, anchor = target, ""
|
|
|
|
if not base:
|
|
return m.group(0)
|
|
|
|
cand = (src.parent / base).resolve()
|
|
dst_md_target = src_to_md_dst.get(cand)
|
|
if dst_md_target is None and Path(base).suffix == "":
|
|
dst_md_target = src_to_md_dst.get(cand.with_suffix(".md"))
|
|
|
|
if dst_md_target is not None:
|
|
dst_target_rel = dst_md_target
|
|
else:
|
|
if not cand.is_file():
|
|
return m.group(0)
|
|
if repo is not None:
|
|
src_url = _repo_src_url(repo, cand)
|
|
if src_url:
|
|
return f"{prefix}{src_url}{anchor}{suffix}"
|
|
dst_target_rel = map_src_abs_to_dst_rel(cand)
|
|
if dst_target_rel is None:
|
|
return m.group(0)
|
|
attachments.add(cand)
|
|
|
|
rel = os.path.relpath(dst_target_rel.as_posix(), start=dst_rel.parent.as_posix())
|
|
rel = rel.replace("\\", "/")
|
|
return f"{prefix}{_encode_md_target(rel)}{anchor}{suffix}"
|
|
|
|
return _MD_REF_DEF_RE.sub(repl_def, out)
|
|
|
|
|
|
def _manifest_name(folder: str) -> str:
|
|
key = folder.strip().strip("/").strip("\\") or "root"
|
|
digest = hashlib.sha1(key.encode("utf-8")).hexdigest()[:8]
|
|
return f"manifest.export_docs.{digest}.json"
|
|
|
|
|
|
def _flatten_rel_path(rel: Path) -> Path:
|
|
parts = [p for p in rel.as_posix().split("/") if p and p != "."]
|
|
if not parts:
|
|
return Path("page.md")
|
|
name = "__".join(parts)
|
|
return Path(name)
|
|
|
|
|
|
def _extract_doc_title(text: str, fallback_stem: str) -> str:
|
|
m = re.search(r"^\s*#\s+(.+?)\s*$", text, flags=re.MULTILINE)
|
|
if m:
|
|
return m.group(1).strip()
|
|
fallback = fallback_stem.replace("_", " ").strip()
|
|
return fallback or "Untitled"
|
|
|
|
|
|
def _target_rel_for(rel: Path, *, flat_root: bool) -> Path:
|
|
return _flatten_rel_path(rel) if flat_root else rel
|
|
|
|
|
|
def _encode_md_target(target: str) -> str:
|
|
# Gitea wiki page URLs omit the `.md` suffix (file `foo.md` → page `/wiki/foo`).
|
|
t = target.replace("\\", "/")
|
|
if t.lower().endswith(".md"):
|
|
t = t[:-3]
|
|
return quote(t, safe="/#._-~")
|
|
|
|
|
|
def _is_folder_index_src(src: Path) -> bool:
|
|
return src.name.casefold() == "index.md"
|
|
|
|
|
|
def _strip_leading_h1(text: str) -> str:
|
|
return re.sub(r"^\s*#\s+.+?\n+", "", text, count=1, flags=re.MULTILINE).lstrip()
|
|
|
|
|
|
def _folder_label(folder_rel: Path, folder_clean: str) -> str:
|
|
if folder_rel == Path("."):
|
|
return folder_clean or "root"
|
|
return folder_rel.as_posix()
|
|
|
|
|
|
def _folder_short_name(folder_rel: Path, folder_clean: str) -> str:
|
|
if folder_rel == Path("."):
|
|
return folder_clean or "root"
|
|
return folder_rel.name or folder_rel.as_posix()
|
|
|
|
|
|
def _rel_link(from_rel: Path, to_rel: Path) -> str:
|
|
rel = os.path.relpath(to_rel.as_posix(), start=from_rel.parent.as_posix())
|
|
return _encode_md_target(rel.replace("\\", "/"))
|
|
|
|
|
|
def _merge_marker_block(existing: str, block: str) -> str:
|
|
import re
|
|
|
|
# Be tolerant: allow whitespace differences around markers.
|
|
marker_re = re.compile(
|
|
r"<!--\s*export_docs:begin\s*-->.*?<!--\s*export_docs:end\s*-->",
|
|
flags=re.DOTALL,
|
|
)
|
|
new_block = f"{_EXPORT_DOCS_MARKER_BEGIN}\n{block}\n{_EXPORT_DOCS_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 _docs_index_rel(folder: str, managed_files: set[str]) -> str | None:
|
|
folder_clean = folder.strip().strip("/").strip("\\")
|
|
if folder_clean:
|
|
cand = f"{folder_clean}/INDEX.md"
|
|
else:
|
|
cand = "INDEX.md"
|
|
if cand in managed_files:
|
|
return cand
|
|
flat = _flatten_rel_path(Path(cand)).as_posix()
|
|
if flat in managed_files:
|
|
return flat
|
|
return None
|
|
|
|
|
|
def _sidebar_folder_links(folder: str, managed_files: set[str]) -> list[tuple[int, str, str]]:
|
|
"""
|
|
Return (depth, label, target) for folder index pages in wiki, in tree order.
|
|
|
|
Supports both:
|
|
- nested mode: docs/sub/INDEX.md
|
|
- flat mode: docs__sub__INDEX.md
|
|
"""
|
|
folder_clean = folder.strip().strip("/").strip("\\")
|
|
if not folder_clean:
|
|
return []
|
|
|
|
target_by_folder: dict[str, str] = {}
|
|
|
|
for rel in managed_files:
|
|
if not rel.endswith("/INDEX.md"):
|
|
continue
|
|
if rel.startswith(folder_clean + "/"):
|
|
folder_rel = rel[: -len("/INDEX.md")]
|
|
target_by_folder[folder_rel] = rel
|
|
|
|
root_flat = f"{folder_clean}__INDEX.md"
|
|
if root_flat in managed_files:
|
|
target_by_folder[folder_clean] = root_flat
|
|
prefix = f"{folder_clean}__"
|
|
suffix = "__INDEX.md"
|
|
for rel in managed_files:
|
|
if not rel.startswith(prefix) or not rel.endswith(suffix) or rel == root_flat:
|
|
continue
|
|
mid = rel[len(prefix) : -len(suffix)]
|
|
if not mid:
|
|
continue
|
|
folder_rel = folder_clean + "/" + mid.replace("__", "/")
|
|
target_by_folder[folder_rel] = rel
|
|
|
|
if not target_by_folder:
|
|
return []
|
|
|
|
children: dict[str, list[str]] = {}
|
|
for folder_rel in target_by_folder:
|
|
if folder_rel == folder_clean:
|
|
continue
|
|
parent = Path(folder_rel).parent.as_posix()
|
|
if parent != folder_clean and not parent.startswith(folder_clean + "/"):
|
|
parent = folder_clean
|
|
children.setdefault(parent, []).append(folder_rel)
|
|
|
|
ordered: list[tuple[int, str, str]] = []
|
|
|
|
def walk(parent: str, depth: int) -> None:
|
|
for child in sorted(children.get(parent, []), key=lambda x: x.casefold()):
|
|
ordered.append((depth, child, target_by_folder[child]))
|
|
walk(child, depth + 1)
|
|
|
|
if folder_clean in target_by_folder:
|
|
ordered.append((0, folder_clean, target_by_folder[folder_clean]))
|
|
walk(folder_clean, 1)
|
|
else:
|
|
walk(folder_clean, 0)
|
|
|
|
return ordered
|
|
|
|
|
|
def ensure_root_navigation(
|
|
*,
|
|
work_dir: Path,
|
|
folder: str,
|
|
managed_files: set[str],
|
|
) -> None:
|
|
index_rel = _docs_index_rel(folder, managed_files)
|
|
|
|
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 ""
|
|
)
|
|
|
|
if index_rel:
|
|
# Gitea wiki page URLs typically omit the `.md` extension:
|
|
# file `docs/INDEX.md` → page `/wiki/docs/INDEX`.
|
|
link_target = index_rel[:-3] if index_rel.endswith(".md") else index_rel
|
|
# For nested wiki paths, INDEX is often addressed as lowercase `index`.
|
|
if "/" in link_target:
|
|
parts = link_target.split("/")
|
|
parts[-1] = parts[-1].lower()
|
|
link_target = "/".join(parts)
|
|
display = link_target
|
|
home_block = "\n".join(
|
|
[
|
|
"## Документация (docs)",
|
|
"",
|
|
f"- [{display}]({_encode_md_target(link_target)})",
|
|
]
|
|
)
|
|
tree = _sidebar_folder_links(folder, managed_files)
|
|
tree_lines: list[str] = []
|
|
for depth, label, target in tree:
|
|
indent = " " * depth
|
|
short = Path(label).name or label
|
|
tree_lines.append(
|
|
f"{indent}- [📁 {short}]({_encode_md_target(target)})"
|
|
)
|
|
if not tree_lines:
|
|
root_short = Path(link_target).name or display
|
|
tree_lines = [f"- [📁 {root_short}]({_encode_md_target(link_target)})"]
|
|
sidebar_block = "\n".join(
|
|
[
|
|
"### Документация (docs)",
|
|
"",
|
|
*tree_lines,
|
|
"",
|
|
]
|
|
)
|
|
else:
|
|
home_block = "\n".join(["## Документация (docs)", "", "_INDEX.md в этой выгрузке не найден._"])
|
|
sidebar_block = "\n".join(["### Документация (docs)", "", "_INDEX.md в этой выгрузке не найден._", ""])
|
|
|
|
home_text = _merge_marker_block(home_text, home_block)
|
|
sidebar_text = _merge_marker_block(sidebar_text, sidebar_block)
|
|
|
|
write_text(home_path, home_text)
|
|
write_text(sidebar_path, sidebar_text)
|
|
|
|
|
|
def build_docs_output(
|
|
items: list[DocItem],
|
|
*,
|
|
out_dir: Path,
|
|
folder: str,
|
|
flat_root: bool = True,
|
|
clean: bool = True,
|
|
repo: RepoLinkContext | None = None,
|
|
) -> tuple[DocsStats, str]:
|
|
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)
|
|
|
|
folder_clean = folder.strip().strip("/").strip("\\")
|
|
dst_root = Path(folder_clean) if folder_clean else Path(".")
|
|
pattern_roots = sorted({x.pattern_root.resolve() for x in items})
|
|
|
|
# Avoid clobbering export_1c_help navigation pages when publishing into wiki root.
|
|
items_ok: list[DocItem] = []
|
|
for item in items:
|
|
if (
|
|
not folder_clean
|
|
and item.dst_rel.parent == Path(".")
|
|
and item.dst_rel.name in _RESERVED_ROOT_PAGES
|
|
):
|
|
continue
|
|
items_ok.append(item)
|
|
|
|
def map_src_abs_to_dst_rel(abs_src: Path) -> Path | None:
|
|
abs_src = abs_src.resolve()
|
|
best_root: Path | None = None
|
|
best_rel: Path | None = None
|
|
for r in pattern_roots:
|
|
try:
|
|
rel = abs_src.relative_to(r)
|
|
except ValueError:
|
|
continue
|
|
if best_root is None or len(r.as_posix()) > len(best_root.as_posix()):
|
|
best_root = r
|
|
best_rel = rel
|
|
if best_root is None or best_rel is None:
|
|
return None
|
|
return dst_root / best_rel
|
|
|
|
md_dst_by_src: dict[Path, Path] = {}
|
|
title_by_src: dict[Path, str] = {}
|
|
for x in items_ok:
|
|
rel = x.dst_rel
|
|
rel = _target_rel_for(rel, flat_root=flat_root)
|
|
md_dst_by_src[x.src.resolve()] = rel
|
|
|
|
src_to_md_dst = md_dst_by_src
|
|
attachments: set[Path] = set()
|
|
managed_rels: set[Path] = set(src_to_md_dst.values())
|
|
folder_docs: dict[Path, list[tuple[str, Path]]] = {}
|
|
index_body_by_folder: dict[Path, str] = {}
|
|
index_src_by_folder: dict[Path, Path] = {}
|
|
for item in items_ok:
|
|
raw = item.src.read_text(encoding="utf-8-sig", errors="ignore")
|
|
title_by_src[item.src.resolve()] = _extract_doc_title(raw, item.src.stem)
|
|
dst_rel = src_to_md_dst[item.src.resolve()]
|
|
src_folder = item.dst_rel.parent
|
|
if _is_folder_index_src(item.src):
|
|
index_body_by_folder[src_folder] = raw
|
|
index_src_by_folder[src_folder] = item.src
|
|
continue
|
|
rendered = _rewrite_links(
|
|
raw,
|
|
src=item.src,
|
|
dst_rel=dst_rel,
|
|
src_to_md_dst=src_to_md_dst,
|
|
map_src_abs_to_dst_rel=map_src_abs_to_dst_rel,
|
|
attachments=attachments,
|
|
repo=repo,
|
|
)
|
|
folder_index_rel = _target_rel_for(
|
|
(src_folder / "INDEX.md") if src_folder != Path(".") else Path("INDEX.md"),
|
|
flat_root=flat_root,
|
|
)
|
|
short = _folder_short_name(src_folder, folder_clean)
|
|
nav = "\n".join(
|
|
[
|
|
f"## 📁 {short}",
|
|
"",
|
|
f"[⤴️ Вернуться в {short}]({_rel_link(dst_rel, folder_index_rel)})",
|
|
"",
|
|
]
|
|
)
|
|
rendered = nav + rendered.lstrip()
|
|
write_text(out_dir / dst_rel, rendered)
|
|
folder_docs.setdefault(src_folder, []).append(
|
|
(title_by_src[item.src.resolve()], dst_rel)
|
|
)
|
|
|
|
for abs_src in sorted(attachments, key=lambda p: p.as_posix()):
|
|
dst_rel = map_src_abs_to_dst_rel(abs_src)
|
|
if dst_rel is None:
|
|
continue
|
|
dst_rel = _target_rel_for(dst_rel, flat_root=flat_root)
|
|
if dst_rel not in managed_rels:
|
|
managed_rels.add(dst_rel)
|
|
copy_file(abs_src, out_dir / dst_rel)
|
|
|
|
# Build per-folder index pages with links to articles and child folders.
|
|
all_folders = sorted(
|
|
set(folder_docs.keys()) | set(index_body_by_folder.keys()),
|
|
key=lambda p: p.as_posix(),
|
|
)
|
|
child_folders: dict[Path, set[Path]] = {}
|
|
folder_set = set(all_folders)
|
|
for f in all_folders:
|
|
parent = f.parent
|
|
while parent != Path(".") and parent not in folder_set:
|
|
parent = parent.parent
|
|
if parent in folder_set and parent != f:
|
|
child_folders.setdefault(parent, set()).add(f)
|
|
if f.parent == Path(".") and Path(".") in folder_set and f != Path("."):
|
|
child_folders.setdefault(Path("."), set()).add(f)
|
|
|
|
index_target_by_folder: dict[Path, Path] = {}
|
|
for f in all_folders:
|
|
idx_rel = (f / "INDEX.md") if f != Path(".") else Path("INDEX.md")
|
|
index_target_by_folder[f] = _target_rel_for(idx_rel, flat_root=flat_root)
|
|
|
|
for f in all_folders:
|
|
lines: list[str] = []
|
|
heading = _folder_label(f, folder_clean)
|
|
lines.append(f"# Индекс: {heading}")
|
|
lines.append("")
|
|
|
|
if f != Path("."):
|
|
parent = f.parent if f.parent != Path(".") else Path(".")
|
|
parent_label = _folder_label(parent, folder_clean)
|
|
parent_short = _folder_short_name(parent, folder_clean)
|
|
parent_target = index_target_by_folder.get(parent)
|
|
if parent_target is not None:
|
|
lines.append(f"## 📁 {parent_short}")
|
|
lines.append("")
|
|
lines.append(
|
|
f"[⤴️ Вернуться в {parent_short}]({_rel_link(index_target_by_folder[f], parent_target)})"
|
|
)
|
|
lines.append("")
|
|
|
|
children = sorted(child_folders.get(f, set()), key=lambda p: p.as_posix().casefold())
|
|
if children:
|
|
lines.append("## Папки")
|
|
lines.append("")
|
|
for ch in children:
|
|
target_rel = index_target_by_folder[ch]
|
|
ch_label = _folder_short_name(ch, folder_clean)
|
|
rel_link = _rel_link(index_target_by_folder[f], target_rel)
|
|
lines.append(f"- [📁 {ch_label}]({rel_link})")
|
|
lines.append("")
|
|
|
|
docs = sorted(folder_docs.get(f, []), key=lambda x: x[0].casefold())
|
|
idx_target = index_target_by_folder[f]
|
|
if docs:
|
|
lines.append("## Статьи")
|
|
lines.append("")
|
|
for title, target_rel in docs:
|
|
if target_rel == idx_target:
|
|
continue
|
|
rel_link = _rel_link(index_target_by_folder[f], target_rel)
|
|
lines.append(f"- [{title}]({rel_link})")
|
|
lines.append("")
|
|
|
|
index_raw = index_body_by_folder.get(f)
|
|
if index_raw:
|
|
idx_dst = index_target_by_folder[f]
|
|
index_src = index_src_by_folder.get(f, Path("INDEX.md"))
|
|
index_rendered = _rewrite_links(
|
|
_strip_leading_h1(index_raw),
|
|
src=index_src,
|
|
dst_rel=idx_dst,
|
|
src_to_md_dst=src_to_md_dst,
|
|
map_src_abs_to_dst_rel=map_src_abs_to_dst_rel,
|
|
attachments=attachments,
|
|
repo=repo,
|
|
)
|
|
if index_rendered.strip():
|
|
lines.append(index_rendered.rstrip())
|
|
lines.append("")
|
|
|
|
write_text(out_dir / idx_target, "\n".join(lines).rstrip() + "\n")
|
|
managed_rels.add(idx_target)
|
|
|
|
manifest_name = _manifest_name(folder)
|
|
manifest = {
|
|
"tool": "export_docs",
|
|
"version": __version__,
|
|
"folder": folder,
|
|
"files": [p.as_posix() for p in sorted(managed_rels, key=lambda x: x.as_posix())],
|
|
"sources": [x.src.as_posix() for x in items_ok],
|
|
}
|
|
write_text(out_dir / manifest_name, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
|
folders: dict[str, list[int]] = {}
|
|
markdown_rels = set(src_to_md_dst.values()) | set(index_target_by_folder.values())
|
|
attachment_rels = managed_rels - markdown_rels
|
|
for rel in markdown_rels:
|
|
key = rel.parent.as_posix() if rel.parent.as_posix() != "." else "/"
|
|
folders.setdefault(key, [0, 0])[0] += 1
|
|
for rel in attachment_rels:
|
|
key = rel.parent.as_posix() if rel.parent.as_posix() != "." else "/"
|
|
folders.setdefault(key, [0, 0])[1] += 1
|
|
return DocsStats(
|
|
files_total=len(items_ok) + len(attachments) + len(index_target_by_folder),
|
|
files_written=len(managed_rels),
|
|
files_deleted=0,
|
|
markdown_files=len(markdown_rels),
|
|
attachment_files=len(attachment_rels),
|
|
folders={k: (v[0], v[1]) for k, v in sorted(folders.items())},
|
|
), manifest_name
|
|
|
|
|
|
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:
|
|
"""Gitea may mark Home/_Sidebar assume-unchanged; git add then skips them."""
|
|
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, manifest_name: str) -> 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_docs_wiki(
|
|
*,
|
|
wiki_url: str,
|
|
out_dir: Path,
|
|
work_dir: Path,
|
|
message: str,
|
|
folder: str,
|
|
branch: str = "main",
|
|
dry_run: bool = False,
|
|
) -> DocsStats:
|
|
out_dir = out_dir.resolve()
|
|
work_dir = work_dir.resolve()
|
|
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
|
|
|
|
manifest_name = _manifest_name(folder)
|
|
new_manifest = out_dir / manifest_name
|
|
if not new_manifest.is_file():
|
|
raise WikiPushError(f"missing manifest in output: {new_manifest}")
|
|
new_data = json.loads(new_manifest.read_text(encoding="utf-8"))
|
|
new_files = {str(x) for x in (new_data.get("files") or [])}
|
|
prev_files = _load_prev_managed(work_dir, manifest_name)
|
|
|
|
deleted = 0
|
|
for rel in sorted(prev_files - new_files):
|
|
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)
|
|
copy_file(new_manifest, work_dir / manifest_name)
|
|
|
|
# Update root navigation pages to expose published docs.
|
|
ensure_root_navigation(
|
|
work_dir=work_dir,
|
|
folder=folder,
|
|
managed_files=new_files,
|
|
)
|
|
|
|
if dry_run:
|
|
_wiki_clear_index_flags(work_dir, "Home.md", "_Sidebar.md")
|
|
_run_git(["git", "add", "-A"], cwd=work_dir)
|
|
return DocsStats(
|
|
files_total=len(new_files),
|
|
files_written=len(new_files),
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
if not _git_stage_and_ready(work_dir):
|
|
return DocsStats(
|
|
files_total=len(new_files),
|
|
files_written=len(new_files),
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
_run_git(["git", "commit", "-m", message], cwd=work_dir)
|
|
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
|
|
return DocsStats(
|
|
files_total=len(new_files),
|
|
files_written=len(new_files),
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
|
|
|
|
def clean_docs_wiki(
|
|
*,
|
|
wiki_url: str,
|
|
work_dir: Path,
|
|
folder: str,
|
|
message: str,
|
|
branch: str = "main",
|
|
dry_run: bool = False,
|
|
) -> DocsStats:
|
|
"""
|
|
Remove docs pages previously managed by export_docs for one folder.
|
|
|
|
Also removes export_docs marker blocks from Home/_Sidebar.
|
|
"""
|
|
work_dir = work_dir.resolve()
|
|
prepare_wiki_clone(wiki_url, work_dir, branch=branch)
|
|
|
|
manifest_name = _manifest_name(folder)
|
|
prev_files = _load_prev_managed(work_dir, manifest_name)
|
|
deleted = 0
|
|
for rel in sorted(prev_files):
|
|
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)
|
|
|
|
# Drop export_docs nav block if present.
|
|
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_docs:begin\s*-->.*?<!--\s*export_docs: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 DocsStats(
|
|
files_total=deleted,
|
|
files_written=0,
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
if not _git_stage_and_ready(work_dir):
|
|
return DocsStats(
|
|
files_total=deleted,
|
|
files_written=0,
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
_run_git(["git", "commit", "-m", message], cwd=work_dir)
|
|
_run_git(["git", "push", "origin", f"HEAD:{branch}"], cwd=work_dir)
|
|
return DocsStats(
|
|
files_total=deleted,
|
|
files_written=0,
|
|
files_deleted=deleted,
|
|
markdown_files=0,
|
|
attachment_files=0,
|
|
folders={},
|
|
)
|
|
|
|
|
|
def validate_folder(folder: str) -> str:
|
|
f = folder.strip().strip("\\").strip("/")
|
|
if not f:
|
|
return ""
|
|
p = Path(f)
|
|
if any(part in ("..", "") for part in p.parts):
|
|
raise ValueError(f"invalid folder path: {folder}")
|
|
return p.as_posix()
|
|
|
|
|
|
def format_patterns(patterns: Iterable[str]) -> str:
|
|
return ", ".join(patterns)
|
|
|
|
|
|
def format_folder_summary(stats: DocsStats) -> str:
|
|
if not stats.folders:
|
|
return " /: 0 md, 0 attachments"
|
|
lines: list[str] = []
|
|
for folder, (md_count, attachment_count) in stats.folders.items():
|
|
lines.append(f" {folder}: {md_count} md, {attachment_count} attachments")
|
|
return "\n".join(lines)
|