mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
527 lines
16 KiB
Python
Executable file
527 lines
16 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: 0BSD
|
|
"""Fetch the Reticulum manual at build time and stage it for bundling.
|
|
|
|
The downloaded archive is extracted into meshchatx/public/reticulum-docs-bundled/current
|
|
so that the application ships with an offline copy of the manual. At runtime the
|
|
backend will serve those files for any /reticulum-docs/ request that does not
|
|
have a user-uploaded version overriding it.
|
|
|
|
Usage::
|
|
|
|
python scripts/build/fetch_reticulum_manual.py [--source URL] [--dest DIR]
|
|
[--force] [--include-pdf]
|
|
|
|
Sources may be HTTPS ZIP URLs, local directories that contain a docs/ tree, or
|
|
rns:// rngit remotes (requires git and git-remote-rns).
|
|
|
|
By default the upstream PDF/EPUB copies of the manual are excluded from the
|
|
bundle because the in-app viewer only renders the HTML version. Pass
|
|
--include-pdf (or set MESHCHATX_DOCS_INCLUDE_PDF=1) to keep them.
|
|
|
|
Environment variables::
|
|
|
|
MESHCHATX_RETICULUM_DOCS_URL Override the default source URL (single value).
|
|
MESHCHATX_RETICULUM_DOCS_DEST Override the destination directory.
|
|
MESHCHATX_RETICULUM_DOCS_VIA_RNS If set, prefer the default rngit website remote.
|
|
MESHCHATX_RETICULUM_DOCS_REF Git ref for rns:// clones (default HEAD).
|
|
MESHCHATX_SKIP_DOCS_FETCH If set to 1/true, exit without fetching.
|
|
MESHCHATX_DOCS_INCLUDE_PDF If set to 1/true, include PDF/EPUB.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import io
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
|
|
if str(_SCRIPTS_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
|
|
from pip_rns_remotes import DEFAULT_WEBSITE_REMOTE # noqa: E402
|
|
|
|
DEFAULT_SOURCES = (
|
|
"https://codeload.github.com/markqvist/reticulum_website/zip/refs/heads/master",
|
|
)
|
|
DEFAULT_RNS_SOURCE = DEFAULT_WEBSITE_REMOTE
|
|
|
|
DEFAULT_DEST = (
|
|
Path(__file__).resolve().parent.parent.parent
|
|
/ "meshchatx"
|
|
/ "public"
|
|
/ "reticulum-docs-bundled"
|
|
/ "current"
|
|
)
|
|
|
|
BUNDLE_MANIFEST_PATH = Path(__file__).resolve().parent / "reticulum_docs_bundle.json"
|
|
|
|
EXTRA_BINARY_SUFFIXES = (".pdf", ".epub")
|
|
"""File suffixes for large alternate-format manuals that are excluded from the
|
|
bundled copy by default. The HTML viewer does not use them, so dropping these
|
|
saves roughly 9 MB on disk per build artifact."""
|
|
|
|
|
|
def _is_truthy(value: str | None) -> bool:
|
|
return value is not None and value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _is_rns_source(source: str) -> bool:
|
|
return source.strip().lower().startswith("rns://")
|
|
|
|
|
|
def _download(url: str, timeout: float) -> bytes:
|
|
logging.info("Downloading Reticulum manual from %s", url)
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "meshchatx-build-script"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
return response.read()
|
|
|
|
|
|
def _resolve_docs_root(zip_bytes: bytes) -> tuple[zipfile.ZipFile, str]:
|
|
"""Return the open zip and the path prefix that contains the docs/ tree."""
|
|
archive = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
|
names = archive.namelist()
|
|
if not names:
|
|
archive.close()
|
|
raise ValueError("downloaded archive is empty")
|
|
|
|
root = names[0].split("/", 1)[0]
|
|
docs_prefix = f"{root}/docs/"
|
|
if not any(name.startswith(docs_prefix) for name in names):
|
|
archive.close()
|
|
raise ValueError(
|
|
f"archive does not contain expected docs/ folder under {root}/",
|
|
)
|
|
return archive, docs_prefix
|
|
|
|
|
|
def _extract(
|
|
archive: zipfile.ZipFile,
|
|
docs_prefix: str,
|
|
dest: Path,
|
|
include_pdf: bool = False,
|
|
) -> tuple[int, int]:
|
|
"""Extract docs/ tree from archive into dest.
|
|
|
|
Returns (extracted_count, skipped_binary_count). When include_pdf is
|
|
false, large alternate-format manuals listed in EXTRA_BINARY_SUFFIXES
|
|
are skipped to keep shipped artifacts small.
|
|
"""
|
|
extracted = 0
|
|
skipped_binary = 0
|
|
for member in archive.infolist():
|
|
name = member.filename
|
|
if not name.startswith(docs_prefix):
|
|
continue
|
|
rel = name[len(docs_prefix) :]
|
|
if not rel or rel.endswith("/"):
|
|
continue
|
|
if ".." in rel.split("/"):
|
|
continue
|
|
if not include_pdf and rel.lower().endswith(EXTRA_BINARY_SUFFIXES):
|
|
skipped_binary += 1
|
|
continue
|
|
target = dest / rel
|
|
try:
|
|
target.relative_to(dest)
|
|
except ValueError:
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with archive.open(member) as src, open(target, "wb") as fh:
|
|
shutil.copyfileobj(src, fh)
|
|
extracted += 1
|
|
return extracted, skipped_binary
|
|
|
|
|
|
def _extract_from_docs_dir(
|
|
docs_dir: Path,
|
|
dest: Path,
|
|
include_pdf: bool = False,
|
|
) -> tuple[int, int]:
|
|
"""Copy a local docs/ tree into dest."""
|
|
if not docs_dir.is_dir():
|
|
raise ValueError(f"docs directory missing: {docs_dir}")
|
|
extracted = 0
|
|
skipped_binary = 0
|
|
for path in docs_dir.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
rel = path.relative_to(docs_dir).as_posix()
|
|
if not rel or ".." in rel.split("/"):
|
|
continue
|
|
if not include_pdf and rel.lower().endswith(EXTRA_BINARY_SUFFIXES):
|
|
skipped_binary += 1
|
|
continue
|
|
target = dest / rel
|
|
try:
|
|
target.relative_to(dest)
|
|
except ValueError:
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, target)
|
|
extracted += 1
|
|
return extracted, skipped_binary
|
|
|
|
|
|
def _find_docs_dir(root: Path) -> Path:
|
|
direct = root / "docs"
|
|
if direct.is_dir():
|
|
return direct
|
|
matches = [p for p in root.rglob("docs") if p.is_dir()]
|
|
for candidate in matches:
|
|
if (candidate / "index.html").is_file() or (candidate / "manual").is_dir():
|
|
return candidate
|
|
if matches:
|
|
return matches[0]
|
|
raise ValueError(f"no docs/ directory found under {root}")
|
|
|
|
|
|
def _run_git(args: list[str], *, cwd: Path | None, timeout: float) -> None:
|
|
try:
|
|
completed = subprocess.run(
|
|
args,
|
|
cwd=str(cwd) if cwd else None,
|
|
check=False,
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise ValueError(f"git timed out: {' '.join(args)}") from exc
|
|
if completed.returncode != 0:
|
|
err = (completed.stderr or b"").decode("utf-8", errors="replace")[:500]
|
|
raise ValueError(f"git failed ({completed.returncode}): {err or args}")
|
|
|
|
|
|
def _clone_rns_docs(
|
|
remote: str,
|
|
*,
|
|
timeout: float,
|
|
ref: str,
|
|
) -> Path:
|
|
"""Clone an rns:// website repo sparsely and return its docs/ path."""
|
|
if shutil.which("git") is None:
|
|
raise ValueError("git is required for rns:// docs sources")
|
|
if shutil.which("git-remote-rns") is None:
|
|
raise ValueError("git-remote-rns is required for rns:// docs sources")
|
|
|
|
work = Path(tempfile.mkdtemp(prefix="meshchatx-rns-docs-"))
|
|
try:
|
|
logging.info("Cloning Reticulum website from %s (ref=%s)", remote, ref)
|
|
_run_git(
|
|
[
|
|
"git",
|
|
"clone",
|
|
"--filter=blob:none",
|
|
"--sparse",
|
|
"--no-checkout",
|
|
remote,
|
|
str(work),
|
|
],
|
|
cwd=None,
|
|
timeout=timeout,
|
|
)
|
|
_run_git(
|
|
["git", "sparse-checkout", "set", "--no-cone", "--", "docs"],
|
|
cwd=work,
|
|
timeout=min(60.0, timeout),
|
|
)
|
|
fetch_ref = ref if ref and ref != "HEAD" else "HEAD"
|
|
_run_git(
|
|
["git", "fetch", "--depth", "1", "origin", fetch_ref],
|
|
cwd=work,
|
|
timeout=timeout,
|
|
)
|
|
_run_git(
|
|
["git", "checkout", "FETCH_HEAD", "--", "docs"],
|
|
cwd=work,
|
|
timeout=min(120.0, timeout),
|
|
)
|
|
return _find_docs_dir(work)
|
|
except Exception:
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
raise
|
|
|
|
|
|
def _stage_from_local_or_rns(
|
|
source: str,
|
|
dest: Path,
|
|
*,
|
|
timeout: float,
|
|
include_pdf: bool,
|
|
ref: str,
|
|
) -> tuple[int, int, str]:
|
|
"""Return extracted counts and a cleanup workdir path (may be empty)."""
|
|
cleanup = ""
|
|
local = Path(source)
|
|
if local.exists() and local.is_dir():
|
|
docs_dir = _find_docs_dir(local)
|
|
extracted, skipped = _extract_from_docs_dir(
|
|
docs_dir, dest, include_pdf=include_pdf
|
|
)
|
|
return extracted, skipped, cleanup
|
|
|
|
if _is_rns_source(source):
|
|
docs_dir = _clone_rns_docs(source.strip(), timeout=timeout, ref=ref)
|
|
cleanup = str(docs_dir.parent)
|
|
try:
|
|
extracted, skipped = _extract_from_docs_dir(
|
|
docs_dir, dest, include_pdf=include_pdf
|
|
)
|
|
finally:
|
|
shutil.rmtree(cleanup, ignore_errors=True)
|
|
cleanup = ""
|
|
return extracted, skipped, cleanup
|
|
|
|
raise ValueError(f"unsupported local/rns source: {source}")
|
|
|
|
|
|
def _write_bundle_manifest(
|
|
*,
|
|
source_url: str,
|
|
dest: Path,
|
|
extracted: int,
|
|
skipped_binary: int,
|
|
manifest_path: Path,
|
|
) -> None:
|
|
repo_root = Path(__file__).resolve().parent.parent.parent
|
|
try:
|
|
dest_value = str(dest.resolve().relative_to(repo_root))
|
|
except ValueError:
|
|
dest_value = str(dest.resolve())
|
|
|
|
source_value = source_url
|
|
if source_url.startswith("/"):
|
|
try:
|
|
source_value = str(Path(source_url).resolve().relative_to(repo_root))
|
|
except ValueError:
|
|
pass
|
|
|
|
payload = {
|
|
"source_url": source_value,
|
|
"dest": dest_value,
|
|
"fetched_utc": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"html_files": extracted,
|
|
"skipped_binary_files": skipped_binary,
|
|
}
|
|
manifest_path.write_text(
|
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
logging.info("Wrote bundle manifest to %s", manifest_path)
|
|
|
|
|
|
def fetch_manual(
|
|
sources: list[str],
|
|
dest: Path,
|
|
timeout: float = 120.0,
|
|
force: bool = False,
|
|
include_pdf: bool = False,
|
|
ref: str = "HEAD",
|
|
manifest_path: Path | None = None,
|
|
) -> int:
|
|
if dest.exists() and any(dest.iterdir()) and not force:
|
|
logging.info(
|
|
"Reticulum manual already present at %s (%d entries); skipping fetch.",
|
|
dest,
|
|
sum(1 for _ in dest.rglob("*")),
|
|
)
|
|
return 0
|
|
|
|
last_error: Exception | None = None
|
|
extracted = 0
|
|
skipped_binary = 0
|
|
source_url: str | None = None
|
|
|
|
for url in sources:
|
|
try:
|
|
if dest.exists():
|
|
shutil.rmtree(dest)
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
|
|
if _is_rns_source(url) or Path(url).exists():
|
|
extracted, skipped_binary, _cleanup = _stage_from_local_or_rns(
|
|
url,
|
|
dest,
|
|
timeout=timeout,
|
|
include_pdf=include_pdf,
|
|
ref=ref,
|
|
)
|
|
source_url = url
|
|
break
|
|
|
|
data = _download(url, timeout)
|
|
archive, docs_prefix = _resolve_docs_root(data)
|
|
try:
|
|
extracted, skipped_binary = _extract(
|
|
archive,
|
|
docs_prefix,
|
|
dest,
|
|
include_pdf=include_pdf,
|
|
)
|
|
finally:
|
|
archive.close()
|
|
source_url = url
|
|
break
|
|
except (
|
|
urllib.error.URLError,
|
|
OSError,
|
|
ValueError,
|
|
zipfile.BadZipFile,
|
|
) as exc:
|
|
logging.warning("Failed to fetch %s: %s", url, exc)
|
|
last_error = exc
|
|
if dest.exists():
|
|
shutil.rmtree(dest, ignore_errors=True)
|
|
|
|
if source_url is None:
|
|
raise SystemExit(
|
|
f"Could not download Reticulum manual from any source: {last_error}",
|
|
)
|
|
|
|
if extracted == 0:
|
|
raise SystemExit("Archive contained no docs/ files to extract")
|
|
|
|
if skipped_binary:
|
|
logging.info(
|
|
"Skipped %d alternate-format manual file(s) (%s); pass --include-pdf to keep them.",
|
|
skipped_binary,
|
|
", ".join(EXTRA_BINARY_SUFFIXES),
|
|
)
|
|
|
|
logging.info("Extracted %d files to %s", extracted, dest)
|
|
_write_bundle_manifest(
|
|
source_url=source_url,
|
|
dest=dest,
|
|
extracted=extracted,
|
|
skipped_binary=skipped_binary,
|
|
manifest_path=manifest_path or BUNDLE_MANIFEST_PATH,
|
|
)
|
|
return extracted
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--source",
|
|
action="append",
|
|
default=None,
|
|
help=(
|
|
"HTTPS ZIP URL, local checkout path, or rns:// remote. May be passed "
|
|
"multiple times for fallbacks. Defaults to the canonical upstream sources."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--dest",
|
|
type=Path,
|
|
default=None,
|
|
help="Output directory for the extracted manual.",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=120.0,
|
|
help="HTTP / git timeout in seconds.",
|
|
)
|
|
parser.add_argument(
|
|
"--ref",
|
|
default=os.environ.get("MESHCHATX_RETICULUM_DOCS_REF", "HEAD"),
|
|
help="Git ref for rns:// clones (default HEAD or MESHCHATX_RETICULUM_DOCS_REF).",
|
|
)
|
|
parser.add_argument(
|
|
"--force",
|
|
action="store_true",
|
|
help="Re-fetch even if the destination already exists.",
|
|
)
|
|
parser.add_argument(
|
|
"--via-rns",
|
|
action="store_true",
|
|
default=_is_truthy(os.environ.get("MESHCHATX_RETICULUM_DOCS_VIA_RNS")),
|
|
help=(
|
|
"Prefer the default rngit website remote "
|
|
f"({DEFAULT_RNS_SOURCE}). Also set MESHCHATX_RETICULUM_DOCS_VIA_RNS=1."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--include-pdf",
|
|
action="store_true",
|
|
default=_is_truthy(os.environ.get("MESHCHATX_DOCS_INCLUDE_PDF")),
|
|
help=(
|
|
"Include the upstream PDF/EPUB manuals in the bundle. They are "
|
|
"skipped by default to keep build artifacts smaller because the "
|
|
"in-app viewer only uses the HTML version."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--quiet",
|
|
action="store_true",
|
|
help="Reduce log output.",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
logging.basicConfig(
|
|
level=logging.WARNING if args.quiet else logging.INFO,
|
|
format="%(message)s",
|
|
)
|
|
|
|
if _is_truthy(os.environ.get("MESHCHATX_SKIP_DOCS_FETCH")):
|
|
logging.info(
|
|
"MESHCHATX_SKIP_DOCS_FETCH is set; skipping Reticulum manual fetch."
|
|
)
|
|
return 0
|
|
|
|
sources: list[str] = []
|
|
if args.source:
|
|
sources.extend(args.source)
|
|
env_url = os.environ.get("MESHCHATX_RETICULUM_DOCS_URL")
|
|
if env_url:
|
|
sources.append(env_url)
|
|
if args.via_rns:
|
|
sources.insert(0, DEFAULT_RNS_SOURCE)
|
|
if not sources:
|
|
sources = list(DEFAULT_SOURCES)
|
|
|
|
env_dest = os.environ.get("MESHCHATX_RETICULUM_DOCS_DEST")
|
|
dest = args.dest or (Path(env_dest) if env_dest else DEFAULT_DEST)
|
|
|
|
if _is_truthy(os.environ.get("MESHCHATX_OFFLINE_BUILD")):
|
|
if dest.exists() and any(dest.iterdir()):
|
|
logging.info(
|
|
"MESHCHATX_OFFLINE_BUILD=1 and Reticulum manual already present at %s; skipping fetch.",
|
|
dest,
|
|
)
|
|
return 0
|
|
logging.error(
|
|
"MESHCHATX_OFFLINE_BUILD=1 but Reticulum manual is missing at %s", dest
|
|
)
|
|
return 1
|
|
|
|
fetch_manual(
|
|
sources=sources,
|
|
dest=dest.resolve(),
|
|
timeout=args.timeout,
|
|
force=args.force,
|
|
include_pdf=args.include_pdf,
|
|
ref=args.ref,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|