Release 0.3.9: export_docs clean + wiki index pages
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
"""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 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],
|
||||
) -> 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)
|
||||
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)
|
||||
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:
|
||||
# Keep slash + fragment markers, encode spaces/parentheses/unicode safely.
|
||||
return quote(target, safe="/#._-~")
|
||||
|
||||
|
||||
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 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)})",
|
||||
]
|
||||
)
|
||||
sidebar_block = "\n".join(
|
||||
[
|
||||
"### Документация (docs)",
|
||||
"",
|
||||
f"- [{display}]({_encode_md_target(link_target)})",
|
||||
"",
|
||||
]
|
||||
)
|
||||
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,
|
||||
) -> 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]]] = {}
|
||||
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()]
|
||||
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,
|
||||
)
|
||||
write_text(out_dir / dst_rel, rendered)
|
||||
src_folder = item.dst_rel.parent
|
||||
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(folder_docs.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 = f.as_posix() if f != Path(".") else (folder_clean or "docs")
|
||||
lines.append(f"# Индекс: {heading}")
|
||||
lines.append("")
|
||||
|
||||
docs = sorted(folder_docs.get(f, []), key=lambda x: x[0].casefold())
|
||||
if docs:
|
||||
lines.append("## Статьи")
|
||||
lines.append("")
|
||||
for title, target_rel in docs:
|
||||
rel_link = os.path.relpath(
|
||||
target_rel.as_posix(),
|
||||
start=index_target_by_folder[f].parent.as_posix(),
|
||||
).replace("\\", "/")
|
||||
lines.append(f"- [{title}]({_encode_md_target(rel_link)})")
|
||||
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]
|
||||
rel_link = os.path.relpath(
|
||||
target_rel.as_posix(),
|
||||
start=index_target_by_folder[f].parent.as_posix(),
|
||||
).replace("\\", "/")
|
||||
lines.append(f"- [{ch.as_posix()}]({_encode_md_target(rel_link)})")
|
||||
lines.append("")
|
||||
|
||||
idx_target = index_target_by_folder[f]
|
||||
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 _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,
|
||||
)
|
||||
|
||||
_run_git(["git", "add", "-A"], cwd=work_dir)
|
||||
status = _run_git(["git", "status", "--porcelain"], cwd=work_dir)
|
||||
if not status.strip() or dry_run:
|
||||
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)
|
||||
|
||||
_run_git(["git", "add", "-A"], cwd=work_dir)
|
||||
status = _run_git(["git", "status", "--porcelain"], cwd=work_dir)
|
||||
if not status.strip() or dry_run:
|
||||
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)
|
||||
Reference in New Issue
Block a user