primarysource 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Catalog + LAN distribution for built packs.
|
|
2
|
+
|
|
3
|
+
`packs catalog` scans a directory of .rfcpack files and writes catalog.json:
|
|
4
|
+
|
|
5
|
+
{"generated_at": ..., "packs": [{"id","name","description","version",
|
|
6
|
+
"doc_count","formats","file","size","sha256","generated_at"}]}
|
|
7
|
+
|
|
8
|
+
`packs serve` exposes that directory over plain HTTP so an Android app on
|
|
9
|
+
the same LAN can list and fetch packs:
|
|
10
|
+
|
|
11
|
+
GET / -> tiny HTML listing
|
|
12
|
+
GET /catalog.json -> the catalog
|
|
13
|
+
GET /packs/<file> -> a .rfcpack download
|
|
14
|
+
|
|
15
|
+
sha256 is computed at catalog build time; the app re-verifies each download
|
|
16
|
+
before installing (same trust model as caionline's course packs).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import zipfile
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from functools import partial
|
|
26
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .builder import PACK_MARKER, PACK_SUFFIX
|
|
30
|
+
|
|
31
|
+
CATALOG_NAME = "catalog.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _now() -> str:
|
|
35
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pack_info(pack_path: Path) -> dict:
|
|
39
|
+
"""Read the pack manifest out of the zip + compute file-level digest."""
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
with open(pack_path, "rb") as fh:
|
|
42
|
+
for blk in iter(lambda: fh.read(1 << 20), b""):
|
|
43
|
+
h.update(blk)
|
|
44
|
+
with zipfile.ZipFile(pack_path) as zf:
|
|
45
|
+
roots = {n.split("/")[0] for n in zf.namelist() if "/" in n}
|
|
46
|
+
if len(roots) != 1:
|
|
47
|
+
raise ValueError(f"{pack_path.name}: not a single-root pack")
|
|
48
|
+
root = next(iter(roots))
|
|
49
|
+
with zf.open(f"{root}/{PACK_MARKER}") as fh:
|
|
50
|
+
manifest = json.load(fh)
|
|
51
|
+
return {
|
|
52
|
+
"id": manifest["id"], "name": manifest.get("name", manifest["id"]),
|
|
53
|
+
"description": manifest.get("description", ""),
|
|
54
|
+
"version": manifest.get("version", ""),
|
|
55
|
+
"generated_at": manifest.get("generated_at", ""),
|
|
56
|
+
"doc_count": manifest.get("doc_count", 0),
|
|
57
|
+
"chunk_count": manifest.get("chunk_count", 0),
|
|
58
|
+
"formats": manifest.get("formats", []),
|
|
59
|
+
"file": pack_path.name, "size": pack_path.stat().st_size,
|
|
60
|
+
"sha256": h.hexdigest(),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_catalog(packs_dir: Path) -> dict:
|
|
65
|
+
packs_dir = Path(packs_dir)
|
|
66
|
+
packs = [pack_info(p) for p in sorted(packs_dir.glob("*" + PACK_SUFFIX))]
|
|
67
|
+
catalog = {"generated_at": _now(), "packs": packs}
|
|
68
|
+
(packs_dir / CATALOG_NAME).write_text(
|
|
69
|
+
json.dumps(catalog, ensure_ascii=False, indent=1), "utf-8")
|
|
70
|
+
return catalog
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Handler(SimpleHTTPRequestHandler):
|
|
74
|
+
def log_message(self, fmt, *args): # keep the console readable
|
|
75
|
+
if self.path != "/":
|
|
76
|
+
super().log_message(fmt, *args)
|
|
77
|
+
|
|
78
|
+
def _pack_target(self):
|
|
79
|
+
name = self.path[len("/packs/"):]
|
|
80
|
+
target = Path(self.directory) / Path(name).name
|
|
81
|
+
if not target.is_file() or target.suffix != PACK_SUFFIX:
|
|
82
|
+
return None
|
|
83
|
+
return target
|
|
84
|
+
|
|
85
|
+
def do_HEAD(self): # noqa: N802
|
|
86
|
+
if self.path.startswith("/packs/"):
|
|
87
|
+
target = self._pack_target()
|
|
88
|
+
if target is None:
|
|
89
|
+
self.send_error(404)
|
|
90
|
+
return
|
|
91
|
+
self.send_response(200)
|
|
92
|
+
self.send_header("Content-Type", "application/zip")
|
|
93
|
+
self.send_header("Content-Length", str(target.stat().st_size))
|
|
94
|
+
self.end_headers()
|
|
95
|
+
return
|
|
96
|
+
super().do_HEAD()
|
|
97
|
+
|
|
98
|
+
def do_GET(self): # noqa: N802
|
|
99
|
+
if self.path == "/":
|
|
100
|
+
cat_path = Path(self.directory) / CATALOG_NAME
|
|
101
|
+
catalog = (json.loads(cat_path.read_text("utf-8"))
|
|
102
|
+
if cat_path.is_file() else {"packs": []})
|
|
103
|
+
rows = "".join(
|
|
104
|
+
"<li><b><a href=\"/packs/{file}\">{name}</a></b> " \
|
|
105
|
+
"({docs} docs, {mb:.1f} MB) — " \
|
|
106
|
+
"<code>{sha}…</code></li>".format(
|
|
107
|
+
file=p["file"], name=p["name"], docs=p["doc_count"],
|
|
108
|
+
mb=p["size"] / 1e6, sha=p["sha256"][:16])
|
|
109
|
+
for p in catalog.get("packs", []))
|
|
110
|
+
body = (f"<html><body><h3>rfclib packs ({len(catalog.get('packs', []))})"
|
|
111
|
+
f"</h3><p>App → Sync → http://<this host>:{self.server.server_address[1]}</p>"
|
|
112
|
+
f"<ul>{rows}</ul></body></html>").encode()
|
|
113
|
+
self.send_response(200)
|
|
114
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
115
|
+
self.send_header("Content-Length", str(len(body)))
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(body)
|
|
118
|
+
return
|
|
119
|
+
if self.path.startswith("/packs/"):
|
|
120
|
+
target = self._pack_target()
|
|
121
|
+
if target is None:
|
|
122
|
+
self.send_error(404)
|
|
123
|
+
return
|
|
124
|
+
self.send_response(200)
|
|
125
|
+
self.send_header("Content-Type", "application/zip")
|
|
126
|
+
self.send_header("Content-Length", str(target.stat().st_size))
|
|
127
|
+
self.send_header("Content-Disposition",
|
|
128
|
+
f"attachment; filename=\"{target.name}\"")
|
|
129
|
+
self.end_headers()
|
|
130
|
+
with open(target, "rb") as fh:
|
|
131
|
+
while blk := fh.read(1 << 20):
|
|
132
|
+
self.wfile.write(blk)
|
|
133
|
+
return
|
|
134
|
+
super().do_GET()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def serve(packs_dir: Path, host: str, port: int) -> ThreadingHTTPServer:
|
|
138
|
+
packs_dir = Path(packs_dir)
|
|
139
|
+
if not (packs_dir / CATALOG_NAME).is_file():
|
|
140
|
+
write_catalog(packs_dir)
|
|
141
|
+
httpd = ThreadingHTTPServer(
|
|
142
|
+
(host, port), partial(_Handler, directory=str(packs_dir)))
|
|
143
|
+
return httpd
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Kiwix ZIM export: one collection -> one .zim archive.
|
|
2
|
+
|
|
3
|
+
Uses the official ``libzim`` bindings (optional extra: ``uv sync --extra
|
|
4
|
+
zim``), imported lazily so the rest of rfclib works without the native
|
|
5
|
+
wheel. Articles live at lowercase paths (``rfc9000``) and are rendered
|
|
6
|
+
by the shared export.py layer — the exact same pages as the EPUB/HTML
|
|
7
|
+
exports, with cross-reference links rewritten to ZIM paths.
|
|
8
|
+
|
|
9
|
+
libzim builds a Xapian full-text index and a title-suggestion index at
|
|
10
|
+
write time (``config_indexing``), so Kiwix-serve / Kiwix apps get offline
|
|
11
|
+
full-text search and type-ahead suggestions over the corpus for free.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import html as htmllib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import stat
|
|
20
|
+
import struct
|
|
21
|
+
import zlib
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Optional
|
|
25
|
+
|
|
26
|
+
from .collections import Collection
|
|
27
|
+
from .export import ExportDoc, STYLE_CSS, make_resolver, render_doc_page
|
|
28
|
+
|
|
29
|
+
HAVE_LIBZIM = importlib.util.find_spec("libzim") is not None
|
|
30
|
+
|
|
31
|
+
MIMETYPE_HTML = "text/html"
|
|
32
|
+
MIMETYPE_CSS = "text/css"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _png_illustration(size: int = 48) -> bytes:
|
|
36
|
+
"""Minimal valid PNG (stdlib only): page icon with text lines."""
|
|
37
|
+
bg, fg = (31, 41, 55), (229, 231, 235)
|
|
38
|
+
bars = [(10, 14, 8, 40), (17, 21, 8, 40), (24, 28, 8, 40), (31, 35, 8, 28)]
|
|
39
|
+
raw = b"".join(
|
|
40
|
+
b"\x00" + b"".join(
|
|
41
|
+
bytes(fg if any(y0 <= y < y1 and x0 <= x < x1
|
|
42
|
+
for y0, y1, x0, x1 in bars) else bg)
|
|
43
|
+
for x in range(size))
|
|
44
|
+
for y in range(size))
|
|
45
|
+
|
|
46
|
+
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
47
|
+
return (struct.pack(">I", len(data)) + tag + data
|
|
48
|
+
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
|
49
|
+
|
|
50
|
+
return (b"\x89PNG\r\n\x1a\n"
|
|
51
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0))
|
|
52
|
+
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _index_page(coll: Collection, docs: list[ExportDoc]) -> str:
|
|
56
|
+
rows = "".join(
|
|
57
|
+
"<tr>"
|
|
58
|
+
f'<td class="num"><a href="{htmllib.escape(d.doc_id.lower())}">'
|
|
59
|
+
f"{htmllib.escape(d.doc_id)}</a></td>"
|
|
60
|
+
f"<td>{htmllib.escape(d.title)}</td>"
|
|
61
|
+
f"<td>{htmllib.escape(d.status)}</td>"
|
|
62
|
+
f'<td class="num">{htmllib.escape(d.pub_date)}</td>'
|
|
63
|
+
f"<td>{'current' if d.current else 'obsolete'}</td>"
|
|
64
|
+
"</tr>" for d in docs)
|
|
65
|
+
return f"""<!DOCTYPE html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="utf-8"/>
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
70
|
+
<title>{htmllib.escape(coll.name)} — rfclib ZIM</title>
|
|
71
|
+
<link rel="stylesheet" type="text/css" href="style.css"/>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<h1>{htmllib.escape(coll.name)}</h1>
|
|
75
|
+
<p class="meta">{len(docs)} documents · rfclib ZIM export · full-text search
|
|
76
|
+
via Kiwix</p>
|
|
77
|
+
<p>{htmllib.escape(coll.description)}</p>
|
|
78
|
+
<table class="index">
|
|
79
|
+
<tr><th>Doc</th><th>Title</th><th>Status</th><th>Published</th><th></th></tr>
|
|
80
|
+
{rows}
|
|
81
|
+
</table>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _unseal_remove(func, path, exc):
|
|
88
|
+
"""rmtree onerror: Xapian seals scratch read-only; clear and retry once."""
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(path, stat.S_IWRITE)
|
|
91
|
+
func(path)
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_zim(coll: Collection, docs: list[ExportDoc], out_dir: Path,
|
|
97
|
+
link_refs: str = "internal",
|
|
98
|
+
progress: Optional[Callable[[str], None]] = None) -> dict:
|
|
99
|
+
"""Write <out_dir>/<coll.id>.zim; returns a summary dict (build_html shape)."""
|
|
100
|
+
if not HAVE_LIBZIM:
|
|
101
|
+
raise ImportError("zim export requires the optional extra: "
|
|
102
|
+
"uv sync --extra zim")
|
|
103
|
+
import libzim.writer as lw
|
|
104
|
+
|
|
105
|
+
known = {d.doc_id: d.doc_id.lower() for d in docs}
|
|
106
|
+
resolver = make_resolver(known, link_refs)
|
|
107
|
+
zim_path = Path(out_dir) / f"{coll.id}.zim"
|
|
108
|
+
zim_path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
class _Item(lw.Item):
|
|
111
|
+
def __init__(self, path, title, mimetype, content, front):
|
|
112
|
+
self._p, self._t, self._m = path, title, mimetype
|
|
113
|
+
self._c, self._front = content, front
|
|
114
|
+
|
|
115
|
+
def get_path(self):
|
|
116
|
+
return self._p
|
|
117
|
+
|
|
118
|
+
def get_title(self):
|
|
119
|
+
return self._t
|
|
120
|
+
|
|
121
|
+
def get_mimetype(self):
|
|
122
|
+
return self._m
|
|
123
|
+
|
|
124
|
+
def get_contentprovider(self):
|
|
125
|
+
return lw.StringProvider(self._c)
|
|
126
|
+
|
|
127
|
+
def get_hints(self):
|
|
128
|
+
return {lw.Hint.COMPRESS: True,
|
|
129
|
+
lw.Hint.FRONT_ARTICLE: self._front}
|
|
130
|
+
|
|
131
|
+
creator = (lw.Creator(str(zim_path))
|
|
132
|
+
.config_compression(lw.Compression.zstd)
|
|
133
|
+
.config_indexing(True, "eng")
|
|
134
|
+
.config_verbose(False))
|
|
135
|
+
creator.set_mainpath("index") # config-time only; validated at finish
|
|
136
|
+
with creator:
|
|
137
|
+
creator.add_metadata("Name", coll.id)
|
|
138
|
+
creator.add_metadata("Title", coll.name)
|
|
139
|
+
creator.add_metadata("Description", coll.description)
|
|
140
|
+
creator.add_metadata("Language", "eng")
|
|
141
|
+
creator.add_metadata("Creator", "rfclib")
|
|
142
|
+
creator.add_metadata("Publisher", "rfclib")
|
|
143
|
+
creator.add_metadata("Date", datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
|
144
|
+
creator.add_metadata("Tags", "rfc;standards;internet")
|
|
145
|
+
creator.add_illustration(48, _png_illustration(48))
|
|
146
|
+
creator.add_item(_Item("index", coll.name, MIMETYPE_HTML,
|
|
147
|
+
_index_page(coll, docs), front=False))
|
|
148
|
+
creator.add_item(_Item("style.css", "", MIMETYPE_CSS, STYLE_CSS,
|
|
149
|
+
front=False))
|
|
150
|
+
for n, doc in enumerate(docs, 1):
|
|
151
|
+
page, _heads = render_doc_page(doc, resolver,
|
|
152
|
+
css_href="style.css",
|
|
153
|
+
back_href="index")
|
|
154
|
+
creator.add_item(_Item(known[doc.doc_id],
|
|
155
|
+
f"{doc.doc_id}: {doc.title}",
|
|
156
|
+
MIMETYPE_HTML, page, front=True))
|
|
157
|
+
if progress:
|
|
158
|
+
progress(f"zim {n}/{len(docs)} {doc.doc_id}")
|
|
159
|
+
# libzim leaves Xapian scratch next to the archive after finish() (sealed
|
|
160
|
+
# read-only on Windows — files and whole .idx.tmp dirs): sweep them.
|
|
161
|
+
for scratch in zim_path.parent.glob(f"{zim_path.name}*idx*"):
|
|
162
|
+
try:
|
|
163
|
+
if scratch.is_dir():
|
|
164
|
+
shutil.rmtree(scratch, onerror=_unseal_remove)
|
|
165
|
+
else:
|
|
166
|
+
os.chmod(scratch, stat.S_IWRITE)
|
|
167
|
+
scratch.unlink()
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
return {"id": coll.id, "format": "zim", "path": str(zim_path),
|
|
171
|
+
"docs": len(docs), "bytes": zim_path.stat().st_size}
|