primarysource 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primarysource/__init__.py +5 -0
- primarysource/bbf/__init__.py +56 -0
- primarysource/bbf/_xml.py +63 -0
- primarysource/bbf/catalogue.json +777 -0
- primarysource/bbf/catalogue.py +137 -0
- primarysource/bbf/cli.py +79 -0
- primarysource/bbf/datamodel.py +281 -0
- primarysource/bbf/importer.py +151 -0
- primarysource/bbf/library.py +380 -0
- primarysource/bbf/server.py +72 -0
- primarysource/budget.py +63 -0
- primarysource/c2sp/__init__.py +53 -0
- primarysource/c2sp/_fetch.py +5 -0
- primarysource/c2sp/catalogue.json +231 -0
- primarysource/c2sp/catalogue.py +95 -0
- primarysource/c2sp/cli.py +62 -0
- primarysource/c2sp/importer.py +90 -0
- primarysource/c2sp/library.py +309 -0
- primarysource/c2sp/markdown.py +31 -0
- primarysource/c2sp/server.py +68 -0
- primarysource/cavp/__init__.py +42 -0
- primarysource/cavp/cli.py +84 -0
- primarysource/cavp/data/vectors.toml +47 -0
- primarysource/cavp/importer.py +156 -0
- primarysource/cavp/model.py +279 -0
- primarysource/cavp/parse.py +269 -0
- primarysource/cavp/paths.py +17 -0
- primarysource/cavp/server.py +67 -0
- primarysource/cavp/tools.py +169 -0
- primarysource/cli.py +107 -0
- primarysource/cursor.py +82 -0
- primarysource/cve/__init__.py +5 -0
- primarysource/cve/__main__.py +6 -0
- primarysource/cve/cli.py +144 -0
- primarysource/cve/collection.py +20 -0
- primarysource/cve/config.py +58 -0
- primarysource/cve/errors.py +16 -0
- primarysource/cve/kernel.py +232 -0
- primarysource/cve/mcp.py +139 -0
- primarysource/cve/parse.py +318 -0
- primarysource/cve/py.typed +0 -0
- primarysource/cve/service.py +760 -0
- primarysource/cve/store.py +214 -0
- primarysource/cve/sync.py +438 -0
- primarysource/cve/views.py +304 -0
- primarysource/debpolicy/__init__.py +16 -0
- primarysource/debpolicy/catalogue.py +263 -0
- primarysource/debpolicy/cli.py +12 -0
- primarysource/debpolicy/server.py +11 -0
- primarysource/draft/__init__.py +11 -0
- primarysource/draft/catalogue.py +117 -0
- primarysource/draft/cli.py +149 -0
- primarysource/draft/collection.py +21 -0
- primarysource/draft/datatracker.py +158 -0
- primarysource/draft/importer.py +163 -0
- primarysource/draft/licence.py +89 -0
- primarysource/draft/names.py +64 -0
- primarysource/draft/net.py +133 -0
- primarysource/draft/segment.py +192 -0
- primarysource/draft/server.py +91 -0
- primarysource/draft/status.py +146 -0
- primarysource/draft/store.py +199 -0
- primarysource/draft/tracked.json +104 -0
- primarysource/draft/views.py +430 -0
- primarysource/draft/xmlmeta.py +74 -0
- primarysource/ecma/__init__.py +52 -0
- primarysource/ecma/_pdf.py +143 -0
- primarysource/ecma/catalogue.json +22 -0
- primarysource/ecma/catalogue.py +100 -0
- primarysource/ecma/cli.py +67 -0
- primarysource/ecma/identify.py +25 -0
- primarysource/ecma/importer.py +108 -0
- primarysource/ecma/library.py +240 -0
- primarysource/ecma/server.py +67 -0
- primarysource/errors.py +29 -0
- primarysource/fetch.py +360 -0
- primarysource/fsutil.py +150 -0
- primarysource/gitdocs/__init__.py +15 -0
- primarysource/gitdocs/catalogue.py +116 -0
- primarysource/gitdocs/cli.py +12 -0
- primarysource/gitdocs/server.py +8 -0
- primarysource/home.py +65 -0
- primarysource/iana/__init__.py +32 -0
- primarysource/iana/catalogue.json +244 -0
- primarysource/iana/catalogue.py +69 -0
- primarysource/iana/cli.py +69 -0
- primarysource/iana/importer.py +157 -0
- primarysource/iana/model.py +325 -0
- primarysource/iana/reader.py +315 -0
- primarysource/iana/server.py +67 -0
- primarysource/iana/store.py +83 -0
- primarysource/iana/xmlsafe.py +47 -0
- primarysource/identify.py +186 -0
- primarysource/ieee/__init__.py +58 -0
- primarysource/ieee/_html.py +49 -0
- primarysource/ieee/catalogue.json +74 -0
- primarysource/ieee/catalogue.py +123 -0
- primarysource/ieee/cli.py +79 -0
- primarysource/ieee/identify.py +24 -0
- primarysource/ieee/ieee802.py +47 -0
- primarysource/ieee/importer.py +305 -0
- primarysource/ieee/posix.py +233 -0
- primarysource/ieee/server.py +79 -0
- primarysource/index.py +446 -0
- primarysource/iso/__init__.py +51 -0
- primarysource/iso/cli.py +98 -0
- primarysource/iso/designation.py +95 -0
- primarysource/iso/identify.py +100 -0
- primarysource/iso/importer.py +224 -0
- primarysource/iso/library.py +249 -0
- primarysource/iso/server.py +69 -0
- primarysource/itu/__init__.py +41 -0
- primarysource/itu/__main__.py +8 -0
- primarysource/itu/asn1mod.py +478 -0
- primarysource/itu/catalog.py +472 -0
- primarysource/itu/cli.py +445 -0
- primarysource/itu/collection.py +20 -0
- primarysource/itu/corpus.py +118 -0
- primarysource/itu/errata.py +16 -0
- primarysource/itu/formal.py +565 -0
- primarysource/itu/identify.py +26 -0
- primarysource/itu/index.py +490 -0
- primarysource/itu/mcp.py +474 -0
- primarysource/itu/py.typed +0 -0
- primarysource/itu/sources.py +600 -0
- primarysource/itu/text.py +609 -0
- primarysource/kconfig/__init__.py +16 -0
- primarysource/kconfig/catalogue.py +98 -0
- primarysource/kconfig/cli.py +12 -0
- primarysource/kconfig/server.py +8 -0
- primarysource/model.py +63 -0
- primarysource/nist/__init__.py +5 -0
- primarysource/nist/collection.py +46 -0
- primarysource/nist/data/acquired-nist.json +1657 -0
- primarysource/nist/data/nist-catalog.json +12090 -0
- primarysource/nist/data/sources.toml +1996 -0
- primarysource/nist/identify.py +41 -0
- primarysource/nist/ingest/__init__.py +1 -0
- primarysource/nist/ingest/acquire_nist.py +128 -0
- primarysource/nist/ingest/controls.py +82 -0
- primarysource/nist/ingest/discover_nist.py +218 -0
- primarysource/nist/ingest/extract.py +48 -0
- primarysource/nist/ingest/fetch.py +211 -0
- primarysource/nist/ingest/index.py +14 -0
- primarysource/nist/ingest/oscal.py +480 -0
- primarysource/nist/ingest/prose_layouts.py +236 -0
- primarysource/nist/ingest/sources.py +94 -0
- primarysource/nist/ingest/status.py +255 -0
- primarysource/nist/models.py +7 -0
- primarysource/nist/paths.py +33 -0
- primarysource/nist/server/__init__.py +3 -0
- primarysource/nist/server/corpus.py +38 -0
- primarysource/nist/server/mcp.py +19 -0
- primarysource/nist/server/tools.py +35 -0
- primarysource/nist/spec.py +38 -0
- primarysource/pdfcorpus/__init__.py +16 -0
- primarysource/pdfcorpus/corpus.py +159 -0
- primarysource/pdfcorpus/fts.py +175 -0
- primarysource/pdfcorpus/index.py +70 -0
- primarysource/pdfcorpus/models.py +30 -0
- primarysource/pdfcorpus/server.py +96 -0
- primarysource/pdfcorpus/spec.py +37 -0
- primarysource/pdfcorpus/split.py +391 -0
- primarysource/pdfcorpus/tools.py +244 -0
- primarysource/pep/__init__.py +15 -0
- primarysource/pep/catalogue.py +191 -0
- primarysource/pep/cli.py +12 -0
- primarysource/pep/server.py +8 -0
- primarysource/pkgdocs/__init__.py +19 -0
- primarysource/pkgdocs/fetch.py +356 -0
- primarysource/pkgdocs/index.py +89 -0
- primarysource/pkgdocs/model.py +76 -0
- primarysource/pkgdocs/readers/__init__.py +1 -0
- primarysource/pkgdocs/readers/_common.py +134 -0
- primarysource/pkgdocs/readers/asciidoc.py +160 -0
- primarysource/pkgdocs/readers/latex.py +315 -0
- primarysource/pkgdocs/readers/markdown.py +173 -0
- primarysource/pkgdocs/readers/pod.py +132 -0
- primarysource/pkgdocs/readers/rst.py +239 -0
- primarysource/pkgdocs/readers/scdoc.py +108 -0
- primarysource/pkgdocs/runtime.py +552 -0
- primarysource/pkgdocs/serving.py +158 -0
- primarysource/pkgdocs/store.py +167 -0
- primarysource/pms/__init__.py +16 -0
- primarysource/pms/catalogue.py +88 -0
- primarysource/pms/cli.py +12 -0
- primarysource/pms/server.py +8 -0
- primarysource/py.typed +0 -0
- primarysource/readers/__init__.py +10 -0
- primarysource/readers/bookmarks.py +192 -0
- primarysource/readers/html.py +1312 -0
- primarysource/readers/markdown.py +189 -0
- primarysource/readers/pdf.py +187 -0
- primarysource/readers/xml.py +230 -0
- primarysource/refs.py +87 -0
- primarysource/registry.py +161 -0
- primarysource/render.py +49 -0
- primarysource/rfc/__init__.py +3 -0
- primarysource/rfc/__main__.py +6 -0
- primarysource/rfc/cite.py +204 -0
- primarysource/rfc/cli.py +903 -0
- primarysource/rfc/collection.py +19 -0
- primarysource/rfc/config.py +138 -0
- primarysource/rfc/errata.py +203 -0
- primarysource/rfc/fsutil.py +82 -0
- primarysource/rfc/graph.py +213 -0
- primarysource/rfc/index/__init__.py +1 -0
- primarysource/rfc/index/chunker.py +281 -0
- primarysource/rfc/index/embed.py +166 -0
- primarysource/rfc/index/hybrid.py +305 -0
- primarysource/rfc/index/lexical.py +223 -0
- primarysource/rfc/index/rfcxml.py +673 -0
- primarysource/rfc/index/search.py +508 -0
- primarysource/rfc/index/txtseg.py +585 -0
- primarysource/rfc/library.py +294 -0
- primarysource/rfc/mcp/__init__.py +1 -0
- primarysource/rfc/mcp/server.py +381 -0
- primarysource/rfc/packs/__init__.py +1 -0
- primarysource/rfc/packs/builder.py +228 -0
- primarysource/rfc/packs/collections/all-current-txt.json +10 -0
- primarysource/rfc/packs/collections/essentials.json +19 -0
- primarysource/rfc/packs/collections/fyi-current.json +10 -0
- primarysource/rfc/packs/collections/standards-current.json +10 -0
- primarysource/rfc/packs/collections/year-2025.json +10 -0
- primarysource/rfc/packs/collections/year-2026.json +10 -0
- primarysource/rfc/packs/collections.py +163 -0
- primarysource/rfc/packs/docsetexport.py +163 -0
- primarysource/rfc/packs/epub.py +159 -0
- primarysource/rfc/packs/export.py +306 -0
- primarysource/rfc/packs/htmlexport.py +82 -0
- primarysource/rfc/packs/serve.py +143 -0
- primarysource/rfc/packs/zimexport.py +171 -0
- primarysource/rfc/query.py +466 -0
- primarysource/rfc/subjects.py +457 -0
- primarysource/rfc/sync/__init__.py +1 -0
- primarysource/rfc/sync/downloader.py +132 -0
- primarysource/rfc/sync/manifest.py +162 -0
- primarysource/rfc/sync/scanner.py +49 -0
- primarysource/rfc/sync/state.py +464 -0
- primarysource/rfc/views.py +1067 -0
- primarysource/rpmdocs/__init__.py +15 -0
- primarysource/rpmdocs/catalogue.py +117 -0
- primarysource/rpmdocs/cli.py +12 -0
- primarysource/rpmdocs/server.py +8 -0
- primarysource/scaffold.py +223 -0
- primarysource/serve.py +75 -0
- primarysource/settings.py +152 -0
- primarysource/usb/__init__.py +5 -0
- primarysource/usb/collection.py +37 -0
- primarysource/usb/data/acquired.json +2025 -0
- primarysource/usb/data/sources-usb.toml +852 -0
- primarysource/usb/data/sources.toml +10 -0
- primarysource/usb/data/usb-catalog.json +1663 -0
- primarysource/usb/data/usb-classification.json +1986 -0
- primarysource/usb/data/usb-corpus-build.json +608 -0
- primarysource/usb/data/usb-pdf-choice.json +839 -0
- primarysource/usb/data/usb-split-measure.json +1803 -0
- primarysource/usb/ingest/__init__.py +1 -0
- primarysource/usb/ingest/acquire.py +130 -0
- primarysource/usb/ingest/discover_usb.py +88 -0
- primarysource/usb/ingest/extract.py +48 -0
- primarysource/usb/ingest/fetch.py +197 -0
- primarysource/usb/ingest/index.py +14 -0
- primarysource/usb/ingest/sources.py +85 -0
- primarysource/usb/models.py +7 -0
- primarysource/usb/paths.py +33 -0
- primarysource/usb/server/__init__.py +3 -0
- primarysource/usb/server/corpus.py +38 -0
- primarysource/usb/server/mcp.py +19 -0
- primarysource/usb/server/tools.py +35 -0
- primarysource/usb/spec.py +20 -0
- primarysource/usbids/__init__.py +56 -0
- primarysource/usbids/cli.py +65 -0
- primarysource/usbids/importer.py +272 -0
- primarysource/usbids/parse.py +283 -0
- primarysource/usbids/server.py +55 -0
- primarysource/usbids/store.py +80 -0
- primarysource/usbids/tools.py +174 -0
- primarysource/zim/__init__.py +5 -0
- primarysource/zim/__main__.py +8 -0
- primarysource/zim/_libzim.py +37 -0
- primarysource/zim/catalog.py +594 -0
- primarysource/zim/cli.py +555 -0
- primarysource/zim/collection.py +41 -0
- primarysource/zim/errors.py +100 -0
- primarysource/zim/groups.py +30 -0
- primarysource/zim/library.py +668 -0
- primarysource/zim/manage.py +636 -0
- primarysource/zim/markdown.py +1247 -0
- primarysource/zim/mcp.py +201 -0
- primarysource/zim/purl.py +130 -0
- primarysource/zim/py.typed +0 -0
- primarysource/zim/refs.py +90 -0
- primarysource/zim/service.py +1182 -0
- primarysource/zim/snapshot.py +197 -0
- primarysource/zim/sync.py +269 -0
- primarysource/zim/views.py +230 -0
- primarysource-0.1.0.dist-info/METADATA +118 -0
- primarysource-0.1.0.dist-info/RECORD +303 -0
- primarysource-0.1.0.dist-info/WHEEL +4 -0
- primarysource-0.1.0.dist-info/entry_points.txt +2 -0
- primarysource-0.1.0.dist-info/licenses/LICENSE +203 -0
- primarysource-0.1.0.dist-info/licenses/NOTICE +51 -0
|
@@ -0,0 +1,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}
|