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,162 @@
1
+ """Fetch and parse the official RFC Index manifest (rfc-index.xml).
2
+
3
+ Source of truth: https://www.rfc-editor.org/rfc-index.xml
4
+ Schema: https://www.rfc-editor.org/rfc-index.xsd
5
+
6
+ The manifest lists every published document (RFC/BCP/STD/FYI) with title,
7
+ authors, dates, statuses, stream, abstract, and the full obsoletes/updates/
8
+ is-also relation graph.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ import xml.etree.ElementTree as ET
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ from ..config import MANIFEST_URL, USER_AGENT, network_mode, require_network
20
+ from ..fsutil import atomic_write_bytes
21
+
22
+ NS = "{https://www.rfc-editor.org/rfc-index}"
23
+
24
+ # entry tag -> series code ('rfc-not-issued-entry' is intentionally skipped)
25
+ _SERIES_BY_TAG = {
26
+ "rfc-entry": "RFC",
27
+ "bcp-entry": "BCP",
28
+ "std-entry": "STD",
29
+ "fyi-entry": "FYI",
30
+ "ien-entry": "IEN",
31
+ }
32
+
33
+ _REL_TAGS = {
34
+ "obsoletes": "obsoletes",
35
+ "obsoleted-by": "obsoleted_by",
36
+ "updates": "updates",
37
+ "updated-by": "updated_by",
38
+ "is-also": "alias",
39
+ }
40
+
41
+ _MONTHS = {
42
+ m: i for i, m in enumerate(
43
+ ["January", "February", "March", "April", "May", "June",
44
+ "July", "August", "September", "October", "November", "December"], 1)
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class DocMeta:
50
+ doc_id: str
51
+ series: str
52
+ title: str | None = None
53
+ authors: list[dict] = field(default_factory=list)
54
+ pub_date: str | None = None # ISO 'YYYY-MM-01' when known
55
+ status: str | None = None # publication-status at time of publishing
56
+ current_status: str | None = None # status today (may differ if promoted)
57
+ stream: str | None = None
58
+ wg: str | None = None
59
+ area: str | None = None
60
+ doi: str | None = None
61
+ errata_url: str | None = None
62
+ page_count: int | None = None
63
+ keywords: list[str] = field(default_factory=list)
64
+ abstract: str | None = None
65
+ formats: list[str] = field(default_factory=list)
66
+ draft_name: str | None = None
67
+ relations: list[tuple[str, str]] = field(default_factory=list) # (kind, target)
68
+
69
+
70
+ def download_manifest(dest: Path, force: bool = False, max_age_hours: float = 24.0) -> Path:
71
+ """Download rfc-index.xml into dest (a file path), reusing a fresh copy."""
72
+ dest.parent.mkdir(parents=True, exist_ok=True)
73
+ if not force and dest.exists():
74
+ age_h = (time.time() - dest.stat().st_mtime) / 3600.0
75
+ if age_h < max_age_hours:
76
+ return dest
77
+ if dest.exists() and network_mode() == "offline":
78
+ return dest # stale but usable; offline means offline
79
+ require_network("the RFC index (rfc-index.xml)")
80
+ import httpx # lazy: serving from an existing library never needs it
81
+ with httpx.Client(follow_redirects=True, timeout=120.0) as client:
82
+ r = client.get(MANIFEST_URL, headers={"User-Agent": USER_AGENT})
83
+ r.raise_for_status()
84
+ atomic_write_bytes(dest, r.content)
85
+ return dest
86
+
87
+
88
+ def _text(el: ET.Element | None) -> str | None:
89
+ if el is None or el.text is None:
90
+ return None
91
+ return el.text.strip() or None
92
+
93
+
94
+ def _parse_entry(entry: ET.Element, series: str) -> DocMeta | None:
95
+ doc_id = _text(entry.find(f"{NS}doc-id"))
96
+ if not doc_id:
97
+ return None
98
+ meta = DocMeta(doc_id=doc_id.strip().upper(), series=series)
99
+ meta.title = _text(entry.find(f"{NS}title"))
100
+
101
+ for a in entry.findall(f"{NS}author"):
102
+ name = _text(a.find(f"{NS}name"))
103
+ if name:
104
+ meta.authors.append({"name": name, "role": _text(a.find(f"{NS}title"))})
105
+
106
+ date = entry.find(f"{NS}date")
107
+ if date is not None:
108
+ year = _text(date.find(f"{NS}year"))
109
+ month = _MONTHS.get(_text(date.find(f"{NS}month")) or "", None)
110
+ if year:
111
+ meta.pub_date = f"{year}-{month:02d}-01" if month else f"{year}-01-01"
112
+
113
+ meta.status = _text(entry.find(f"{NS}publication-status"))
114
+ meta.current_status = _text(entry.find(f"{NS}current-status"))
115
+ meta.stream = _text(entry.find(f"{NS}stream"))
116
+ wg = _text(entry.find(f"{NS}wg_acronym"))
117
+ meta.wg = None if wg and wg.upper() == "NON WORKING GROUP" else wg
118
+ meta.area = _text(entry.find(f"{NS}area"))
119
+ meta.doi = _text(entry.find(f"{NS}doi"))
120
+ meta.errata_url = _text(entry.find(f"{NS}errata-url"))
121
+ meta.draft_name = _text(entry.find(f"{NS}draft"))
122
+ pc = _text(entry.find(f"{NS}page-count"))
123
+ if pc and pc.isdigit():
124
+ meta.page_count = int(pc)
125
+
126
+ fmt = entry.find(f"{NS}format")
127
+ if fmt is not None:
128
+ meta.formats = [t.strip().lower() for t in
129
+ (x.text or "" for x in fmt.findall(f"{NS}file-format")) if t]
130
+
131
+ kw = entry.find(f"{NS}keywords")
132
+ if kw is not None:
133
+ meta.keywords = [t for t in (_text(x) for x in kw.findall(f"{NS}kw")) if t]
134
+
135
+ abstract = entry.find(f"{NS}abstract")
136
+ if abstract is not None:
137
+ paras = [_text(p) for p in abstract.findall(f"{NS}p")]
138
+ joined = "\n\n".join(p for p in paras if p)
139
+ meta.abstract = joined or None
140
+
141
+ for tag, kind in _REL_TAGS.items():
142
+ rel_el = entry.find(f"{NS}{tag}")
143
+ if rel_el is not None:
144
+ for d in rel_el.findall(f"{NS}doc-id"):
145
+ target = _text(d)
146
+ if target:
147
+ meta.relations.append((kind, target.strip().upper()))
148
+ return meta
149
+
150
+
151
+ def parse_manifest(path: Path) -> list[DocMeta]:
152
+ root = ET.parse(path).getroot()
153
+ out: list[DocMeta] = []
154
+ for entry in root:
155
+ tag = entry.tag.split("}")[-1]
156
+ series = _SERIES_BY_TAG.get(tag)
157
+ if series is None:
158
+ continue # e.g. rfc-not-issued-entry
159
+ meta = _parse_entry(entry, series)
160
+ if meta is not None:
161
+ out.append(meta)
162
+ return out
@@ -0,0 +1,49 @@
1
+ """Bootstrap the file inventory from an rsync-produced local mirror.
2
+
3
+ Run after scripts/mirror.ps1 completes: walks the mirror directory, finds all
4
+ document files (rfcNNNN.txt / bcpNN.html / ... at any nesting depth so both
5
+ possible rsync layouts work), hashes them, and registers them in the DB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sqlite3
12
+ from pathlib import Path
13
+
14
+ from .downloader import sha256_file
15
+ from .state import register_file
16
+
17
+ _PREFIX_TO_SERIES = {"rfc": "RFC", "bcp": "BCP", "std": "STD",
18
+ "fyi": "FYI", "ien": "IEN"}
19
+ _FILE_RE = re.compile(
20
+ r"^(rfc|bcp|std|fyi|ien)(\d{1,5})\.(txt|html|xml|pdf|json)$", re.IGNORECASE)
21
+
22
+
23
+ def scan_mirror(conn: sqlite3.Connection, mirror_root: Path) -> dict:
24
+ stats = {"scanned": 0, "registered": 0, "skipped_unmatched": 0, "skipped_unknown_doc": 0}
25
+ has_docs = conn.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"] > 0
26
+ batch: list[tuple] = []
27
+ for path in mirror_root.rglob("*"):
28
+ if not path.is_file():
29
+ continue
30
+ m = _FILE_RE.match(path.name)
31
+ if not m:
32
+ stats["skipped_unmatched"] += 1
33
+ continue
34
+ doc_id = _PREFIX_TO_SERIES[m.group(1).lower()] + m.group(2)
35
+ fmt = m.group(3).lower()
36
+ # Only register docs known from the manifest (guards against stray
37
+ # files); if the manifest hasn't been loaded yet, register everything.
38
+ if has_docs and not conn.execute(
39
+ "SELECT 1 FROM docs WHERE doc_id = ?", (doc_id,)).fetchone():
40
+ stats["skipped_unknown_doc"] += 1
41
+ continue
42
+ batch.append((doc_id, fmt, str(path), sha256_file(path), path.stat().st_size))
43
+ stats["scanned"] += 1
44
+
45
+ for doc_id, fmt, pth, digest, size in batch:
46
+ register_file(conn, doc_id, fmt, pth, digest, size, None, "mirror-scan")
47
+ stats["registered"] += 1
48
+ conn.commit()
49
+ return stats
@@ -0,0 +1,464 @@
1
+ """SQLite state: document metadata, relation graph, local file inventory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path, PurePosixPath, PureWindowsPath
10
+ from typing import Iterable
11
+
12
+ from .manifest import DocMeta
13
+
14
+ SCHEMA = """
15
+ CREATE TABLE IF NOT EXISTS docs(
16
+ doc_id TEXT PRIMARY KEY,
17
+ series TEXT NOT NULL,
18
+ title TEXT,
19
+ authors TEXT, -- JSON list
20
+ pub_date TEXT, -- ISO YYYY-MM-01
21
+ status TEXT, -- publication status at publication
22
+ current_status TEXT, -- status today
23
+ stream TEXT,
24
+ wg TEXT,
25
+ area TEXT,
26
+ doi TEXT,
27
+ errata_url TEXT,
28
+ page_count INTEGER,
29
+ keywords TEXT,
30
+ abstract TEXT,
31
+ formats TEXT, -- JSON list of formats offered upstream
32
+ draft_name TEXT,
33
+ is_current INTEGER NOT NULL DEFAULT 1,
34
+ synced_at TEXT
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_docs_pub ON docs(pub_date DESC);
37
+ CREATE INDEX IF NOT EXISTS idx_docs_series ON docs(series);
38
+
39
+ CREATE TABLE IF NOT EXISTS doc_relations(
40
+ src TEXT NOT NULL,
41
+ dst TEXT NOT NULL,
42
+ kind TEXT NOT NULL, -- obsoletes|obsoleted_by|updates|updated_by|alias
43
+ PRIMARY KEY (src, dst, kind)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_rel_dst ON doc_relations(dst, kind);
46
+
47
+ CREATE TABLE IF NOT EXISTS files(
48
+ doc_id TEXT NOT NULL,
49
+ fmt TEXT NOT NULL, -- txt|html|xml|pdf|json
50
+ path TEXT NOT NULL,
51
+ sha256 TEXT,
52
+ size INTEGER,
53
+ source_url TEXT,
54
+ fetched_at TEXT,
55
+ origin TEXT, -- 'http' | 'mirror-scan'
56
+ PRIMARY KEY (doc_id, fmt)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT);
60
+
61
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
62
+ doc_id UNINDEXED, title, abstract, keywords,
63
+ tokenize='porter unicode61'
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS chunks(
67
+ id INTEGER PRIMARY KEY,
68
+ doc_id TEXT NOT NULL,
69
+ sec_num TEXT NOT NULL, -- '1','4.2','Appendix A','' = front matter
70
+ sec_title TEXT NOT NULL,
71
+ start_line INTEGER NOT NULL, -- 1-based line range in the .txt file
72
+ end_line INTEGER NOT NULL,
73
+ body TEXT NOT NULL, -- junk-stripped section text
74
+ sha256 TEXT NOT NULL, -- content hash (embed staleness)
75
+ -- External-content FTS5 maps by COLUMN NAME, so provide the names:
76
+ sec TEXT GENERATED ALWAYS AS (trim(sec_num || ' ' || sec_title)) STORED
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
79
+
80
+ CREATE TABLE IF NOT EXISTS chunk_src(
81
+ doc_id TEXT PRIMARY KEY,
82
+ sha256 TEXT NOT NULL -- source-file hash; skip unchanged
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS embed_state(
86
+ chunk_id INTEGER PRIMARY KEY,
87
+ sha TEXT NOT NULL,
88
+ model TEXT,
89
+ at TEXT
90
+ );
91
+
92
+ -- Cross-reference graph mined from RFC txt sources (see rfclib.graph).
93
+ -- kind: normative|informative|reference (References-section tag) or
94
+ -- mention (section-anchored reference found anywhere in the body).
95
+ -- sec: referenced section number, '' when the citation is whole-doc.
96
+ CREATE TABLE IF NOT EXISTS ref_edges(
97
+ src TEXT NOT NULL,
98
+ dst TEXT NOT NULL,
99
+ kind TEXT NOT NULL,
100
+ sec TEXT NOT NULL DEFAULT '',
101
+ PRIMARY KEY (src, dst, kind, sec)
102
+ );
103
+ CREATE INDEX IF NOT EXISTS idx_refs_dst ON ref_edges(dst, kind);
104
+ CREATE INDEX IF NOT EXISTS idx_refs_src ON ref_edges(src, kind);
105
+
106
+ CREATE TABLE IF NOT EXISTS ref_src(
107
+ doc_id TEXT PRIMARY KEY,
108
+ sha256 TEXT NOT NULL -- source .txt hash; skip unchanged
109
+ );
110
+
111
+ -- Verified/reported errata cached from the RFC Editor errata service
112
+ -- (errata.rfc-editor.org); fetched per document on demand, never bulk.
113
+ CREATE TABLE IF NOT EXISTS errata(
114
+ eid INTEGER PRIMARY KEY,
115
+ doc_id TEXT NOT NULL,
116
+ status TEXT NOT NULL, -- Verified|Reported|Held for Document Update|Rejected
117
+ type TEXT, -- Technical|Editorial
118
+ section TEXT,
119
+ says TEXT,
120
+ should_say TEXT,
121
+ notes TEXT,
122
+ reported_by TEXT,
123
+ reported_date TEXT,
124
+ handler TEXT, -- verified/rejected by
125
+ handler_date TEXT,
126
+ fetched_at TEXT
127
+ );
128
+ CREATE INDEX IF NOT EXISTS idx_errata_doc ON errata(doc_id, status);
129
+
130
+ -- Citations made BY a project (code or docs) INTO this library, recorded so
131
+ -- drift is detectable: rfclib.cite.lint re-checks them after a sync and
132
+ -- reports obsolete targets, vanished sections or changed span text. Storing
133
+ -- the span hash is what makes "did the spec move under this comment?" a
134
+ -- lookup instead of a re-reading job.
135
+ CREATE TABLE IF NOT EXISTS citations(
136
+ id INTEGER PRIMARY KEY,
137
+ project TEXT NOT NULL, -- label, e.g. 'rwhois' or a file path
138
+ location TEXT NOT NULL, -- 'path:lineno' or 'module.symbol'
139
+ doc_id TEXT NOT NULL,
140
+ sec_num TEXT NOT NULL DEFAULT '',
141
+ span_sha TEXT NOT NULL DEFAULT '', -- span_hash of the cited text, '' = doc-wide
142
+ note TEXT,
143
+ status TEXT, -- ok|obsolete|missing-section|drift
144
+ checked_at TEXT,
145
+ UNIQUE (project, location, doc_id, sec_num)
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_cite_doc ON citations(doc_id, sec_num);
148
+
149
+ -- Paragraph-level structure from RFCXML v3 (RFC 8650+; rfclib.index.rfcxml).
150
+ -- pn is the RFC Editor's own block id and HTML anchor ('section-9.6-3');
151
+ -- start/end_line point into the .txt exactly like chunks do. exact=0 means
152
+ -- the range was interpolated between aligned neighbours.
153
+ CREATE TABLE IF NOT EXISTS blocks(
154
+ doc_id TEXT NOT NULL,
155
+ pn TEXT NOT NULL,
156
+ ord INTEGER NOT NULL, -- document order
157
+ kind TEXT NOT NULL, -- t|ul|ol|dl|sourcecode|artwork|table|reference|author|...
158
+ sec_num TEXT NOT NULL, -- same vocabulary as chunks.sec_num
159
+ sec_title TEXT NOT NULL,
160
+ sec_anchor TEXT NOT NULL DEFAULT '', -- author anchor, e.g. 'migration'
161
+ start_line INTEGER NOT NULL,
162
+ end_line INTEGER NOT NULL,
163
+ text TEXT NOT NULL,
164
+ bcp14 TEXT NOT NULL DEFAULT '', -- 'MUST,SHOULD NOT' from <bcp14>
165
+ lang TEXT NOT NULL DEFAULT '', -- sourcecode/artwork type ('abnf')
166
+ exact INTEGER NOT NULL DEFAULT 1,
167
+ PRIMARY KEY (doc_id, pn)
168
+ );
169
+ CREATE INDEX IF NOT EXISTS idx_blocks_sec ON blocks(doc_id, sec_num);
170
+
171
+ -- Section-precise citations from <xref target=REF section="7.7">: which
172
+ -- block of doc_id cites which section of dst_doc ('' = whole document).
173
+ CREATE TABLE IF NOT EXISTS xrefs(
174
+ doc_id TEXT NOT NULL,
175
+ src_pn TEXT NOT NULL,
176
+ src_sec TEXT NOT NULL,
177
+ dst_doc TEXT NOT NULL,
178
+ dst_sec TEXT NOT NULL DEFAULT '',
179
+ ref_anchor TEXT NOT NULL, -- bib anchor, e.g. 'QUIC-RECOVERY'
180
+ normative INTEGER NOT NULL DEFAULT 0,
181
+ PRIMARY KEY (doc_id, src_pn, dst_doc, dst_sec)
182
+ );
183
+ CREATE INDEX IF NOT EXISTS idx_xrefs_dst ON xrefs(dst_doc, dst_sec);
184
+
185
+ -- How each document was segmented (rfclib.index.txtseg for txt-only docs,
186
+ -- 'rfcxml' for RFC 8650+). score = ToC entries located in the body / ToC
187
+ -- entries (NULL when the document has no usable ToC): the review list for
188
+ -- segmentations that may be wrong.
189
+ CREATE TABLE IF NOT EXISTS seg_quality(
190
+ doc_id TEXT PRIMARY KEY,
191
+ method TEXT NOT NULL, -- rfcxml | toc | scan | legacy
192
+ toc_entries INTEGER NOT NULL DEFAULT 0,
193
+ matched INTEGER NOT NULL DEFAULT 0,
194
+ extra INTEGER NOT NULL DEFAULT 0, -- headings deeper than the ToC lists
195
+ headings INTEGER NOT NULL DEFAULT 0,
196
+ score REAL,
197
+ notes TEXT NOT NULL DEFAULT ''
198
+ );
199
+
200
+ -- Identifier tokens per section (rfclib.index.lexical): '_' and '-' stay
201
+ -- inside tokens, no stemming, so "preferred_address" matches only itself.
202
+ -- rowid == chunks.id; ident_none marks sections with nothing to index.
203
+ CREATE VIRTUAL TABLE IF NOT EXISTS ident_fts USING fts5(
204
+ terms, tokenize = "unicode61 tokenchars '_-' remove_diacritics 0"
205
+ );
206
+ CREATE TABLE IF NOT EXISTS ident_none(chunk_id INTEGER PRIMARY KEY);
207
+
208
+ -- "Network Time Protocol (NTP)" pairs mined from the corpus; a query
209
+ -- acronym also matches its expansion.
210
+ CREATE TABLE IF NOT EXISTS acronyms(
211
+ acro TEXT PRIMARY KEY, -- 'NTP'
212
+ expansion TEXT NOT NULL, -- 'network time protocol'
213
+ n INTEGER NOT NULL -- times seen
214
+ );
215
+
216
+ -- External-content FTS5 index over chunk bodies; rowid == chunks.id.
217
+ -- Rebuild with: INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');
218
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
219
+ sec, body, content='chunks', content_rowid='id',
220
+ tokenize='porter unicode61 remove_diacritics 2'
221
+ );
222
+ """
223
+
224
+
225
+ def open_db(path: Path, read_only: bool = False) -> sqlite3.Connection:
226
+ """read_only: open with SQLite's mode=ro and skip schema creation and
227
+ migrations, for a server that must never write (the library has to
228
+ exist already). Readers still wait for a writer via busy_timeout."""
229
+ if read_only:
230
+ try:
231
+ conn = sqlite3.connect(f"{Path(path).resolve().as_uri()}?mode=ro",
232
+ uri=True, timeout=30)
233
+ except sqlite3.OperationalError as e:
234
+ raise sqlite3.OperationalError(
235
+ f"{e}: {path} (a read-only server needs an existing "
236
+ "library; run `rfclib init` or start the shared server "
237
+ "first)") from None
238
+ conn.row_factory = sqlite3.Row
239
+ conn.execute("PRAGMA busy_timeout=30000")
240
+ return conn
241
+ path.parent.mkdir(parents=True, exist_ok=True)
242
+ # Several processes share one library (an MCP server per client session,
243
+ # plus `rfclib sync`): wait for a writer instead of failing at once.
244
+ conn = sqlite3.connect(path, timeout=30)
245
+ conn.row_factory = sqlite3.Row
246
+ conn.execute("PRAGMA journal_mode=WAL")
247
+ conn.execute("PRAGMA busy_timeout=30000")
248
+ # Migration: old chunks schema lacked the name-mapped FTS columns.
249
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(chunks)")]
250
+ if cols and "body" not in cols:
251
+ conn.executescript(
252
+ "DROP TABLE IF EXISTS chunks_fts; DROP TABLE IF EXISTS chunks;"
253
+ "DROP TABLE IF EXISTS chunk_src; DELETE FROM embed_state;")
254
+ conn.executescript(SCHEMA)
255
+ if get_meta(conn, "files_paths") != "relative":
256
+ _relativize_paths(conn)
257
+ # every table a read path expects exists once a writer has opened the
258
+ # library, so a read-only server never has to create one
259
+ from ..subjects import ensure_schema as _subjects_schema
260
+ _subjects_schema(conn)
261
+ return conn
262
+
263
+
264
+ # --------------------------------------------------------------------------
265
+ # file paths: stored relative to <home>/mirror in POSIX form ('rfc/rfc9000.txt')
266
+ # so one library works after a move, on another OS, or mounted in a container.
267
+ # Absolute paths (files registered from outside the mirror) are kept as-is.
268
+
269
+ def _is_abs(stored: str) -> bool:
270
+ return PureWindowsPath(stored).is_absolute() or stored.startswith("/")
271
+
272
+
273
+ def mirror_of(conn: sqlite3.Connection) -> Path:
274
+ """<home>/mirror for the library this connection belongs to."""
275
+ row = conn.execute("PRAGMA database_list").fetchone()
276
+ return Path(row[2]).parent / "mirror"
277
+
278
+
279
+ def stored_path(conn: sqlite3.Connection, path: Path | str) -> str:
280
+ """The form a file path is recorded in: relative to the mirror when the
281
+ file lives under it, else absolute."""
282
+ p = Path(path)
283
+ try:
284
+ return p.resolve().relative_to(mirror_of(conn).resolve()).as_posix()
285
+ except (ValueError, OSError):
286
+ return str(p)
287
+
288
+
289
+ def file_path(conn: sqlite3.Connection, stored: str,
290
+ mirror: Path | None = None) -> Path:
291
+ """Resolve a recorded files.path to a real path on this machine."""
292
+ if _is_abs(stored):
293
+ return Path(stored)
294
+ return (mirror or mirror_of(conn)) / PurePosixPath(stored)
295
+
296
+
297
+ def _relativize_paths(conn: sqlite3.Connection) -> None:
298
+ """One-time migration of absolute paths written by older versions
299
+ ('D:\\code\\rfclibrary\\data\\mirror\\rfc\\rfc9000.txt', '/home/mirror/...'):
300
+ everything after the last 'mirror' path segment becomes the relative
301
+ path, whichever OS wrote it."""
302
+ rows = conn.execute("SELECT doc_id, fmt, path FROM files").fetchall()
303
+ upd = []
304
+ for r in rows:
305
+ s = r["path"] or ""
306
+ if not _is_abs(s):
307
+ continue
308
+ parts = [x for x in re.split(r"[\\/]+", s) if x]
309
+ if "mirror" in parts:
310
+ i = len(parts) - 1 - parts[::-1].index("mirror")
311
+ if i + 1 < len(parts):
312
+ upd.append(("/".join(parts[i + 1:]), r["doc_id"], r["fmt"]))
313
+ conn.executemany("UPDATE files SET path=? WHERE doc_id=? AND fmt=?", upd)
314
+ set_meta(conn, "files_paths", "relative")
315
+
316
+
317
+ def _now() -> str:
318
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
319
+
320
+
321
+ def upsert_docs(conn: sqlite3.Connection, docs: Iterable[DocMeta]) -> tuple[int, int]:
322
+ now = _now()
323
+ doc_rows, rel_rows, ids = [], [], []
324
+ for d in docs:
325
+ ids.append(d.doc_id)
326
+ doc_rows.append((
327
+ d.doc_id, d.series, d.title, json.dumps(d.authors), d.pub_date,
328
+ d.status, d.current_status, d.stream, d.wg, d.area, d.doi,
329
+ d.errata_url, d.page_count, " ".join(d.keywords), d.abstract,
330
+ json.dumps(d.formats), d.draft_name, 1, now,
331
+ ))
332
+ for kind, target in d.relations:
333
+ rel_rows.append((d.doc_id, target, kind))
334
+
335
+ conn.executemany(
336
+ """INSERT INTO docs(doc_id, series, title, authors, pub_date, status,
337
+ current_status, stream, wg, area, doi, errata_url,
338
+ page_count, keywords, abstract, formats, draft_name,
339
+ is_current, synced_at)
340
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
341
+ ON CONFLICT(doc_id) DO UPDATE SET
342
+ series=excluded.series, title=excluded.title, authors=excluded.authors,
343
+ pub_date=excluded.pub_date, status=excluded.status,
344
+ current_status=excluded.current_status, stream=excluded.stream,
345
+ wg=excluded.wg, area=excluded.area, doi=excluded.doi,
346
+ errata_url=excluded.errata_url, page_count=excluded.page_count,
347
+ keywords=excluded.keywords, abstract=excluded.abstract,
348
+ formats=excluded.formats, draft_name=excluded.draft_name,
349
+ synced_at=excluded.synced_at""",
350
+ doc_rows,
351
+ )
352
+ if ids:
353
+ placeholders = ",".join("?" * len(ids))
354
+ conn.execute(f"DELETE FROM doc_relations WHERE src IN ({placeholders})", ids)
355
+ conn.executemany(
356
+ "INSERT OR IGNORE INTO doc_relations(src, dst, kind) VALUES(?,?,?)", rel_rows
357
+ )
358
+ conn.commit()
359
+ return len(doc_rows), len(rel_rows)
360
+
361
+
362
+ def refresh_current_flags(conn: sqlite3.Connection) -> int:
363
+ """A doc is not current if the manifest says it was obsoleted."""
364
+ conn.execute("UPDATE docs SET is_current = 1")
365
+ cur = conn.execute(
366
+ """UPDATE docs SET is_current = 0 WHERE doc_id IN (
367
+ SELECT src FROM doc_relations WHERE kind = 'obsoleted_by')"""
368
+ )
369
+ conn.commit()
370
+ return cur.rowcount
371
+
372
+
373
+ def rebuild_fts(conn: sqlite3.Connection) -> int:
374
+ conn.execute("DELETE FROM docs_fts")
375
+ conn.execute(
376
+ """INSERT INTO docs_fts(doc_id, title, abstract, keywords)
377
+ SELECT doc_id, COALESCE(title,''), COALESCE(abstract,''),
378
+ COALESCE(keywords,'')
379
+ FROM docs WHERE series = 'RFC' OR title IS NOT NULL"""
380
+ )
381
+ conn.commit()
382
+ return conn.execute("SELECT COUNT(*) FROM docs_fts").fetchone()[0]
383
+
384
+
385
+ def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
386
+ conn.execute(
387
+ "INSERT INTO meta(key,value) VALUES(?,?) "
388
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
389
+ conn.commit()
390
+
391
+
392
+ def get_meta(conn: sqlite3.Connection, key: str) -> str | None:
393
+ row = conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
394
+ return row["value"] if row else None
395
+
396
+
397
+ def register_file(conn: sqlite3.Connection, doc_id: str, fmt: str,
398
+ path: Path | str, sha256: str | None, size: int | None,
399
+ source_url: str | None, origin: str) -> None:
400
+ path = stored_path(conn, path)
401
+ conn.execute(
402
+ """INSERT INTO files(doc_id, fmt, path, sha256, size, source_url, fetched_at, origin)
403
+ VALUES(?,?,?,?,?,?,?,?)
404
+ ON CONFLICT(doc_id, fmt) DO UPDATE SET
405
+ path=excluded.path, sha256=excluded.sha256, size=excluded.size,
406
+ source_url=excluded.source_url, fetched_at=excluded.fetched_at,
407
+ origin=excluded.origin""",
408
+ (doc_id, fmt, path, sha256, size, source_url, _now(), origin),
409
+ )
410
+
411
+
412
+ def missing_docs(conn: sqlite3.Connection, fmts: list[str],
413
+ include_obsolete: bool = False) -> list[str]:
414
+ """doc_ids of series docs whose local copy of EVERY requested format is absent.
415
+
416
+ Docs whose manifest `formats` list offers none of the requested formats
417
+ are skipped: asking for xml would otherwise 404 on ~8,800 pre-RFC8650
418
+ documents that were never published in RFCXML. An empty list (BCP/STD/
419
+ FYI aliases) only ever has a txt rendering upstream."""
420
+ cond = "" if include_obsolete else "AND d.is_current = 1"
421
+ fmt_in = ",".join("?" * len(fmts))
422
+ unknown_ok = "1" if "txt" in fmts else "0"
423
+ rows = conn.execute(
424
+ f"""SELECT d.doc_id FROM docs d
425
+ WHERE d.series IN ('RFC','BCP','STD','FYI') {cond}
426
+ AND (SELECT COUNT(*) FROM files f
427
+ WHERE f.doc_id = d.doc_id AND f.fmt IN ({fmt_in})) = 0
428
+ AND (CASE WHEN d.formats IS NULL OR d.formats IN ('', '[]')
429
+ THEN {unknown_ok}
430
+ ELSE EXISTS (SELECT 1 FROM json_each(d.formats) j
431
+ WHERE lower(j.value) IN ({fmt_in}))
432
+ END)
433
+ ORDER BY d.pub_date DESC, d.doc_id DESC""",
434
+ fmts + fmts,
435
+ ).fetchall()
436
+ return [r["doc_id"] for r in rows]
437
+
438
+
439
+ def db_stats(conn: sqlite3.Connection) -> dict:
440
+ stats: dict = {}
441
+ stats["docs"] = conn.execute("SELECT COUNT(*) c FROM docs").fetchone()["c"]
442
+ stats["by_series"] = {
443
+ r["series"]: r["c"] for r in
444
+ conn.execute("SELECT series, COUNT(*) c FROM docs GROUP BY series")}
445
+ stats["current"] = conn.execute(
446
+ "SELECT COUNT(*) c FROM docs WHERE is_current=1").fetchone()["c"]
447
+ stats["relations"] = conn.execute(
448
+ "SELECT COUNT(*) c FROM doc_relations").fetchone()["c"]
449
+ stats["files_by_fmt"] = {
450
+ r["fmt"]: r["c"] for r in
451
+ conn.execute("SELECT fmt, COUNT(*) c FROM files GROUP BY fmt")}
452
+ stats["files_bytes"] = conn.execute(
453
+ "SELECT COALESCE(SUM(size),0) c FROM files").fetchone()["c"]
454
+ stats["newest_pub_date"] = conn.execute(
455
+ "SELECT MAX(pub_date) m FROM docs").fetchone()["m"]
456
+ stats["manifest_synced_at"] = get_meta(conn, "manifest_synced_at")
457
+ have = conn.execute(
458
+ "SELECT COUNT(*) c FROM sqlite_master WHERE type='table' "
459
+ "AND name='subjects'").fetchone()["c"]
460
+ stats["subjects"] = (conn.execute("SELECT COUNT(*) c FROM subjects")
461
+ .fetchone()["c"] if have else 0)
462
+ stats["subjects_synced_at"] = get_meta(conn, "subjects_synced_at")
463
+ return stats
464
+