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.
Files changed (303) hide show
  1. primarysource/__init__.py +5 -0
  2. primarysource/bbf/__init__.py +56 -0
  3. primarysource/bbf/_xml.py +63 -0
  4. primarysource/bbf/catalogue.json +777 -0
  5. primarysource/bbf/catalogue.py +137 -0
  6. primarysource/bbf/cli.py +79 -0
  7. primarysource/bbf/datamodel.py +281 -0
  8. primarysource/bbf/importer.py +151 -0
  9. primarysource/bbf/library.py +380 -0
  10. primarysource/bbf/server.py +72 -0
  11. primarysource/budget.py +63 -0
  12. primarysource/c2sp/__init__.py +53 -0
  13. primarysource/c2sp/_fetch.py +5 -0
  14. primarysource/c2sp/catalogue.json +231 -0
  15. primarysource/c2sp/catalogue.py +95 -0
  16. primarysource/c2sp/cli.py +62 -0
  17. primarysource/c2sp/importer.py +90 -0
  18. primarysource/c2sp/library.py +309 -0
  19. primarysource/c2sp/markdown.py +31 -0
  20. primarysource/c2sp/server.py +68 -0
  21. primarysource/cavp/__init__.py +42 -0
  22. primarysource/cavp/cli.py +84 -0
  23. primarysource/cavp/data/vectors.toml +47 -0
  24. primarysource/cavp/importer.py +156 -0
  25. primarysource/cavp/model.py +279 -0
  26. primarysource/cavp/parse.py +269 -0
  27. primarysource/cavp/paths.py +17 -0
  28. primarysource/cavp/server.py +67 -0
  29. primarysource/cavp/tools.py +169 -0
  30. primarysource/cli.py +107 -0
  31. primarysource/cursor.py +82 -0
  32. primarysource/cve/__init__.py +5 -0
  33. primarysource/cve/__main__.py +6 -0
  34. primarysource/cve/cli.py +144 -0
  35. primarysource/cve/collection.py +20 -0
  36. primarysource/cve/config.py +58 -0
  37. primarysource/cve/errors.py +16 -0
  38. primarysource/cve/kernel.py +232 -0
  39. primarysource/cve/mcp.py +139 -0
  40. primarysource/cve/parse.py +318 -0
  41. primarysource/cve/py.typed +0 -0
  42. primarysource/cve/service.py +760 -0
  43. primarysource/cve/store.py +214 -0
  44. primarysource/cve/sync.py +438 -0
  45. primarysource/cve/views.py +304 -0
  46. primarysource/debpolicy/__init__.py +16 -0
  47. primarysource/debpolicy/catalogue.py +263 -0
  48. primarysource/debpolicy/cli.py +12 -0
  49. primarysource/debpolicy/server.py +11 -0
  50. primarysource/draft/__init__.py +11 -0
  51. primarysource/draft/catalogue.py +117 -0
  52. primarysource/draft/cli.py +149 -0
  53. primarysource/draft/collection.py +21 -0
  54. primarysource/draft/datatracker.py +158 -0
  55. primarysource/draft/importer.py +163 -0
  56. primarysource/draft/licence.py +89 -0
  57. primarysource/draft/names.py +64 -0
  58. primarysource/draft/net.py +133 -0
  59. primarysource/draft/segment.py +192 -0
  60. primarysource/draft/server.py +91 -0
  61. primarysource/draft/status.py +146 -0
  62. primarysource/draft/store.py +199 -0
  63. primarysource/draft/tracked.json +104 -0
  64. primarysource/draft/views.py +430 -0
  65. primarysource/draft/xmlmeta.py +74 -0
  66. primarysource/ecma/__init__.py +52 -0
  67. primarysource/ecma/_pdf.py +143 -0
  68. primarysource/ecma/catalogue.json +22 -0
  69. primarysource/ecma/catalogue.py +100 -0
  70. primarysource/ecma/cli.py +67 -0
  71. primarysource/ecma/identify.py +25 -0
  72. primarysource/ecma/importer.py +108 -0
  73. primarysource/ecma/library.py +240 -0
  74. primarysource/ecma/server.py +67 -0
  75. primarysource/errors.py +29 -0
  76. primarysource/fetch.py +360 -0
  77. primarysource/fsutil.py +150 -0
  78. primarysource/gitdocs/__init__.py +15 -0
  79. primarysource/gitdocs/catalogue.py +116 -0
  80. primarysource/gitdocs/cli.py +12 -0
  81. primarysource/gitdocs/server.py +8 -0
  82. primarysource/home.py +65 -0
  83. primarysource/iana/__init__.py +32 -0
  84. primarysource/iana/catalogue.json +244 -0
  85. primarysource/iana/catalogue.py +69 -0
  86. primarysource/iana/cli.py +69 -0
  87. primarysource/iana/importer.py +157 -0
  88. primarysource/iana/model.py +325 -0
  89. primarysource/iana/reader.py +315 -0
  90. primarysource/iana/server.py +67 -0
  91. primarysource/iana/store.py +83 -0
  92. primarysource/iana/xmlsafe.py +47 -0
  93. primarysource/identify.py +186 -0
  94. primarysource/ieee/__init__.py +58 -0
  95. primarysource/ieee/_html.py +49 -0
  96. primarysource/ieee/catalogue.json +74 -0
  97. primarysource/ieee/catalogue.py +123 -0
  98. primarysource/ieee/cli.py +79 -0
  99. primarysource/ieee/identify.py +24 -0
  100. primarysource/ieee/ieee802.py +47 -0
  101. primarysource/ieee/importer.py +305 -0
  102. primarysource/ieee/posix.py +233 -0
  103. primarysource/ieee/server.py +79 -0
  104. primarysource/index.py +446 -0
  105. primarysource/iso/__init__.py +51 -0
  106. primarysource/iso/cli.py +98 -0
  107. primarysource/iso/designation.py +95 -0
  108. primarysource/iso/identify.py +100 -0
  109. primarysource/iso/importer.py +224 -0
  110. primarysource/iso/library.py +249 -0
  111. primarysource/iso/server.py +69 -0
  112. primarysource/itu/__init__.py +41 -0
  113. primarysource/itu/__main__.py +8 -0
  114. primarysource/itu/asn1mod.py +478 -0
  115. primarysource/itu/catalog.py +472 -0
  116. primarysource/itu/cli.py +445 -0
  117. primarysource/itu/collection.py +20 -0
  118. primarysource/itu/corpus.py +118 -0
  119. primarysource/itu/errata.py +16 -0
  120. primarysource/itu/formal.py +565 -0
  121. primarysource/itu/identify.py +26 -0
  122. primarysource/itu/index.py +490 -0
  123. primarysource/itu/mcp.py +474 -0
  124. primarysource/itu/py.typed +0 -0
  125. primarysource/itu/sources.py +600 -0
  126. primarysource/itu/text.py +609 -0
  127. primarysource/kconfig/__init__.py +16 -0
  128. primarysource/kconfig/catalogue.py +98 -0
  129. primarysource/kconfig/cli.py +12 -0
  130. primarysource/kconfig/server.py +8 -0
  131. primarysource/model.py +63 -0
  132. primarysource/nist/__init__.py +5 -0
  133. primarysource/nist/collection.py +46 -0
  134. primarysource/nist/data/acquired-nist.json +1657 -0
  135. primarysource/nist/data/nist-catalog.json +12090 -0
  136. primarysource/nist/data/sources.toml +1996 -0
  137. primarysource/nist/identify.py +41 -0
  138. primarysource/nist/ingest/__init__.py +1 -0
  139. primarysource/nist/ingest/acquire_nist.py +128 -0
  140. primarysource/nist/ingest/controls.py +82 -0
  141. primarysource/nist/ingest/discover_nist.py +218 -0
  142. primarysource/nist/ingest/extract.py +48 -0
  143. primarysource/nist/ingest/fetch.py +211 -0
  144. primarysource/nist/ingest/index.py +14 -0
  145. primarysource/nist/ingest/oscal.py +480 -0
  146. primarysource/nist/ingest/prose_layouts.py +236 -0
  147. primarysource/nist/ingest/sources.py +94 -0
  148. primarysource/nist/ingest/status.py +255 -0
  149. primarysource/nist/models.py +7 -0
  150. primarysource/nist/paths.py +33 -0
  151. primarysource/nist/server/__init__.py +3 -0
  152. primarysource/nist/server/corpus.py +38 -0
  153. primarysource/nist/server/mcp.py +19 -0
  154. primarysource/nist/server/tools.py +35 -0
  155. primarysource/nist/spec.py +38 -0
  156. primarysource/pdfcorpus/__init__.py +16 -0
  157. primarysource/pdfcorpus/corpus.py +159 -0
  158. primarysource/pdfcorpus/fts.py +175 -0
  159. primarysource/pdfcorpus/index.py +70 -0
  160. primarysource/pdfcorpus/models.py +30 -0
  161. primarysource/pdfcorpus/server.py +96 -0
  162. primarysource/pdfcorpus/spec.py +37 -0
  163. primarysource/pdfcorpus/split.py +391 -0
  164. primarysource/pdfcorpus/tools.py +244 -0
  165. primarysource/pep/__init__.py +15 -0
  166. primarysource/pep/catalogue.py +191 -0
  167. primarysource/pep/cli.py +12 -0
  168. primarysource/pep/server.py +8 -0
  169. primarysource/pkgdocs/__init__.py +19 -0
  170. primarysource/pkgdocs/fetch.py +356 -0
  171. primarysource/pkgdocs/index.py +89 -0
  172. primarysource/pkgdocs/model.py +76 -0
  173. primarysource/pkgdocs/readers/__init__.py +1 -0
  174. primarysource/pkgdocs/readers/_common.py +134 -0
  175. primarysource/pkgdocs/readers/asciidoc.py +160 -0
  176. primarysource/pkgdocs/readers/latex.py +315 -0
  177. primarysource/pkgdocs/readers/markdown.py +173 -0
  178. primarysource/pkgdocs/readers/pod.py +132 -0
  179. primarysource/pkgdocs/readers/rst.py +239 -0
  180. primarysource/pkgdocs/readers/scdoc.py +108 -0
  181. primarysource/pkgdocs/runtime.py +552 -0
  182. primarysource/pkgdocs/serving.py +158 -0
  183. primarysource/pkgdocs/store.py +167 -0
  184. primarysource/pms/__init__.py +16 -0
  185. primarysource/pms/catalogue.py +88 -0
  186. primarysource/pms/cli.py +12 -0
  187. primarysource/pms/server.py +8 -0
  188. primarysource/py.typed +0 -0
  189. primarysource/readers/__init__.py +10 -0
  190. primarysource/readers/bookmarks.py +192 -0
  191. primarysource/readers/html.py +1312 -0
  192. primarysource/readers/markdown.py +189 -0
  193. primarysource/readers/pdf.py +187 -0
  194. primarysource/readers/xml.py +230 -0
  195. primarysource/refs.py +87 -0
  196. primarysource/registry.py +161 -0
  197. primarysource/render.py +49 -0
  198. primarysource/rfc/__init__.py +3 -0
  199. primarysource/rfc/__main__.py +6 -0
  200. primarysource/rfc/cite.py +204 -0
  201. primarysource/rfc/cli.py +903 -0
  202. primarysource/rfc/collection.py +19 -0
  203. primarysource/rfc/config.py +138 -0
  204. primarysource/rfc/errata.py +203 -0
  205. primarysource/rfc/fsutil.py +82 -0
  206. primarysource/rfc/graph.py +213 -0
  207. primarysource/rfc/index/__init__.py +1 -0
  208. primarysource/rfc/index/chunker.py +281 -0
  209. primarysource/rfc/index/embed.py +166 -0
  210. primarysource/rfc/index/hybrid.py +305 -0
  211. primarysource/rfc/index/lexical.py +223 -0
  212. primarysource/rfc/index/rfcxml.py +673 -0
  213. primarysource/rfc/index/search.py +508 -0
  214. primarysource/rfc/index/txtseg.py +585 -0
  215. primarysource/rfc/library.py +294 -0
  216. primarysource/rfc/mcp/__init__.py +1 -0
  217. primarysource/rfc/mcp/server.py +381 -0
  218. primarysource/rfc/packs/__init__.py +1 -0
  219. primarysource/rfc/packs/builder.py +228 -0
  220. primarysource/rfc/packs/collections/all-current-txt.json +10 -0
  221. primarysource/rfc/packs/collections/essentials.json +19 -0
  222. primarysource/rfc/packs/collections/fyi-current.json +10 -0
  223. primarysource/rfc/packs/collections/standards-current.json +10 -0
  224. primarysource/rfc/packs/collections/year-2025.json +10 -0
  225. primarysource/rfc/packs/collections/year-2026.json +10 -0
  226. primarysource/rfc/packs/collections.py +163 -0
  227. primarysource/rfc/packs/docsetexport.py +163 -0
  228. primarysource/rfc/packs/epub.py +159 -0
  229. primarysource/rfc/packs/export.py +306 -0
  230. primarysource/rfc/packs/htmlexport.py +82 -0
  231. primarysource/rfc/packs/serve.py +143 -0
  232. primarysource/rfc/packs/zimexport.py +171 -0
  233. primarysource/rfc/query.py +466 -0
  234. primarysource/rfc/subjects.py +457 -0
  235. primarysource/rfc/sync/__init__.py +1 -0
  236. primarysource/rfc/sync/downloader.py +132 -0
  237. primarysource/rfc/sync/manifest.py +162 -0
  238. primarysource/rfc/sync/scanner.py +49 -0
  239. primarysource/rfc/sync/state.py +464 -0
  240. primarysource/rfc/views.py +1067 -0
  241. primarysource/rpmdocs/__init__.py +15 -0
  242. primarysource/rpmdocs/catalogue.py +117 -0
  243. primarysource/rpmdocs/cli.py +12 -0
  244. primarysource/rpmdocs/server.py +8 -0
  245. primarysource/scaffold.py +223 -0
  246. primarysource/serve.py +75 -0
  247. primarysource/settings.py +152 -0
  248. primarysource/usb/__init__.py +5 -0
  249. primarysource/usb/collection.py +37 -0
  250. primarysource/usb/data/acquired.json +2025 -0
  251. primarysource/usb/data/sources-usb.toml +852 -0
  252. primarysource/usb/data/sources.toml +10 -0
  253. primarysource/usb/data/usb-catalog.json +1663 -0
  254. primarysource/usb/data/usb-classification.json +1986 -0
  255. primarysource/usb/data/usb-corpus-build.json +608 -0
  256. primarysource/usb/data/usb-pdf-choice.json +839 -0
  257. primarysource/usb/data/usb-split-measure.json +1803 -0
  258. primarysource/usb/ingest/__init__.py +1 -0
  259. primarysource/usb/ingest/acquire.py +130 -0
  260. primarysource/usb/ingest/discover_usb.py +88 -0
  261. primarysource/usb/ingest/extract.py +48 -0
  262. primarysource/usb/ingest/fetch.py +197 -0
  263. primarysource/usb/ingest/index.py +14 -0
  264. primarysource/usb/ingest/sources.py +85 -0
  265. primarysource/usb/models.py +7 -0
  266. primarysource/usb/paths.py +33 -0
  267. primarysource/usb/server/__init__.py +3 -0
  268. primarysource/usb/server/corpus.py +38 -0
  269. primarysource/usb/server/mcp.py +19 -0
  270. primarysource/usb/server/tools.py +35 -0
  271. primarysource/usb/spec.py +20 -0
  272. primarysource/usbids/__init__.py +56 -0
  273. primarysource/usbids/cli.py +65 -0
  274. primarysource/usbids/importer.py +272 -0
  275. primarysource/usbids/parse.py +283 -0
  276. primarysource/usbids/server.py +55 -0
  277. primarysource/usbids/store.py +80 -0
  278. primarysource/usbids/tools.py +174 -0
  279. primarysource/zim/__init__.py +5 -0
  280. primarysource/zim/__main__.py +8 -0
  281. primarysource/zim/_libzim.py +37 -0
  282. primarysource/zim/catalog.py +594 -0
  283. primarysource/zim/cli.py +555 -0
  284. primarysource/zim/collection.py +41 -0
  285. primarysource/zim/errors.py +100 -0
  286. primarysource/zim/groups.py +30 -0
  287. primarysource/zim/library.py +668 -0
  288. primarysource/zim/manage.py +636 -0
  289. primarysource/zim/markdown.py +1247 -0
  290. primarysource/zim/mcp.py +201 -0
  291. primarysource/zim/purl.py +130 -0
  292. primarysource/zim/py.typed +0 -0
  293. primarysource/zim/refs.py +90 -0
  294. primarysource/zim/service.py +1182 -0
  295. primarysource/zim/snapshot.py +197 -0
  296. primarysource/zim/sync.py +269 -0
  297. primarysource/zim/views.py +230 -0
  298. primarysource-0.1.0.dist-info/METADATA +118 -0
  299. primarysource-0.1.0.dist-info/RECORD +303 -0
  300. primarysource-0.1.0.dist-info/WHEEL +4 -0
  301. primarysource-0.1.0.dist-info/entry_points.txt +2 -0
  302. primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
  303. 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://&lt;this host&gt;:{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}