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,228 @@
1
+ """Build .rfcpack zips from collections.
2
+
3
+ Pack format v1 (mirrors caionline's .caipack: one root folder, one marker
4
+ file, verified manifest):
5
+
6
+ <pack-id>/
7
+ pack.json manifest: metadata, per-doc files w/ sha256+size
8
+ search.sqlite optional: docs + chunks + FTS5 index over txt bodies
9
+ docs/RFC9000.txt document files, one per fmt registered in pack.json
10
+
11
+ The Android reader verifies every file against sha256 on install and refuses
12
+ tampered/corrupt packs; catalog.json (see serve.py) carries pack-level sha256
13
+ for download verification. Everything is stdlib (zipfile, sqlite3, hashlib).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import sqlite3
21
+ import zipfile
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ from ..index.chunker import split_sections
26
+ from ..sync.state import file_path
27
+ from .collections import Collection, resolve, sort_key
28
+
29
+ PACK_MARKER = "pack.json"
30
+ PACK_SUFFIX = ".rfcpack"
31
+ FORMAT_VERSION = 1
32
+
33
+
34
+ def _now() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ def _sha256(path: Path) -> str:
39
+ h = hashlib.sha256()
40
+ with open(path, "rb") as fh:
41
+ for blk in iter(lambda: fh.read(1 << 20), b""):
42
+ h.update(blk)
43
+ return h.hexdigest()
44
+
45
+
46
+ class MissingFiles(Exception):
47
+ def __init__(self, missing: list[str]):
48
+ self.missing = missing
49
+ head = ", ".join(missing[:10])
50
+ super().__init__(
51
+ f"{len(missing)} docs have no local file "
52
+ f"(run `rfclib sync` first): {head}"
53
+ + (" ..." if len(missing) > 10 else ""))
54
+
55
+
56
+ def resolve_files(conn, doc_ids: list[str],
57
+ formats: list[str]) -> dict[str, dict[str, Path]]:
58
+ """{doc_id: {fmt: local path}}; raises MissingFiles listing gaps."""
59
+ out: dict[str, dict[str, Path]] = {}
60
+ missing: list[str] = []
61
+ for doc_id in doc_ids:
62
+ per: dict[str, Path] = {}
63
+ for fmt in formats:
64
+ row = conn.execute(
65
+ "SELECT path FROM files WHERE doc_id=? AND fmt=?",
66
+ (doc_id, fmt)).fetchone()
67
+ path = file_path(conn, row[0]) if row else None
68
+ if path is None or not path.is_file():
69
+ missing.append(f"{doc_id}.{fmt}")
70
+ else:
71
+ per[fmt] = path
72
+ if per:
73
+ out[doc_id] = per
74
+ if missing:
75
+ raise MissingFiles(missing)
76
+ return out
77
+
78
+
79
+ def _docs_meta(conn, doc_ids: list[str],
80
+ files: dict[str, dict[str, Path]]) -> list[dict]:
81
+ docs = []
82
+ for doc_id in doc_ids:
83
+ row = conn.execute(
84
+ """SELECT doc_id, series, title, status, pub_date, is_current,
85
+ stream, wg FROM docs WHERE doc_id=?""", (doc_id,)).fetchone()
86
+ if row is None:
87
+ continue
88
+ docs.append({
89
+ "doc_id": row["doc_id"], "series": row["series"],
90
+ "title": row["title"] or "", "status": row["status"] or "",
91
+ "pub_date": row["pub_date"] or "", "current": bool(row["is_current"]),
92
+ "stream": row["stream"] or "", "wg": row["wg"] or "",
93
+ "formats": sorted(files[doc_id]),
94
+ })
95
+ return docs
96
+
97
+
98
+ SEARCH_SCHEMA = """
99
+ CREATE TABLE docs(
100
+ doc_id TEXT PRIMARY KEY, series TEXT, title TEXT, status TEXT,
101
+ pub_date TEXT, is_current INTEGER, stream TEXT, wg TEXT);
102
+ CREATE TABLE chunks(
103
+ id INTEGER PRIMARY KEY, doc_id TEXT NOT NULL,
104
+ sec_num TEXT NOT NULL, sec_title TEXT NOT NULL,
105
+ start_line INTEGER NOT NULL, end_line INT NOT NULL, body TEXT NOT NULL,
106
+ sec TEXT GENERATED ALWAYS AS (trim(sec_num || ' ' || sec_title)) STORED);
107
+ CREATE INDEX idx_chunks_doc ON chunks(doc_id);
108
+ CREATE VIRTUAL TABLE chunks_fts USING fts5(
109
+ sec, body, content='chunks', content_rowid='id',
110
+ tokenize='porter unicode61 remove_diacritics 2');
111
+ """
112
+
113
+
114
+ def build_search_db(doc_ids: list[str], txt_files: dict[str, Path],
115
+ out_path: Path) -> int:
116
+ """Offline FTS5 index the app can open with android.database.sqlite."""
117
+ if out_path.exists():
118
+ out_path.unlink()
119
+ conn = sqlite3.connect(out_path)
120
+ try:
121
+ conn.executescript(SEARCH_SCHEMA)
122
+ n_chunks = 0
123
+ for doc_id in doc_ids:
124
+ path = txt_files.get(doc_id)
125
+ if path is None:
126
+ continue
127
+ text = path.read_text("utf-8", errors="replace")
128
+ for sec in split_sections(text):
129
+ conn.execute(
130
+ "INSERT INTO chunks(doc_id, sec_num, sec_title,"
131
+ " start_line, end_line, body) VALUES(?,?,?,?,?,?)",
132
+ (doc_id, sec.sec_num, sec.sec_title,
133
+ sec.start_line, sec.end_line, sec.text))
134
+ n_chunks += 1
135
+ conn.commit()
136
+ conn.execute("INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild')")
137
+ conn.commit()
138
+ conn.isolation_level = None # VACUUM cannot run inside a transaction
139
+ conn.execute("VACUUM")
140
+ return n_chunks
141
+ finally:
142
+ conn.close()
143
+
144
+
145
+ def _fill_search_docs(search_db: Path, conn, doc_ids: list[str]) -> None:
146
+ sc = sqlite3.connect(search_db)
147
+ try:
148
+ for doc_id in doc_ids:
149
+ row = conn.execute(
150
+ """SELECT doc_id, series, COALESCE(title,''),
151
+ COALESCE(status,''), COALESCE(pub_date,''),
152
+ is_current, COALESCE(stream,''), COALESCE(wg,'')
153
+ FROM docs WHERE doc_id=?""", (doc_id,)).fetchone()
154
+ if row:
155
+ sc.execute("INSERT OR REPLACE INTO docs VALUES(?,?,?,?,?,?,?,?)",
156
+ tuple(row))
157
+ sc.commit()
158
+ finally:
159
+ sc.close()
160
+
161
+
162
+ def build_pack(conn, coll: Collection, out_dir: Path,
163
+ formats: list[str] = ("txt",), search: bool = True,
164
+ progress=None) -> dict:
165
+ """Build <out_dir>/<coll.id>.rfcpack; returns a summary dict."""
166
+ out_dir = Path(out_dir)
167
+ out_dir.mkdir(parents=True, exist_ok=True)
168
+ doc_ids = resolve(conn, coll)
169
+ if not doc_ids:
170
+ raise LookupError(f"collection '{coll.id}' resolves to 0 docs")
171
+ # Alias-series docs (STD/BCP/FYI) are pointers, not content; drop any
172
+ # that carry no member files instead of failing the whole pack.
173
+ doc_ids = [d for d in doc_ids if d.startswith("RFC") or conn.execute(
174
+ "SELECT 1 FROM files WHERE doc_id=? LIMIT 1", (d,)).fetchone()]
175
+ files = resolve_files(conn, doc_ids, list(formats))
176
+ doc_ids = [d for d in doc_ids if d in files]
177
+
178
+ root = coll.id
179
+ tmp = out_dir / (coll.id + PACK_SUFFIX + ".part")
180
+ search_db = out_dir / f".search-{coll.id}.sqlite.tmp"
181
+ n_chunks = 0
182
+ try:
183
+ if search and "txt" in files[doc_ids[0]]:
184
+ txt = {d: files[d]["txt"] for d in doc_ids if "txt" in files[d]}
185
+ if progress:
186
+ progress(f"indexing {len(txt)} docs for offline search ...")
187
+ n_chunks = build_search_db(doc_ids, txt, search_db)
188
+ _fill_search_docs(search_db, conn, doc_ids)
189
+
190
+ file_entries, hashes = [], []
191
+ with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED,
192
+ compresslevel=9) as zf:
193
+ for doc_id in sorted(doc_ids, key=sort_key):
194
+ for fmt, path in sorted(files[doc_id].items()):
195
+ arc = f"{root}/docs/{doc_id}.{fmt}"
196
+ sha = _sha256(path)
197
+ hashes.append(sha)
198
+ file_entries.append({
199
+ "path": arc, "doc_id": doc_id, "fmt": fmt,
200
+ "sha256": sha, "size": path.stat().st_size})
201
+ zf.write(path, arc)
202
+ if search_db.is_file():
203
+ zf.write(search_db, f"{root}/search.sqlite")
204
+ manifest = {
205
+ "format": FORMAT_VERSION,
206
+ "id": coll.id, "name": coll.name,
207
+ "description": coll.description,
208
+ "generated_at": _now(),
209
+ "formats": sorted(formats),
210
+ "version": hashlib.sha256(
211
+ "".join(hashes).encode()).hexdigest()[:16],
212
+ "doc_count": len(doc_ids), "chunk_count": n_chunks,
213
+ "docs": _docs_meta(conn, doc_ids, files),
214
+ "files": file_entries,
215
+ }
216
+ zf.writestr(f"{root}/{PACK_MARKER}",
217
+ json.dumps(manifest, ensure_ascii=False, indent=1))
218
+ final = out_dir / (coll.id + PACK_SUFFIX)
219
+ tmp.replace(final)
220
+ return {"id": coll.id, "path": str(final), "docs": len(doc_ids),
221
+ "files": len(file_entries), "chunks": n_chunks,
222
+ "bytes": final.stat().st_size,
223
+ "version": manifest["version"]}
224
+ finally:
225
+ if search_db.exists():
226
+ search_db.unlink()
227
+ if tmp.exists():
228
+ tmp.unlink()
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "all-current-txt",
3
+ "name": "Full RFC Archive (current)",
4
+ "description": "Every non-obsolete document in the local mirror. Large (~1 GB uncompressed txt); build with --no-search unless you want the multi-hundred-MB FTS index too.",
5
+ "docs": [],
6
+ "selectors": [
7
+ {"current": true}
8
+ ],
9
+ "history": false
10
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "essentials",
3
+ "name": "Internet Essentials",
4
+ "description": "Hand-picked core protocols every RFC reader should have offline: keyword norms, IPv4/IPv6, TCP/UDP, DNS(+SEC), DHCP, email, HTTP/1.1+2+3, TLS 1.3, QUIC, and the RFC process documents. All current (non-obsolete) IDs; set \"history\": true in a copy to pull in the obsoleted lineage.",
5
+ "docs": [
6
+ "RFC2119", "RFC8174", "RFC2026", "RFC9920", "RFC7841",
7
+ "RFC9293", "RFC792", "RFC1112", "RFC2236", "RFC1071", "RFC1122", "RFC1123",
8
+ "RFC768", "RFC8200", "RFC4291", "RFC4443", "RFC1918", "RFC6890",
9
+ "RFC2131", "RFC2132", "RFC9915",
10
+ "RFC1034", "RFC1035", "RFC7766", "RFC6891", "RFC9331", "RFC9903", "RFC8078",
11
+ "RFC5321", "RFC5322", "RFC6409", "RFC6532", "RFC2045", "RFC2049",
12
+ "RFC1939", "RFC9051", "RFC9458",
13
+ "RFC3986", "RFC9110", "RFC9112", "RFC9113", "RFC9536", "RFC9114", "RFC9115",
14
+ "RFC9846", "RFC8447", "RFC8448", "RFC5280",
15
+ "RFC9000", "RFC9001", "RFC9002", "RFC9369"
16
+ ],
17
+ "selectors": [],
18
+ "history": false
19
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "fyi-current",
3
+ "name": "FYI Notes",
4
+ "description": "Current FYI (For Your Information) notes — the RFC Series' informational essays (e.g. RFC 1918, the KISS principle, privacy considerations).",
5
+ "docs": [],
6
+ "selectors": [
7
+ {"series": ["FYI"], "current": true, "members": true}
8
+ ],
9
+ "history": false
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "standards-current",
3
+ "name": "Official Standards & BCPs",
4
+ "description": "Every RFC that is a member of a current STD (Internet Standard) or BCP (Best Current Practice), resolved from the alias series in rfc-index.xml. This is the closest thing the RFC Series has to an official 'essential' list.",
5
+ "docs": [],
6
+ "selectors": [
7
+ {"series": ["STD", "BCP"], "current": true, "members": true}
8
+ ],
9
+ "history": false
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "year-2025",
3
+ "name": "RFCs Published in 2025",
4
+ "description": "Everything published in 2025 — an example of the year selector for keeping up with releases without watching the RSS feed daily.",
5
+ "docs": [],
6
+ "selectors": [
7
+ {"year": "2025", "current": false}
8
+ ],
9
+ "history": false
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "year-2026",
3
+ "name": "RFCs Published in 2026",
4
+ "description": "Everything published in 2026 so far — rebuild this pack any time after `rfclib sync` to pick up the month's releases.",
5
+ "docs": [],
6
+ "selectors": [
7
+ {"year": "2026", "current": false}
8
+ ],
9
+ "history": false
10
+ }
@@ -0,0 +1,163 @@
1
+ """Collection manifests: named, editable bundles of RFCs resolved from the DB.
2
+
3
+ A collection is a small JSON file (stdlib-parseable — the Android reader
4
+ reads the same format) that names documents explicitly and/or selects them
5
+ by DB attributes (series/stream/area/wg/year). Publication is rolling
6
+ (~5-25 RFCs/month, no official batches), so the useful groupings are the
7
+ official ones: STD/BCP/FYI alias series, streams, IETF areas, working
8
+ groups, publication year — plus hand-curated sets like `essentials`.
9
+
10
+ Selector semantics (all keys optional, AND-combined):
11
+ series / stream / area / wg : list of exact values to include
12
+ year : pub_date year, e.g. "2025"
13
+ current : true (default) keeps only non-obsolete docs
14
+ members : true expands STD/BCP/FYI picks to their
15
+ content-bearing RFC members (alias edges)
16
+
17
+ Collection-level "history": also pull in the docs that the selected docs
18
+ obsolete (transitive), so a reader sees the lineage, not just the tip.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ BUILTIN_DIR = Path(__file__).parent / "collections"
29
+
30
+ _SERIES_ORDER = {"RFC": 0, "BCP": 1, "STD": 2, "FYI": 3, "IEN": 4}
31
+
32
+
33
+ def sort_key(doc_id: str) -> tuple[int, int]:
34
+ m = re.fullmatch(r"(RFC|BCP|STD|FYI|IEN)(\d+)", doc_id)
35
+ if not m:
36
+ return (9, 0)
37
+ return (_SERIES_ORDER.get(m.group(1), 9), int(m.group(2)))
38
+
39
+
40
+ @dataclass
41
+ class Collection:
42
+ id: str
43
+ name: str
44
+ description: str
45
+ docs: list[str] = field(default_factory=list)
46
+ selectors: list[dict] = field(default_factory=list)
47
+ history: bool = False
48
+
49
+ @classmethod
50
+ def from_json(cls, raw: dict, fallback_id: str = "") -> "Collection":
51
+ cid = raw.get("id") or fallback_id
52
+ if not cid or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,40}", cid):
53
+ raise ValueError(f"invalid collection id: {cid!r}")
54
+ return cls(
55
+ id=cid,
56
+ name=raw.get("name", cid),
57
+ description=raw.get("description", ""),
58
+ docs=[str(d) for d in raw.get("docs", [])],
59
+ selectors=[dict(s) for s in raw.get("selectors", [])],
60
+ history=bool(raw.get("history", False)),
61
+ )
62
+
63
+
64
+ def builtin_collections() -> list[Path]:
65
+ return sorted(BUILTIN_DIR.glob("*.json"))
66
+
67
+
68
+ def find_collection(name: str, extra_dirs: list[Path] | None = None) -> Path:
69
+ """Locate `<name>.json` in the extra dirs (user dirs win) or builtins."""
70
+ for d in (extra_dirs or []):
71
+ p = Path(d) / f"{name}.json"
72
+ if p.is_file():
73
+ return p
74
+ p = BUILTIN_DIR / f"{name}.json"
75
+ if p.is_file():
76
+ return p
77
+ raise LookupError(f"no collection '{name}' (checked user dirs and builtins)")
78
+
79
+
80
+ def list_collections(extra_dirs: list[Path] | None = None) -> list[Collection]:
81
+ """Builtins plus user overrides — a user file with the same id wins."""
82
+ by_id: dict[str, Path] = {}
83
+ for p in builtin_collections():
84
+ by_id[p.stem] = p
85
+ for d in (extra_dirs or []):
86
+ for p in sorted(Path(d).glob("*.json")):
87
+ by_id[p.stem] = p
88
+ return [Collection.from_json(json.loads(path.read_text("utf-8")),
89
+ fallback_id=cid)
90
+ for cid, path in sorted(by_id.items())]
91
+
92
+
93
+ def _selector_sql(sel: dict) -> tuple[str, list]:
94
+ where, args = ["1=1"], []
95
+ for key in ("series", "stream", "area", "wg"):
96
+ vals = sel.get(key)
97
+ if vals:
98
+ where.append(f"COALESCE({key},'') IN ({','.join('?' * len(vals))})")
99
+ args += [str(v) for v in vals]
100
+ year = sel.get("year")
101
+ if year:
102
+ where.append("pub_date LIKE ?")
103
+ args.append(f"{int(year)}-%")
104
+ if sel.get("current", True):
105
+ where.append("is_current = 1")
106
+ return " AND ".join(where), args
107
+
108
+
109
+ def _expand_members(conn, ids: set[str]) -> set[str]:
110
+ """Replace STD/BCP/FYI alias docs by their content-bearing RFC members;
111
+ aliases without any member edge are kept as-is."""
112
+ if not ids:
113
+ return ids
114
+ ph = ",".join("?" * len(ids))
115
+ nonrfc = {r[0] for r in conn.execute(
116
+ f"SELECT doc_id FROM docs WHERE doc_id IN ({ph}) AND series != 'RFC'",
117
+ sorted(ids))}
118
+ if not nonrfc:
119
+ return ids
120
+ ph = ",".join("?" * len(nonrfc))
121
+ members = {r[0] for r in conn.execute(
122
+ f"SELECT dst FROM doc_relations WHERE kind='alias' AND src IN ({ph})",
123
+ sorted(nonrfc))}
124
+ by_alias: dict[str, list[str]] = {}
125
+ for src, dst in conn.execute(
126
+ f"SELECT src, dst FROM doc_relations "
127
+ f"WHERE kind='alias' AND src IN ({ph})", sorted(nonrfc)):
128
+ by_alias.setdefault(src, []).append(dst)
129
+ out = set(ids)
130
+ for alias in nonrfc:
131
+ if by_alias.get(alias):
132
+ out.discard(alias)
133
+ return out | members
134
+
135
+
136
+ def resolve(conn, coll: Collection) -> list[str]:
137
+ """Resolve a collection to a sorted list of doc_ids (no file checks)."""
138
+ picked: set[str] = set()
139
+ for doc in coll.docs:
140
+ m = re.fullmatch(r"(RFC|BCP|STD|FYI|IEN)\s*(\d+)", doc.strip(), re.I)
141
+ if not m:
142
+ raise ValueError(f"{coll.id}: bad doc id {doc!r}")
143
+ picked.add(m.group(1).upper() + m.group(2))
144
+ for sel in coll.selectors:
145
+ where, args = _selector_sql(sel)
146
+ ids = {r[0] for r in conn.execute(
147
+ f"SELECT doc_id FROM docs WHERE {where}", args)}
148
+ if sel.get("members"):
149
+ ids = _expand_members(conn, ids)
150
+ picked |= ids
151
+
152
+ if coll.history:
153
+ # Add the docs the picked docs obsolete, transitively (lineage).
154
+ frontier = set(picked)
155
+ while frontier:
156
+ ph = ",".join("?" * len(frontier))
157
+ rows = conn.execute(
158
+ f"""SELECT dst FROM doc_relations
159
+ WHERE kind='obsoletes' AND src IN ({ph})""",
160
+ sorted(frontier)).fetchall()
161
+ frontier = {r[0] for r in rows} - picked
162
+ picked |= frontier
163
+ return sorted(picked, key=sort_key)
@@ -0,0 +1,163 @@
1
+ """Dash docset export: one collection -> one <id>.docset bundle (+ .tgz).
2
+
3
+ Canonical docset layout (Dash / Xcode / Zeal all read it):
4
+
5
+ <id>.docset/Contents/
6
+ Info.plist bundle identity, Dash/Xcode keys
7
+ Resources/
8
+ icon.png 32x32 catalogue icon (generated, stdlib)
9
+ docSet.dsidx SQLite searchIndex(word, type, url)
10
+ Documents/ index.html + style.css + RFCxxxx.html
11
+
12
+ Pages come from the shared export.py render layer — identical to the
13
+ EPUB/HTML/ZIM exports — laid out flat in Documents/. The dsidx gives
14
+ Dash its headline feature: type-ahead lookup of documents ("RFC9110…")
15
+ and top-level sections ("RFC9110: 4.2. HTTP Messaging") that deep-link
16
+ straight into the section anchor. No new dependencies: plistlib,
17
+ sqlite3 and tarfile are stdlib.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import html as htmllib
22
+ import plistlib
23
+ import shutil
24
+ import sqlite3
25
+ import tarfile
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Callable, Optional
29
+
30
+ from .collections import Collection
31
+ from .export import ExportDoc, STYLE_CSS, make_resolver, render_doc_page
32
+ from .zimexport import _png_illustration # stdlib PNG generator, no libzim
33
+
34
+ # Section headings at or above this level (smaller = shallower) become
35
+ # index entries; level 2 is what the renderer gives top-level numbered
36
+ # sections and appendices, so this is "one entry per section".
37
+ MAX_INDEX_LEVEL = 2
38
+
39
+
40
+ def _index_page(coll: Collection, docs: list[ExportDoc], now: str) -> str:
41
+ rows = "".join(
42
+ "<tr>"
43
+ f'<td class="num"><a href="{htmllib.escape(d.doc_id)}.html">'
44
+ f"{htmllib.escape(d.doc_id)}</a></td>"
45
+ f"<td>{htmllib.escape(d.title)}</td>"
46
+ f"<td>{htmllib.escape(d.status)}</td>"
47
+ f'<td class="num">{htmllib.escape(d.pub_date)}</td>'
48
+ f"{'current' if d.current else 'obsolete'}"
49
+ "</tr>" for d in docs)
50
+ return f"""<!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="utf-8"/>
54
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
55
+ <title>{htmllib.escape(coll.name)} — rfclib docset</title>
56
+ <link rel="stylesheet" type="text/css" href="style.css"/>
57
+ </head>
58
+ <body>
59
+ <h1>{htmllib.escape(coll.name)}</h1>
60
+ <p class="meta">{len(docs)} documents · generated {now} · Dash docset
61
+ (lookup via Dash; anchors link into sections)</p>
62
+ <p>{htmllib.escape(coll.description)}</p>
63
+ <table class="index">
64
+ <tr><th>Doc</th><th>Title</th><th>Status</th><th>Published</th><th></th></tr>
65
+ {rows}
66
+ </table>
67
+ </body>
68
+ </html>
69
+ """
70
+
71
+
72
+ def _info_plist(coll: Collection, now: str) -> dict:
73
+ return {
74
+ "CFBundleIdentifier": f"org.rfclib.{coll.id}",
75
+ "CFBundleName": coll.name,
76
+ "CFBundleDevelopmentRegion": "en",
77
+ "CFBundleIconFile": "icon.png",
78
+ # Xcode reads docset_platform; Dash ignores unknown keys.
79
+ "docset_platform": {
80
+ "platform_family": "Common",
81
+ "docset_name": coll.id,
82
+ "dashboard_name": coll.name,
83
+ "docset_article_path": "Documents",
84
+ },
85
+ # Dash-specific markers.
86
+ "isDashDocset": True,
87
+ "dashDocsetCompat": ["Common"],
88
+ "docset_icon": "icon.png",
89
+ "docset_taxonomy": "guides;reference;standards",
90
+ "dash_docset_date": now,
91
+ }
92
+
93
+
94
+ def _build_dsidx(db_path: Path, docs: list[ExportDoc],
95
+ toc: dict[str, list[tuple[int, str, str]]]) -> int:
96
+ """Write the searchIndex table; returns the number of index rows."""
97
+ db_path.parent.mkdir(parents=True, exist_ok=True)
98
+ conn = sqlite3.connect(str(db_path))
99
+ try:
100
+ conn.executescript(
101
+ "DROP TABLE IF EXISTS searchIndex;"
102
+ "CREATE TABLE searchIndex(id INTEGER PRIMARY KEY,"
103
+ " word TEXT, type TEXT, url TEXT);"
104
+ "CREATE INDEX word ON searchIndex (word);")
105
+ rows: list[tuple[str, str, str]] = []
106
+ for doc in docs:
107
+ fname = f"{doc.doc_id}.html"
108
+ rows.append((f"{doc.doc_id}: {doc.title}", "Guide", fname))
109
+ for level, hid, text in toc.get(doc.doc_id, []):
110
+ if level > MAX_INDEX_LEVEL or hid.startswith("toc"):
111
+ continue
112
+ rows.append((f"{doc.doc_id}: {text}", "Section",
113
+ f"{fname}#{hid}"))
114
+ conn.executemany(
115
+ "INSERT INTO searchIndex(word, type, url) VALUES (?,?,?)", rows)
116
+ conn.commit()
117
+ finally:
118
+ conn.close()
119
+ return len(rows)
120
+
121
+
122
+ def build_docset(coll: Collection, docs: list[ExportDoc], out_dir: Path,
123
+ link_refs: str = "internal",
124
+ progress: Optional[Callable[[str], None]] = None) -> dict:
125
+ """Write <out_dir>/<coll.id>.docset (+ .tgz); summary dict like build_html."""
126
+ root = Path(out_dir) / f"{coll.id}.docset"
127
+ res_dir = root / "Contents" / "Resources"
128
+ docs_dir = res_dir / "Documents"
129
+ # Stale files must not survive a rebuild — Dash would keep dead entries.
130
+ if root.exists():
131
+ shutil.rmtree(root)
132
+ docs_dir.mkdir(parents=True, exist_ok=True)
133
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
134
+
135
+ known = {d.doc_id: f"{d.doc_id}.html" for d in docs}
136
+ resolver = make_resolver(known, link_refs)
137
+
138
+ (root / "Contents" / "Info.plist").write_bytes(
139
+ plistlib.dumps(_info_plist(coll, now)))
140
+ (res_dir / "icon.png").write_bytes(_png_illustration(32))
141
+ (docs_dir / "style.css").write_text(STYLE_CSS, "utf-8")
142
+ idx = _index_page(coll, docs, now)
143
+ (docs_dir / "index.html").write_text(idx, "utf-8")
144
+
145
+ nbytes = len(idx.encode("utf-8")) + len(STYLE_CSS)
146
+ toc: dict[str, list[tuple[int, str, str]]] = {}
147
+ for n, doc in enumerate(docs, 1):
148
+ page, headings = render_doc_page(doc, resolver, css_href="style.css",
149
+ back_href="index.html")
150
+ (docs_dir / f"{doc.doc_id}.html").write_text(page, "utf-8")
151
+ toc[doc.doc_id] = headings
152
+ nbytes += len(page.encode("utf-8"))
153
+ if progress:
154
+ progress(f"docset {n}/{len(docs)} {doc.doc_id}")
155
+
156
+ n_entries = _build_dsidx(res_dir / "docSet.dsidx", docs, toc)
157
+
158
+ tgz = Path(out_dir) / f"{coll.id}.docset.tgz"
159
+ with tarfile.open(tgz, "w:gz") as tar:
160
+ tar.add(root, arcname=f"{coll.id}.docset")
161
+ return {"id": coll.id, "format": "docset", "path": str(root),
162
+ "docs": len(docs), "bytes": tgz.stat().st_size,
163
+ "entries": n_entries}