"""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\s*\[[^\]]+\]:\s*)(?P<[^>]+>|\S+)(?P.*)$", re.MULTILINE, ) _URL_SCHEMES = ("http://", "https://", "mailto:", "ftp://") _EXPORT_DOCS_MARKER_BEGIN = "" _EXPORT_DOCS_MARKER_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".*?", 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".*?\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)