History

fast search, readable UI, fix broken group links

Search - query parser: phrases, prefix*, -exclude, OR, from:, subject:, group:, after:/before: date bounds - meta_fts index over subject + from_addr (npm run index:meta) - drop the FTS->messages join from counts (11s -> ~25ms for "unix"); group scope seeks by rowid range instead of joining - highlighted snippets, relevance/newest/oldest sort, timing, clear errors UI - group links with @, spaces, commas no longer 404 (decode route params) - home: hierarchy overview, largest/all views, name filter, sortable columns - group: oldest/newest toggle, scoped search, windowed pager + jump-to-page - message: quote-depth tinting, dimmed signatures, clickable URLs, author/thread links, index link returns to the right page - decode RFC 2047 encoded subjects/authors - fixed table layout (no column overflow), 14px text, mobile layout - keyboard: j/k next/prev, u up, / search; help, 404, error, loading pages

Kris Yotam committed 1 month agomain

Commit c20846c1 parent 4ca247219 files · +2058 −474Browse files

Unified · Split

19 files
  1. README.md +10 −0
  2. app/error.tsx +29 −0
  3. app/globals.css +302 −101
  4. app/group/[name]/[id]/page.tsx +128 −69
  5. app/group/[name]/page.tsx +112 −56
  6. app/help/page.tsx +78 −0
  7. app/layout.tsx +53 −13
  8. app/not-found.tsx +13 −0
  9. app/page.tsx +224 −23
  10. app/search/loading.tsx +9 −0
  11. app/search/page.tsx +220 −95
  12. components/Pager.tsx +87 −0
  13. components/Window.tsx +42 −0
  14. lib/db.ts +310 −116
  15. lib/format.tsx +189 −0
  16. lib/query.ts +201 −0
  17. next.config.ts +1 −0
  18. package.json +6 −1
  19. scripts/index-meta.ts +44 −0
README.md+10−0
@@ -37,6 +37,9 @@ npm run import /path/to/usenet/archive · 36 unmodified lines
3737 # Re-split concatenated blobs (if importing raw Google Groups data)
3838 npx tsx scripts/resplit.ts
3939
40+# Build the subject/author index (after any import; ~25s, safe to re-run)
41+npm run index:meta
42+
4043 # Run dev server
4144 npm run dev
4245
@@ -44,6 +47,12 @@ npm run dev · 1 unmodified line
4447 npm run build && npm start
4548 ```
4649
50+## Search syntax
51+
52+`word`, `"exact phrase"`, `prefix*`, `-exclude`, `a OR b`, `from:pike`, `subject:minix`,
53+`group:comp.os.plan9`, `after:1990`, `before:1992-06`. Sort by best match, newest or oldest.
54+Keyboard: `j`/`k` next/prev, `u` up, `/` search. See `/help`.
55+
4756 ## Schema
4857
4958 ```sql
@@ -66,6 +75,7 @@ CREATE TABLE messages ( · 16 unmodified lines
6675 );
6776
6877 CREATE VIRTUAL TABLE messages_fts USING fts5(body, content='messages', content_rowid='id');
78+CREATE VIRTUAL TABLE meta_fts USING fts5(subject, from_addr, content='messages', content_rowid='id');
6979 ```
7080
7181 ## License
app/error.tsx+29−0
@@ -0,0 +1,29 @@
1+"use client";
2+
3+import Link from "next/link";
4+import { useEffect } from "react";
5+
6+export default function Error({
7+ error,
8+ unstable_retry,
9+}: {
10+ error: Error & { digest?: string };
11+ unstable_retry: () => void;
12+}) {
13+ useEffect(() => {
14+ console.error(error);
15+ }, [error]);
16+ return (
17+ <main className="p9-win">
18+ <header className="p9-win-title">
19+ <h1 className="path">/usr/usenet/error</h1>
20+ </header>
21+ <div className="p9-error">Something broke rendering this page{error.digest ? ` (${error.digest})` : ""}.</div>
22+ <div className="p9-msg-nav">
23+ <button type="button" onClick={() => unstable_retry()}>retry</button>
24+ <Link href="/">groups</Link>
25+ <Link href="/search">search</Link>
26+ </div>
27+ </main>
28+ );
29+}
app/globals.css+302−101
@@ -1,139 +1,340 @@
1+/* Plan 9 / rio palette */
2+:root {
3+ --desk: #b5b5ad;
4+ --paper: #fffff5;
5+ --tag: #eaffff;
6+ --title: #ffffea;
7+ --border: #9eeeee;
8+ --rule: #888;
9+ --hair: #e2e2d8;
10+ --hover: #eeeee5;
11+ --ink: #111;
12+ --dim: #5a5a55;
13+ --faint: #8a8a82;
14+ --link: #000;
15+ --q1: #2f5f8f;
16+ --q2: #8a5a24;
17+ --q3: #4f7a3a;
18+ --mark: #ffff9e;
19+ --focus: #55aaaa;
20+ --mono: var(--font-mono), "IBM Plex Mono", "DejaVu Sans Mono", "Courier New", monospace;
21+}
22+
123 * { box-sizing: border-box; }
24+html { -webkit-text-size-adjust: 100%; }
225 body {
3− background: #B5B5AD;
4− font-family: "IBM Plex Mono", "Courier New", monospace;
5− font-size: 13px;
6− line-height: 1.45;
7− color: #000;
26+ background: var(--desk);
27+ color: var(--ink);
28+ font-family: var(--mono);
29+ font-size: 14px;
30+ line-height: 1.5;
831 margin: 0;
9− padding: 20px;
32+ padding: 20px 16px 12px;
33+}
34+a { color: var(--link); text-underline-offset: 2px; }
35+a:hover { text-decoration: underline; }
36+:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
37+mark { background: var(--mark); color: inherit; padding: 0 1px; }
38+kbd {
39+ font-family: inherit;
40+ font-size: 11px;
41+ border: 1px solid var(--rule);
42+ border-bottom-width: 2px;
43+ background: var(--title);
44+ padding: 0 4px;
1045 }
46+h1, h2 { font: inherit; margin: 0; }
47+
48+/* ---------- window chrome ---------- */
1149 .p9-win {
12− border: 2px solid #9EEEEE;
13− background: #FFFFF5;
14− max-width: 900px;
50+ border: 2px solid var(--border);
51+ background: var(--paper);
52+ max-width: 980px;
1553 margin: 0 auto;
54+ box-shadow: 0 1px 0 rgba(0, 0, 0, .08);
1655 }
56+.p9-win.wide { max-width: 1180px; }
1757 .p9-win-title {
18− background: #FFFFEA;
19− border-bottom: 1px solid #888;
20− padding: 2px 8px;
21− font-size: 11px;
58+ background: var(--title);
59+ border-bottom: 1px solid var(--rule);
60+ padding: 3px 10px;
2261 display: flex;
2362 justify-content: space-between;
24− align-items: center;
25− min-height: 19px;
26− gap: 8px;
27−}
28−.p9-win-title .path { font-weight: bold; }
29−.p9-win-title .meta { color: #555; font-size: 10px; }
30−.p9-tagbar {
31− background: #EAFFFF;
32− border-bottom: 1px solid #888;
33− padding: 6px 10px;
34− font-size: 11px;
35− line-height: 1.7;
63+ align-items: baseline;
64+ gap: 12px;
65+ min-height: 24px;
3666 }
37−.p9-tagbar .hdr {
38− display: grid;
39− grid-template-columns: 90px 1fr;
67+.p9-win-title .path {
68+ font-weight: 700;
69+ font-size: 13px;
70+ overflow: hidden;
71+ text-overflow: ellipsis;
72+ white-space: nowrap;
73+ min-width: 0;
4074 }
41−.p9-tagbar .hdr-label { color: #555; }
75+.p9-win-title .meta { color: var(--dim); font-size: 12px; white-space: nowrap; }
76+
4277 .p9-bread {
43− background: #FFFFEA;
44− border-bottom: 1px solid #888;
45− padding: 3px 8px;
46− font-size: 11px;
47− color: #555;
78+ background: var(--title);
79+ border-bottom: 1px solid var(--rule);
80+ padding: 3px 10px;
81+ font-size: 13px;
82+ display: flex;
83+ justify-content: space-between;
84+ gap: 12px;
85+ flex-wrap: wrap;
4886 }
49−.p9-bread a { color: #000; text-decoration: none; }
87+.p9-bread a { text-decoration: none; }
5088 .p9-bread a:hover { text-decoration: underline; }
51−.p9-table { width: 100%; border-collapse: collapse; font-size: 12px; }
89+.p9-bread .crumbs { min-width: 0; overflow-wrap: anywhere; }
90+.p9-bread .sep, .p9-bread .here { color: var(--dim); }
91+.p9-bread .nav { display: flex; gap: 14px; }
92+
93+/* ---------- tag bars / toolbars ---------- */
94+.p9-bar {
95+ background: var(--tag);
96+ border-bottom: 1px solid var(--rule);
97+ padding: 6px 10px;
98+ font-size: 13px;
99+ display: flex;
100+ flex-wrap: wrap;
101+ gap: 6px 14px;
102+ align-items: center;
103+}
104+.p9-bar .spacer { flex: 1; }
105+.p9-bar .dim { color: var(--dim); }
106+.p9-bar a { text-decoration: none; }
107+.p9-bar a:hover { text-decoration: underline; }
108+.p9-bar a.on { font-weight: 700; text-decoration: underline; }
109+
110+input, select, button { font-family: inherit; font-size: 13px; color: var(--ink); }
111+input[type="text"], input[type="search"], input[type="number"], select {
112+ border: 1px solid var(--rule);
113+ background: var(--paper);
114+ padding: 3px 6px;
115+ border-radius: 0;
116+}
117+input[type="number"] { width: 6.5em; padding: 1px 4px; }
118+button {
119+ border: 1px solid var(--rule);
120+ background: var(--title);
121+ padding: 3px 12px;
122+ cursor: pointer;
123+}
124+button:hover { background: var(--hover); }
125+
126+.p9-search { display: flex; gap: 8px; align-items: center; flex: 1 1 420px; min-width: 0; }
127+.p9-search input[type="search"] { flex: 1; min-width: 0; font-size: 14px; }
128+.p9-search label { color: var(--dim); }
129+.p9-opts { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; color: var(--dim); }
130+.p9-opts label { display: inline-flex; gap: 4px; align-items: center; }
131+.p9-hint { color: var(--dim); font-size: 12px; }
132+.p9-hint code { color: var(--ink); background: var(--title); padding: 0 3px; }
133+
134+/* ---------- tables ---------- */
135+.p9-table {
136+ width: 100%;
137+ border-collapse: collapse;
138+ table-layout: fixed;
139+ font-size: 13px;
140+}
52141 .p9-table th {
53142 text-align: left;
54− border-bottom: 1px solid #888;
55− padding: 3px 8px;
56− font-size: 11px;
57− color: #555;
58− font-weight: normal;
143+ border-bottom: 1px solid var(--rule);
144+ padding: 4px 10px;
145+ font-size: 12px;
146+ color: var(--dim);
147+ font-weight: 400;
59148 white-space: nowrap;
60− background: #EAFFFF;
149+ background: var(--tag);
150+ position: sticky;
151+ top: 0;
152+ z-index: 1;
61153 }
154+.p9-table th a { color: var(--dim); text-decoration: none; }
155+.p9-table th a:hover, .p9-table th a.on { color: var(--ink); text-decoration: underline; }
62156 .p9-table td {
63− padding: 2px 8px;
64− border-bottom: 1px solid #deded8;
157+ padding: 3px 10px;
158+ border-bottom: 1px solid var(--hair);
65159 vertical-align: top;
160+ overflow: hidden;
161+ text-overflow: ellipsis;
66162 }
67163 .p9-table tr:last-child td { border-bottom: none; }
68−.p9-table tr:hover td { background: #EEEEE5; }
69−.p9-table a { color: #000; text-decoration: none; }
70−.p9-table a:hover { text-decoration: underline; }
71−.col-num { text-align: right; width: 52px; color: #555; font-size: 11px; }
72−.col-date { width: 88px; white-space: nowrap; color: #555; font-size: 11px; }
73−.col-count { text-align: right; width: 80px; color: #555; }
74−.col-from { width: 190px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #555; font-size: 11px; }
75−.p9-pager {
76− background: #EAFFFF;
77− border-top: 1px solid #888;
78− padding: 5px 10px;
79− font-size: 11px;
80− color: #555;
164+.p9-table tbody tr:hover td { background: var(--hover); }
165+.p9-table td a { text-decoration: none; }
166+.p9-table td a:hover { text-decoration: underline; }
167+.p9-table td a:visited { color: #3a3a6a; }
168+.p9-table .num { text-align: right; font-variant-numeric: tabular-nums; }
169+
170+.col-num { width: 72px; text-align: right; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
171+.col-date { width: 104px; white-space: nowrap; color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
172+.col-count { width: 104px; text-align: right; color: var(--dim); font-variant-numeric: tabular-nums; }
173+.col-from { width: 230px; white-space: nowrap; color: var(--dim); font-size: 12px; }
174+.col-group { width: 210px; white-space: nowrap; }
175+.col-subj { overflow-wrap: anywhere; }
176+td.col-subj a { display: block; }
177+
178+/* ---------- home: hierarchies ---------- */
179+.p9-hier { border-bottom: 1px solid var(--rule); }
180+.p9-hier:last-of-type { border-bottom: none; }
181+.p9-hier > summary {
182+ list-style: none;
183+ cursor: pointer;
184+ padding: 6px 10px;
81185 display: flex;
82− gap: 14px;
83− align-items: center;
186+ gap: 12px;
187+ align-items: baseline;
188+ background: var(--paper);
189+ user-select: none;
190+}
191+.p9-hier > summary::-webkit-details-marker { display: none; }
192+.p9-hier > summary::before { content: "▸"; color: var(--dim); width: 1ch; }
193+.p9-hier[open] > summary::before { content: "▾"; }
194+.p9-hier > summary:hover { background: var(--hover); }
195+.p9-hier > summary .name { font-weight: 700; }
196+.p9-hier > summary .stat { color: var(--dim); font-size: 12px; margin-left: auto; font-variant-numeric: tabular-nums; }
197+.p9-hier[open] > summary { border-bottom: 1px solid var(--hair); background: var(--title); }
198+.bar-cell { position: relative; }
199+.bar {
200+ position: absolute; left: 0; top: 4px; bottom: 4px;
201+ background: #d8f4f4; z-index: 0;
202+}
203+.bar-cell span { position: relative; z-index: 1; }
204+
205+/* ---------- message ---------- */
206+.p9-headers {
207+ background: var(--tag);
208+ border-bottom: 1px solid var(--rule);
209+ padding: 8px 12px;
210+ font-size: 13px;
211+ line-height: 1.6;
212+ margin: 0;
213+ display: grid;
214+ grid-template-columns: max-content 1fr;
215+ column-gap: 14px;
84216 }
85−.p9-pager a { color: #000; text-decoration: none; }
86−.p9-pager a:hover { text-decoration: underline; }
217+.p9-headers dt { color: var(--dim); }
218+.p9-headers dd { margin: 0; overflow-wrap: anywhere; min-width: 0; }
219+.p9-headers dd .acts { color: var(--dim); font-size: 12px; margin-left: 10px; white-space: nowrap; }
220+.p9-headers dd .acts a { color: var(--dim); }
221+.p9-headers dd .acts a:hover { color: var(--ink); }
222+.p9-headers .subject { font-weight: 700; }
223+
87224 .p9-msg-body {
88− padding: 12px;
89− font-size: 12px;
225+ padding: 16px 18px 20px;
226+ font-family: inherit;
227+ font-size: 14px;
228+ line-height: 1.6;
90229 white-space: pre-wrap;
91− word-break: break-word;
92− line-height: 1.55;
230+ overflow-wrap: anywhere;
93231 margin: 0;
94− background: #FFFFF5;
232+ background: var(--paper);
233+ tab-size: 8;
95234 }
96−.q1 { color: #4477AA; }
97−.q2 { color: #AA7744; }
98−.p9-msg-nav {
99− background: #EAFFFF;
100− border-top: 1px solid #888;
101− padding: 4px 10px;
102− font-size: 11px;
235+.p9-msg-body a { color: #1f4f7f; }
236+.q1 { color: var(--q1); }
237+.q2 { color: var(--q2); }
238+.q3 { color: var(--q3); }
239+.attr { color: var(--dim); font-style: italic; }
240+.sig { color: var(--faint); }
241+
242+.p9-msg-nav, .p9-pager {
243+ background: var(--tag);
244+ border-top: 1px solid var(--rule);
245+ padding: 6px 10px;
246+ font-size: 13px;
103247 display: flex;
104− gap: 16px;
248+ flex-wrap: wrap;
249+ gap: 6px 16px;
105250 align-items: center;
106251 }
107−.p9-msg-nav a { color: #000; text-decoration: none; }
108−.p9-msg-nav a:hover { text-decoration: underline; }
252+.p9-msg-nav a, .p9-pager a { text-decoration: none; }
253+.p9-msg-nav a:hover, .p9-pager a:hover { text-decoration: underline; }
109254 .p9-msg-nav .right { margin-left: auto; }
110−.p9-empty { padding: 16px 12px; color: #555; font-size: 12px; }
111−a { color: #000; }
112−.p9-search {
113− background: #EAFFFF;
114− border-bottom: 1px solid #888;
115− padding: 5px 10px;
116− font-size: 11px;
255+.dim { color: var(--faint); }
256+.p9-pager .pages { display: flex; gap: 2px 8px; flex-wrap: wrap; align-items: center; }
257+.p9-pager .cur { font-weight: 700; border-bottom: 2px solid var(--ink); }
258+.p9-pager .gap { color: var(--faint); }
259+.p9-pager .jump { margin-left: auto; color: var(--dim); }
260+.p9-pager .count { color: var(--dim); }
261+
262+/* ---------- search results ---------- */
263+.p9-status {
264+ padding: 6px 12px;
265+ font-size: 12px;
266+ color: var(--dim);
267+ border-bottom: 1px solid var(--hair);
117268 display: flex;
118− gap: 8px;
119− align-items: center;
269+ gap: 16px;
270+ flex-wrap: wrap;
120271 }
121−.p9-search input {
122− font-family: inherit;
123− font-size: 11px;
124− border: 1px solid #888;
125− background: #FFFFF5;
126− padding: 1px 4px;
127− flex: 1;
128− max-width: 300px;
272+.p9-status .warn { color: #8a4a00; }
273+.p9-hits { list-style: none; margin: 0; padding: 0; }
274+.p9-hits li { padding: 10px 14px 11px; border-bottom: 1px solid var(--hair); }
275+.p9-hits li:last-child { border-bottom: none; }
276+.p9-hits li:hover { background: #fbfbef; }
277+.p9-hits .subj { font-weight: 700; font-size: 14px; text-decoration: none; overflow-wrap: anywhere; }
278+.p9-hits .subj:hover { text-decoration: underline; }
279+.p9-hits .subj:visited { color: #3a3a6a; }
280+.p9-hits .meta {
281+ font-size: 12px;
282+ color: var(--dim);
283+ margin-top: 1px;
284+ display: flex;
285+ flex-wrap: wrap;
286+ gap: 0 14px;
129287 }
130−.p9-search button {
131− font-family: inherit;
288+.p9-hits .meta a { color: var(--dim); text-decoration: none; }
289+.p9-hits .meta a:hover { color: var(--ink); text-decoration: underline; }
290+.p9-hits .excerpt {
291+ margin: 4px 0 0;
292+ font-size: 13px;
293+ line-height: 1.55;
294+ color: #2a2a28;
295+ overflow-wrap: anywhere;
296+ max-width: 88ch;
297+}
298+
299+.p9-empty { padding: 18px 14px; color: var(--dim); font-size: 13px; }
300+.p9-error { padding: 12px 14px; color: #8a1f1f; background: #fff3ee; border-bottom: 1px solid var(--hair); font-size: 13px; }
301+
302+/* ---------- help ---------- */
303+.p9-doc { padding: 12px 18px 18px; font-size: 13px; line-height: 1.6; }
304+.p9-doc h2 { font-weight: 700; margin: 14px 0 4px; }
305+.p9-doc h2:first-child { margin-top: 0; }
306+.p9-doc table { border-collapse: collapse; }
307+.p9-doc td { padding: 2px 18px 2px 0; vertical-align: top; }
308+.p9-doc td:first-child { white-space: nowrap; }
309+.p9-doc code { background: var(--title); padding: 0 3px; }
310+
311+.p9-foot {
312+ max-width: 980px;
313+ margin: 10px auto 0;
132314 font-size: 11px;
133− border: 1px solid #888;
134− background: #FFFFEA;
135− padding: 1px 8px;
136− cursor: pointer;
315+ color: #4a4a45;
316+ text-align: right;
317+}
318+
319+.visually-hidden {
320+ position: absolute !important;
321+ width: 1px; height: 1px;
322+ overflow: hidden; clip: rect(0 0 0 0);
323+ white-space: nowrap;
324+}
325+
326+/* ---------- small screens ---------- */
327+@media (max-width: 720px) {
328+ body { padding: 6px 4px; font-size: 14px; }
329+ .p9-win { border-width: 1px; }
330+ .col-from, .col-num, .hide-sm { display: none; }
331+ .col-date { width: 92px; }
332+ .col-count { width: 84px; }
333+ .col-group { width: 140px; }
334+ .p9-table td, .p9-table th { padding-left: 7px; padding-right: 7px; }
335+ .p9-pager .jump { margin-left: 0; }
336+ .p9-headers { grid-template-columns: 1fr; }
337+ .p9-headers dt { margin-top: 4px; }
338+ .p9-msg-body { padding: 12px 10px; font-size: 13px; }
339+ .p9-foot { display: none; }
137340 }
138−.p9-search button:hover { background: #EEEEE5; }
139−.highlight { background: #FFFFAA; }
app/group/[name]/[id]/page.tsx+128−69
@@ -1,92 +1,151 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
23 import { notFound } from "next/navigation";
34 import { getMessage, getAdjacentIds } from "@/lib/db";
5+import {
6+ authorAddress,
7+ authorName,
8+ decodeMime,
9+ decodeParam,
10+ fmtDate,
11+ formatBody,
12+ groupHref,
13+ messageHref,
14+ queryValue,
15+ searchHref,
16+} from "@/lib/format";
17+import { Window, Sep } from "@/components/Window";
418
5−function formatBody(body: string): React.ReactNode[] {
6− const lines = body.split("\n");
7− return lines.map((line, i) => {
8− if (line.startsWith(">>")) {
9− return (
10− <span key={i}>
11− <span className="q2">{line}</span>
12− {"\n"}
13− </span>
14− );
15− }
16− if (line.startsWith(">")) {
17− return (
18− <span key={i}>
19− <span className="q1">{line}</span>
20− {"\n"}
21− </span>
22− );
23− }
24− return <span key={i}>{line}{"\n"}</span>;
25− });
26−}
19+const PER_PAGE = 50;
2720
28−export default async function MessagePage({
29− params,
30−}: {
21+interface Props {
3122 params: Promise<{ name: string; id: string }>;
32−}) {
33− const { name, id: idStr } = await params;
34− const originalId = parseInt(idStr);
35− if (isNaN(originalId)) notFound();
23+}
3624
25+async function load(params: Props["params"]) {
26+ const p = await params;
27+ const name = decodeParam(p.name);
28+ if (!/^\d+$/.test(p.id)) return null;
29+ const originalId = parseInt(p.id, 10);
3730 const message = getMessage(name, originalId);
38− if (!message) notFound();
31+ return message ? { name, originalId, message } : null;
32+}
33+
34+export async function generateMetadata({ params }: Props): Promise<Metadata> {
35+ const r = await load(params);
36+ if (!r) return { title: "not found" };
37+ return {
38+ title: `${decodeMime(r.message.subject) || "(no subject)"} — ${r.name}`,
39+ description: `${authorName(decodeMime(r.message.from_addr))} · ${r.message.date || "undated"}`,
40+ };
41+}
42+
43+/** Thread key: the subject without Re:/Fwd: prefixes and [list] tags. */
44+function threadSubject(s: string): string {
45+ let t = s.trim();
46+ for (;;) {
47+ const n = t.replace(/^\s*((re|fwd?|aw|sv)(\[\d+\])?\s*:|\[[^\]]{1,40}\])\s*/i, "");
48+ if (n === t) break;
49+ t = n;
50+ }
51+ return t.replace(/\s*\(was:.*\)\s*$/i, "").trim();
52+}
53+
54+export default async function MessagePage({ params }: Props) {
55+ const r = await load(params);
56+ if (!r) notFound();
57+ const { name, originalId } = r;
58+ const message = {
59+ ...r.message,
60+ subject: decodeMime(r.message.subject),
61+ from_addr: decodeMime(r.message.from_addr),
62+ };
63+ const { prev, next, position } = getAdjacentIds(name, originalId);
64+ const indexPage = Math.floor(position / PER_PAGE) + 1;
65+ const indexHref = groupHref(name, indexPage > 1 ? `?page=${indexPage}` : "");
3966
40− const { prev, next } = getAdjacentIds(name, originalId);
67+ const addr = authorAddress(message.from_addr);
68+ const who = authorName(message.from_addr);
69+ const authorQuery = addr || who;
70+ const thread = threadSubject(message.subject);
4171
4272 return (
43− <div className="p9-win">
44− <div className="p9-win-title">
45− <span className="path">
46− /usr/usenet/{name}/{originalId}
47− </span>
48− </div>
49− <div className="p9-bread">
50− <Link href="/">usenet</Link> / <Link href={`/group/${name}`}>{name}</Link> / {originalId}
51− </div>
52− <div className="p9-tagbar">
53− <div className="hdr">
54− <span className="hdr-label">From:</span>
55− <span>{message.from_addr || "(unknown)"}</span>
56− </div>
57− <div className="hdr">
58− <span className="hdr-label">Date:</span>
59− <span>{message.date || "(unknown)"}</span>
60− </div>
61− <div className="hdr">
62− <span className="hdr-label">Subject:</span>
63− <span>{message.subject || "(no subject)"}</span>
64− </div>
73+ <Window
74+ path={`/usr/usenet/${name}/${originalId}`}
75+ meta={<>#{originalId}</>}
76+ crumbs={
77+ <>
78+ <Sep />
79+ <Link href={indexHref}>{name}</Link>
80+ <Sep />
81+ <span className="here">{originalId}</span>
82+ </>
83+ }
84+ >
85+ <dl className="p9-headers">
86+ <dt>From:</dt>
87+ <dd>
88+ {message.from_addr || "(unknown)"}
89+ {authorQuery && (
90+ <span className="acts">
91+ <Link href={searchHref({ q: `from:${queryValue(authorQuery)}`, group: name, sort: "newest" })}>
92+ posts here
93+ </Link>{" "}
94+ ·{" "}
95+ <Link href={searchHref({ q: `from:${queryValue(authorQuery)}`, sort: "newest" })}>
96+ everywhere
97+ </Link>
98+ </span>
99+ )}
100+ </dd>
101+ <dt>Date:</dt>
102+ <dd>{fmtDate(message.date)}</dd>
103+ <dt>Newsgroup:</dt>
104+ <dd>
105+ <Link href={indexHref}>{name}</Link>
106+ </dd>
107+ <dt>Subject:</dt>
108+ <dd>
109+ <span className="subject">{message.subject || "(no subject)"}</span>
110+ {thread && (
111+ <span className="acts">
112+ <Link href={searchHref({ q: `subject:${queryValue(thread)}`, group: name, sort: "oldest" })}>
113+ thread
114+ </Link>
115+ </span>
116+ )}
117+ </dd>
65118 {message.message_id && (
66− <div className="hdr">
67− <span className="hdr-label">Message-ID:</span>
68− <span>{message.message_id}</span>
69− </div>
119+ <>
120+ <dt>Message-ID:</dt>
121+ <dd>{message.message_id}</dd>
122+ </>
70123 )}
71− </div>
72− <pre className="p9-msg-body">{formatBody(message.body)}</pre>
73− <div className="p9-msg-nav">
124+ </dl>
125+
126+ <pre className="p9-msg-body">
127+ {message.body.trim() ? formatBody(message.body) : <span className="dim">(empty body)</span>}
128+ </pre>
129+
130+ <nav className="p9-msg-nav" aria-label="Message navigation">
74131 {prev !== null ? (
75− <Link href={`/group/${name}/${prev}`}>&#9666; prev</Link>
132+ <Link href={messageHref(name, prev)} rel="prev">
133+ &#9666; prev
134+ </Link>
76135 ) : (
77− <span style={{ color: "#999" }}>&#9666; prev</span>
136+ <span className="dim">&#9666; prev</span>
78137 )}
79− <Link href={`/group/${name}`}>index</Link>
138+ <Link href={indexHref} rel="up">
139+ index
140+ </Link>
80141 {next !== null ? (
81− <Link href={`/group/${name}/${next}`} className="right">
142+ <Link href={messageHref(name, next)} rel="next" className="right">
82143 next &#9656;
83144 </Link>
84145 ) : (
85− <span className="right" style={{ color: "#999" }}>
86− next &#9656;
87− </span>
146+ <span className="right dim">next &#9656;</span>
88147 )}
89− </div>
90− </div>
148+ </nav>
149+ </Window>
91150 );
92151 }
app/group/[name]/page.tsx+112−56
@@ -1,71 +1,127 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
23 import { notFound } from "next/navigation";
34 import { getNewsgroup, getMessages } from "@/lib/db";
5+import {
6+ decodeParam,
7+ fmt,
8+ fmtDate,
9+ groupHref,
10+ hierarchyOf,
11+ intParam,
12+ messageHref,
13+ decodeMime,
14+} from "@/lib/format";
15+import { Window, Sep } from "@/components/Window";
16+import { Pager } from "@/components/Pager";
417
5−export default async function GroupPage({
6− params,
7− searchParams,
8−}: {
18+const PER_PAGE = 50;
19+
20+interface Props {
921 params: Promise<{ name: string }>;
10− searchParams: Promise<{ page?: string }>;
11−}) {
12− const { name } = await params;
13− const { page: pageStr } = await searchParams;
22+ searchParams: Promise<{ page?: string; order?: string }>;
23+}
24+
25+export async function generateMetadata({ params }: Props): Promise<Metadata> {
26+ const name = decodeParam((await params).name);
27+ return { title: name };
28+}
29+
30+export default async function GroupPage({ params, searchParams }: Props) {
31+ const name = decodeParam((await params).name);
32+ const sp = await searchParams;
1433 const group = getNewsgroup(name);
1534 if (!group) notFound();
1635
17− const page = Math.max(1, parseInt(pageStr || "1"));
18− const perPage = 50;
19− const { messages, total } = getMessages(name, page, perPage);
20− const totalPages = Math.ceil(total / perPage);
36+ const order = sp.order === "desc" ? "desc" : "asc";
37+ const totalPages = Math.max(1, Math.ceil(group.message_count / PER_PAGE));
38+ const page = Math.min(intParam(sp.page), totalPages);
39+ const { messages } = getMessages(name, page, PER_PAGE, order);
40+
41+ const hrefFor = (p: number, o = order) => {
42+ const qs = new URLSearchParams();
43+ if (p > 1) qs.set("page", String(p));
44+ if (o === "desc") qs.set("order", "desc");
45+ const s = qs.toString();
46+ return groupHref(name, s ? `?${s}` : "");
47+ };
48+ const hier = hierarchyOf(name);
49+ const from = (page - 1) * PER_PAGE + 1;
50+ const to = Math.min(group.message_count, page * PER_PAGE);
2151
2252 return (
23− <div className="p9-win">
24− <div className="p9-win-title">
25− <span className="path">/usr/usenet/{name}</span>
26− <span className="meta">{total.toLocaleString()} messages</span>
27− </div>
28− <div className="p9-bread">
29− <Link href="/">usenet</Link> / {name} &middot;{" "}
30− <Link href={`/search?group=${name}`}>search</Link>
53+ <Window
54+ path={`/usr/usenet/${name}`}
55+ meta={
56+ <>
57+ {fmt(group.message_count)} messages · {fmtDate(group.first_date)} – {fmtDate(group.last_date)}
58+ </>
59+ }
60+ crumbs={
61+ <>
62+ <Sep />
63+ <Link href={`/?h=${encodeURIComponent(hier)}`} rel="up">
64+ {hier}
65+ </Link>
66+ <Sep />
67+ <span className="here">{name}</span>
68+ </>
69+ }
70+ >
71+ <div className="p9-bar">
72+ <form className="p9-search" action="/search" method="GET" role="search">
73+ <input type="hidden" name="group" value={name} />
74+ <label htmlFor="q">search {name}</label>
75+ <input id="q" type="search" name="q" placeholder="words, from:, subject:…" data-search autoComplete="off" />
76+ <button type="submit">find</button>
77+ </form>
78+ <span className="p9-opts">
79+ <Link href={hrefFor(1, "asc")} className={order === "asc" ? "on" : undefined}>
80+ oldest first
81+ </Link>
82+ <Link href={hrefFor(1, "desc")} className={order === "desc" ? "on" : undefined}>
83+ newest first
84+ </Link>
85+ </span>
3186 </div>
32− <table className="p9-table">
33− <thead>
34− <tr>
35− <th className="col-num">#</th>
36− <th>subject</th>
37− <th className="col-from">from</th>
38− <th className="col-date">date</th>
39− </tr>
40− </thead>
41− <tbody>
42− {messages.map((m) => (
43− <tr key={m.original_id}>
44− <td className="col-num">{m.original_id}</td>
45− <td>
46− <Link href={`/group/${name}/${m.original_id}`}>
47− {m.subject || "(no subject)"}
48− </Link>
49− </td>
50− <td className="col-from">{m.from_addr}</td>
51− <td className="col-date">{m.date}</td>
87+
88+ {messages.length === 0 ? (
89+ <div className="p9-empty">No messages on this page.</div>
90+ ) : (
91+ <table className="p9-table">
92+ <thead>
93+ <tr>
94+ <th className="col-num">#</th>
95+ <th>subject</th>
96+ <th className="col-from">from</th>
97+ <th className="col-date">date</th>
5298 </tr>
53− ))}
54− </tbody>
55− </table>
56− {totalPages > 1 && (
57− <div className="p9-pager">
58− {page > 1 && (
59− <Link href={`/group/${name}?page=${page - 1}`}>&#9666; prev</Link>
60− )}
61− <span>
62− page {page}/{totalPages}
63− </span>
64− {page < totalPages && (
65− <Link href={`/group/${name}?page=${page + 1}`}>next &#9656;</Link>
66− )}
67− </div>
99+ </thead>
100+ <tbody>
101+ {messages.map((m) => (
102+ <tr key={m.id}>
103+ <td className="col-num">{m.original_id}</td>
104+ <td className="col-subj">
105+ <Link href={messageHref(name, m.original_id)}>{decodeMime(m.subject) || "(no subject)"}</Link>
106+ </td>
107+ <td className="col-from" title={decodeMime(m.from_addr)}>
108+ {decodeMime(m.from_addr) || "—"}
109+ </td>
110+ <td className="col-date">{fmtDate(m.date)}</td>
111+ </tr>
112+ ))}
113+ </tbody>
114+ </table>
68115 )}
69− </div>
116+
117+ <Pager
118+ page={page}
119+ totalPages={totalPages}
120+ href={(p) => hrefFor(p)}
121+ action={groupHref(name)}
122+ hidden={{ order: order === "desc" ? "desc" : undefined }}
123+ label={`${fmt(from)}–${fmt(to)} of ${fmt(group.message_count)}`}
124+ />
125+ </Window>
70126 );
71127 }
app/help/page.tsx+78−0
@@ -0,0 +1,78 @@
1+import type { Metadata } from "next";
2+import Link from "next/link";
3+import { Window, Sep } from "@/components/Window";
4+import { searchHref } from "@/lib/format";
5+
6+export const metadata: Metadata = { title: "help" };
7+
8+const SYNTAX: [string, string][] = [
9+ ["unix kernel", "messages containing both words (in the body)"],
10+ ['"bell labs"', "exact phrase"],
11+ ["plan9*", "prefix — plan9, plan9port, plan9fans…"],
12+ ["lisp -scheme", "exclude a word; also -\"phrase\", -from:x"],
13+ ["acme OR sam", "either term"],
14+ ["from:pike", "author name or address (also f:, by:)"],
15+ ['from:"dennis ritchie"', "author, as a phrase"],
16+ ["subject:minix", "subject line (also s:, title:)"],
17+ ["group:comp.lang.c", "one newsgroup (also in:, g:)"],
18+ ["after:1990 before:1992-06", "date bounds: YYYY, YYYY-MM or YYYY-MM-DD"],
19+];
20+
21+const KEYS: [string, string][] = [
22+ ["j →", "next message / next page"],
23+ ["k ←", "previous message / previous page"],
24+ ["u", "up: message → group index → hierarchy"],
25+ ["/", "focus the search box"],
26+ ["Esc", "leave the search box"],
27+];
28+
29+export default function Help() {
30+ return (
31+ <Window path="/usr/usenet/help" crumbs={<><Sep /><span className="here">help</span></>}>
32+ <div className="p9-doc">
33+ <h2>search syntax</h2>
34+ <table>
35+ <tbody>
36+ {SYNTAX.map(([q, d]) => (
37+ <tr key={q}>
38+ <td>
39+ <Link href={searchHref({ q })}>
40+ <code>{q}</code>
41+ </Link>
42+ </td>
43+ <td>{d}</td>
44+ </tr>
45+ ))}
46+ </tbody>
47+ </table>
48+ <p>
49+ Terms combine: <code>from:ritchie subject:unix after:1985</code>. Words match whole
50+ tokens, case-insensitively; punctuation splits words, so <code>bell-labs.com</code> is the
51+ phrase “bell labs com”. Results sort by best match; newest/oldest sorting is available when
52+ a query matches under 100,000 messages.
53+ </p>
54+
55+ <h2>keyboard</h2>
56+ <table>
57+ <tbody>
58+ {KEYS.map(([k, d]) => (
59+ <tr key={k}>
60+ <td>
61+ <code>{k}</code>
62+ </td>
63+ <td>{d}</td>
64+ </tr>
65+ ))}
66+ </tbody>
67+ </table>
68+
69+ <h2>reading</h2>
70+ <p>
71+ Quoted text is tinted by depth, attribution lines are italic, and signatures (after a{" "}
72+ <code>-- </code> line) are dimmed. On a message, <b>posts here</b> / <b>everywhere</b> list
73+ the author&apos;s other posts and <b>thread</b> finds messages with the same subject.
74+ </p>
75+ </div>
76+ </Window>
77+ );
78+}
app/layout.tsx+53−13
@@ -1,25 +1,65 @@
1−import type { Metadata } from "next";
1+import type { Metadata, Viewport } from "next";
22 import { IBM_Plex_Mono } from "next/font/google";
33 import "./globals.css";
44
5−const ibmPlexMono = IBM_Plex_Mono({
6− weight: ["400", "700"],
7− subsets: ["latin"],
5+const plex = IBM_Plex_Mono({
6+ weight: ["400", "500", "700"],
7+ style: ["normal", "italic"],
8+ subsets: ["latin", "latin-ext"],
9+ variable: "--font-mono",
10+ display: "swap",
811 });
912
1013 export const metadata: Metadata = {
11− title: "Usenet Archives",
12− description: "Usenet newsgroup archive viewer",
14+ title: { default: "Usenet Archives", template: "%s · Usenet Archives" },
15+ description: "Usenet newsgroup archive viewer, 1980–2014, with full-text search.",
1316 };
1417
15−export default function RootLayout({
16− children,
17−}: Readonly<{
18− children: React.ReactNode;
19−}>) {
18+export const viewport: Viewport = {
19+ width: "device-width",
20+ initialScale: 1,
21+ themeColor: "#B5B5AD",
22+};
23+
24+/*
25+ * Keyboard: j/→ next · k/← prev · u up · / search.
26+ * Acts on links marked rel=next / rel=prev / rel=up, so every page opts in
27+ * just by marking its links. Ignored while typing or with modifiers held.
28+ */
29+const keys = `
30+document.addEventListener("keydown", function (e) {
31+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
32+ var t = e.target, tag = t && t.tagName;
33+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (t && t.isContentEditable)) {
34+ if (e.key === "Escape") t.blur();
35+ return;
36+ }
37+ function go(rel) {
38+ var a = document.querySelector('a[rel~="' + rel + '"]');
39+ if (a) { e.preventDefault(); a.click(); }
40+ }
41+ switch (e.key) {
42+ case "j": case "ArrowRight": go("next"); break;
43+ case "k": case "ArrowLeft": go("prev"); break;
44+ case "u": go("up"); break;
45+ case "/":
46+ var q = document.querySelector("input[data-search]");
47+ e.preventDefault();
48+ if (q) { q.focus(); q.select(); } else { location.href = "/search"; }
49+ break;
50+ }
51+});`;
52+
53+export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
2054 return (
21− <html lang="en">
22− <body className={ibmPlexMono.className}>{children}</body>
55+ <html lang="en" className={plex.variable}>
56+ <body>
57+ {children}
58+ <footer className="p9-foot">
59+ <kbd>j</kbd>/<kbd>k</kbd> next/prev · <kbd>u</kbd> up · <kbd>/</kbd> search
60+ </footer>
61+ <script dangerouslySetInnerHTML={{ __html: keys }} />
62+ </body>
2363 </html>
2464 );
2565 }
app/not-found.tsx+13−0
@@ -0,0 +1,13 @@
1+import Link from "next/link";
2+import { Window, Sep } from "@/components/Window";
3+
4+export default function NotFound() {
5+ return (
6+ <Window path="/usr/usenet/404" crumbs={<><Sep /><span className="here">not found</span></>}>
7+ <div className="p9-empty">
8+ No such group or message. Try the <Link href="/">group list</Link> or{" "}
9+ <Link href="/search">search</Link>.
10+ </div>
11+ </Window>
12+ );
13+}
app/page.tsx+224−23
@@ -1,43 +1,244 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
2−import { getNewsgroups } from "@/lib/db";
3+import { getNewsgroups, getTotals, type Newsgroup } from "@/lib/db";
4+import { fmt, fmtDate, groupHref, hierarchyOf } from "@/lib/format";
5+import { Window, Sep } from "@/components/Window";
36
4−export default function Home() {
5− const groups = getNewsgroups();
6− const totalMessages = groups.reduce((s, g) => s + g.message_count, 0);
7+type SortKey = "name" | "messages" | "first" | "last";
8+type View = "hier" | "all" | "top";
79
10+interface Props {
11+ searchParams: Promise<{ q?: string; h?: string; sort?: string; view?: string }>;
12+}
13+
14+export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
15+ const { h, q } = await searchParams;
16+ if (h) return { title: `${h}.*` };
17+ if (q) return { title: `groups matching “${q}”` };
18+ return {};
19+}
20+
21+const SORTS: Record<SortKey, (a: Newsgroup, b: Newsgroup) => number> = {
22+ name: (a, b) => a.name.localeCompare(b.name),
23+ messages: (a, b) => b.message_count - a.message_count || a.name.localeCompare(b.name),
24+ first: (a, b) =>
25+ (a.first_date ?? "9999").localeCompare(b.first_date ?? "9999") || a.name.localeCompare(b.name),
26+ last: (a, b) =>
27+ (b.last_date ?? "").localeCompare(a.last_date ?? "") || a.name.localeCompare(b.name),
28+};
29+
30+function homeHref(p: { q?: string; h?: string; sort?: string; view?: string }) {
31+ const sp = new URLSearchParams();
32+ if (p.view && p.view !== "hier") sp.set("view", p.view);
33+ if (p.h) sp.set("h", p.h);
34+ if (p.q) sp.set("q", p.q);
35+ if (p.sort && p.sort !== "name") sp.set("sort", p.sort);
36+ const s = sp.toString();
37+ return s ? `/?${s}` : "/";
38+}
39+
40+interface Hier {
41+ name: string;
42+ groups: number;
43+ messages: number;
44+ first: string | null;
45+ last: string | null;
46+}
47+
48+function hierarchies(groups: Newsgroup[]): Hier[] {
49+ const m = new Map<string, Hier>();
50+ for (const g of groups) {
51+ const k = hierarchyOf(g.name);
52+ let h = m.get(k);
53+ if (!h) m.set(k, (h = { name: k, groups: 0, messages: 0, first: null, last: null }));
54+ h.groups++;
55+ h.messages += g.message_count;
56+ if (g.first_date && (!h.first || g.first_date < h.first)) h.first = g.first_date;
57+ if (g.last_date && (!h.last || g.last_date > h.last)) h.last = g.last_date;
58+ }
59+ return [...m.values()].sort((a, b) => b.messages - a.messages || a.name.localeCompare(b.name));
60+}
61+
62+function GroupTable({
63+ groups,
64+ sort,
65+ linkFor,
66+}: {
67+ groups: Newsgroup[];
68+ sort: SortKey;
69+ linkFor: (s: SortKey) => string;
70+}) {
71+ const max = groups.reduce((m, g) => Math.max(m, g.message_count), 1);
72+ const th = (key: SortKey, label: string) => (
73+ <Link href={linkFor(key)} className={sort === key ? "on" : undefined}>
74+ {label}
75+ {sort === key ? " ▾" : ""}
76+ </Link>
77+ );
878 return (
9− <div className="p9-win">
10− <div className="p9-win-title">
11− <span className="path">/usr/usenet</span>
12− <span className="meta">
13− {groups.length} groups &middot; {totalMessages.toLocaleString()} messages
14− </span>
15− </div>
16− <div className="p9-bread">
17− <Link href="/search">search</Link>
18− </div>
79+ <table className="p9-table">
80+ <thead>
81+ <tr>
82+ <th>{th("name", "newsgroup")}</th>
83+ <th className="col-count">{th("messages", "messages")}</th>
84+ <th className="col-date">{th("first", "first")}</th>
85+ <th className="col-date">{th("last", "last")}</th>
86+ </tr>
87+ </thead>
88+ <tbody>
89+ {groups.map((g) => (
90+ <tr key={g.name}>
91+ <td className="col-subj">
92+ <Link href={groupHref(g.name)}>{g.name}</Link>
93+ </td>
94+ <td className="col-count bar-cell">
95+ <i
96+ className="bar"
97+ style={{ width: `${Math.max(1, (100 * Math.log10(g.message_count + 1)) / Math.log10(max + 1))}%` }}
98+ />
99+ <span>{fmt(g.message_count)}</span>
100+ </td>
101+ <td className="col-date">{fmtDate(g.first_date)}</td>
102+ <td className="col-date">{fmtDate(g.last_date)}</td>
103+ </tr>
104+ ))}
105+ </tbody>
106+ </table>
107+ );
108+}
109+
110+export default async function Home({ searchParams }: Props) {
111+ const sp = await searchParams;
112+ const q = (sp.q ?? "").trim();
113+ const h = sp.h?.trim() || undefined;
114+ const sort: SortKey = (["name", "messages", "first", "last"] as const).includes(sp.sort as SortKey)
115+ ? (sp.sort as SortKey)
116+ : "name";
117+ const view: View = sp.view === "all" || sp.view === "top" ? sp.view : "hier";
118+
119+ const all = getNewsgroups();
120+ const totals = getTotals();
121+
122+ let body: React.ReactNode;
123+ let crumbs: React.ReactNode = null;
124+ let meta = `${fmt(totals.groups)} groups · ${fmt(totals.messages)} messages`;
125+
126+ if (q || h || view !== "hier") {
127+ let list = all;
128+ if (h) list = list.filter((g) => hierarchyOf(g.name) === h);
129+ if (q) {
130+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
131+ list = list.filter((g) => {
132+ const n = g.name.toLowerCase();
133+ return words.every((w) => n.includes(w));
134+ });
135+ }
136+ const effSort: SortKey = view === "top" && !sp.sort ? "messages" : sort;
137+ list = [...list].sort(SORTS[effSort]);
138+ if (view === "top" && !q && !h) list = list.slice(0, 100);
139+
140+ const msgs = list.reduce((s, g) => s + g.message_count, 0);
141+ meta = `${fmt(list.length)} groups · ${fmt(msgs)} messages`;
142+ if (h) {
143+ crumbs = (
144+ <>
145+ <Sep />
146+ <span className="here">{h}{h.startsWith("(") ? "" : ".*"}</span>
147+ </>
148+ );
149+ }
150+ body =
151+ list.length === 0 ? (
152+ <div className="p9-empty">
153+ No groups match “{q}”.{" "}
154+ <Link href={`/search?q=${encodeURIComponent(q)}`}>Search messages for it instead →</Link>
155+ </div>
156+ ) : (
157+ <GroupTable
158+ groups={list}
159+ sort={effSort}
160+ linkFor={(s) => homeHref({ q, h, view, sort: s })}
161+ />
162+ );
163+ } else {
164+ const hs = hierarchies(all);
165+ const max = hs[0]?.messages ?? 1;
166+ body = (
19167 <table className="p9-table">
20168 <thead>
21169 <tr>
22− <th>newsgroup</th>
170+ <th>hierarchy</th>
171+ <th className="col-count hide-sm">groups</th>
23172 <th className="col-count">messages</th>
24173 <th className="col-date">first</th>
25174 <th className="col-date">last</th>
26175 </tr>
27176 </thead>
28177 <tbody>
29− {groups.map((g) => (
30− <tr key={g.name}>
31− <td>
32− <Link href={`/group/${g.name}`}>{g.name}</Link>
178+ {hs.map((x) => (
179+ <tr key={x.name}>
180+ <td className="col-subj">
181+ <Link href={homeHref({ h: x.name })}>
182+ {x.name}
183+ {x.name.startsWith("(") ? "" : ".*"}
184+ </Link>
185+ </td>
186+ <td className="col-count hide-sm">{fmt(x.groups)}</td>
187+ <td className="col-count bar-cell">
188+ <i
189+ className="bar"
190+ style={{ width: `${Math.max(1, (100 * Math.log10(x.messages + 1)) / Math.log10(max + 1))}%` }}
191+ />
192+ <span>{fmt(x.messages)}</span>
33193 </td>
34− <td className="col-count">{g.message_count.toLocaleString()}</td>
35− <td className="col-date">{g.first_date ?? ""}</td>
36− <td className="col-date">{g.last_date ?? ""}</td>
194+ <td className="col-date">{fmtDate(x.first)}</td>
195+ <td className="col-date">{fmtDate(x.last)}</td>
37196 </tr>
38197 ))}
39198 </tbody>
40199 </table>
41− </div>
200+ );
201+ }
202+
203+ return (
204+ <Window path={h ? `/usr/usenet/${h}` : "/usr/usenet"} meta={meta} crumbs={crumbs}>
205+ <div className="p9-bar">
206+ <form className="p9-search" action="/search" method="GET" role="search">
207+ <label htmlFor="q">search</label>
208+ <input
209+ id="q"
210+ type="search"
211+ name="q"
212+ placeholder="words, &quot;phrases&quot;, from:pike, subject:plan9"
213+ data-search
214+ autoComplete="off"
215+ />
216+ <button type="submit">find</button>
217+ </form>
218+ </div>
219+ <div className="p9-bar">
220+ <span className="dim">view</span>
221+ <Link href="/" className={view === "hier" && !q && !h ? "on" : undefined}>
222+ hierarchies
223+ </Link>
224+ <Link href={homeHref({ view: "top" })} className={view === "top" && !q ? "on" : undefined}>
225+ largest
226+ </Link>
227+ <Link href={homeHref({ view: "all" })} className={view === "all" && !q ? "on" : undefined}>
228+ all groups
229+ </Link>
230+ <span className="spacer" />
231+ <form action="/" method="GET" className="p9-opts">
232+ {h && <input type="hidden" name="h" value={h} />}
233+ {view !== "hier" && <input type="hidden" name="view" value={view} />}
234+ <label>
235+ filter{" "}
236+ <input type="text" name="q" defaultValue={q} placeholder="lisp, plan9…" size={16} />
237+ </label>
238+ {(q || h) && <Link href={view !== "hier" ? homeHref({ view }) : "/"}>clear</Link>}
239+ </form>
240+ </div>
241+ {body}
242+ </Window>
42243 );
43244 }
app/search/loading.tsx+9−0
@@ -0,0 +1,9 @@
1+import { Window, Sep } from "@/components/Window";
2+
3+export default function Loading() {
4+ return (
5+ <Window path="/usr/usenet/search" meta="searching…" crumbs={<><Sep /><span className="here">search</span></>}>
6+ <div className="p9-empty">Searching the archive…</div>
7+ </Window>
8+ );
9+}
app/search/page.tsx+220−95
@@ -1,108 +1,233 @@
1+import type { Metadata } from "next";
12 import Link from "next/link";
2−import { searchMessages } from "@/lib/db";
3+import { searchMessages, hasMetaIndex, type SearchSort } from "@/lib/db";
4+import { parseQuery } from "@/lib/query";
5+import {
6+ Highlight,
7+ authorAddress,
8+ authorName,
9+ decodeMime,
10+ fmt,
11+ fmtDate,
12+ groupHref,
13+ intParam,
14+ messageHref,
15+ queryValue,
16+ searchHref,
17+} from "@/lib/format";
18+import { Window, Sep } from "@/components/Window";
19+import { Pager } from "@/components/Pager";
320
4−export default async function SearchPage({
5− searchParams,
6−}: {
7− searchParams: Promise<{ q?: string; group?: string; page?: string }>;
8−}) {
9− const { q, group, page: pageStr } = await searchParams;
10− const page = Math.max(1, parseInt(pageStr || "1"));
11− const perPage = 50;
21+const PER_PAGE = 25;
22+const MAX_PAGE = 400; // deep OFFSETs on relevance order are not worth the cost
1223
13− if (!q) {
14− return (
15− <div className="p9-win">
16− <div className="p9-win-title">
17− <span className="path">/usr/usenet/search</span>
18− </div>
19− <div className="p9-bread">
20− <Link href="/">usenet</Link> / search
21− </div>
22− <form className="p9-search" action="/search" method="GET">
23− <span>Search:</span>
24− <input type="text" name="q" autoFocus />
24+interface Props {
25+ searchParams: Promise<{ q?: string; group?: string; page?: string; sort?: string }>;
26+}
27+
28+export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
29+ const { q } = await searchParams;
30+ return { title: q ? `“${q}”` : "search" };
31+}
32+
33+function Examples() {
34+ const ex: [string, string][] = [
35+ ['"rob pike" plan9', "phrase + word"],
36+ ["from:ritchie", "posts by an author"],
37+ ["subject:minix after:1991 before:1992", "subject in a date range"],
38+ ["lisp macro* -scheme", "prefix, exclusion"],
39+ ["group:comp.os.plan9 acme OR sam", "one group, either word"],
40+ ];
41+ return (
42+ <div className="p9-doc">
43+ <h2>examples</h2>
44+ <table>
45+ <tbody>
46+ {ex.map(([q, d]) => (
47+ <tr key={q}>
48+ <td>
49+ <Link href={searchHref({ q })}>
50+ <code>{q}</code>
51+ </Link>
52+ </td>
53+ <td className="dim">{d}</td>
54+ </tr>
55+ ))}
56+ </tbody>
57+ </table>
58+ <p className="p9-hint">
59+ Full syntax on the <Link href="/help">help</Link> page.
60+ </p>
61+ </div>
62+ );
63+}
64+
65+export default async function SearchPage({ searchParams }: Props) {
66+ const sp = await searchParams;
67+ const q = (sp.q ?? "").trim();
68+ const scopeGroup = sp.group?.trim() || undefined;
69+ const sortReq: SearchSort =
70+ sp.sort === "newest" || sp.sort === "oldest" ? sp.sort : "relevance";
71+ const page = Math.min(intParam(sp.page), MAX_PAGE);
72+
73+ const pq = q ? parseQuery(q) : null;
74+ if (pq && scopeGroup && !pq.group) pq.group = scopeGroup;
75+ const group = pq?.group ?? scopeGroup;
76+
77+ const res = pq ? searchMessages(pq, { page, perPage: PER_PAGE, sort: sortReq }) : null;
78+ const totalPages = res ? Math.min(MAX_PAGE, Math.ceil(res.total / PER_PAGE)) : 0;
79+
80+ const hrefFor = (p: number) =>
81+ searchHref({
82+ q,
83+ group: scopeGroup,
84+ sort: sortReq === "relevance" ? undefined : sortReq,
85+ page: p > 1 ? p : undefined,
86+ });
87+
88+ const meta = res && !res.error
89+ ? `${res.capped ? "over " : ""}${fmt(res.total)} ${res.total === 1 ? "result" : "results"} · ${(res.ms / 1000).toFixed(2)}s`
90+ : undefined;
91+
92+ return (
93+ <Window
94+ path="/usr/usenet/search"
95+ meta={meta}
96+ crumbs={
97+ <>
98+ <Sep />
99+ {group ? (
100+ <>
101+ <Link href="/search">search</Link>
102+ <Sep />
103+ <Link href={groupHref(group)} rel="up">
104+ {group}
105+ </Link>
106+ </>
107+ ) : (
108+ <span className="here">search</span>
109+ )}
110+ </>
111+ }
112+ >
113+ <div className="p9-bar">
114+ <form className="p9-search" action="/search" method="GET" role="search">
115+ <label htmlFor="q" className="visually-hidden">
116+ query
117+ </label>
118+ <input
119+ id="q"
120+ type="search"
121+ name="q"
122+ defaultValue={q}
123+ placeholder='words, "phrases", from:pike, subject:plan9, after:1990'
124+ autoFocus={!q}
125+ data-search
126+ autoComplete="off"
127+ />
128+ {scopeGroup && (
129+ <select name="group" defaultValue={scopeGroup} aria-label="Scope">
130+ <option value={scopeGroup}>in {scopeGroup}</option>
131+ <option value="">all groups</option>
132+ </select>
133+ )}
134+ <select name="sort" defaultValue={sortReq} aria-label="Sort">
135+ <option value="relevance">best match</option>
136+ <option value="newest">newest</option>
137+ <option value="oldest">oldest</option>
138+ </select>
25139 <button type="submit">find</button>
26140 </form>
27− <div className="p9-empty">Enter a search query above.</div>
28141 </div>
29− );
30− }
31142
32− const { messages, total } = searchMessages(q, group, page, perPage);
33− const totalPages = Math.ceil(total / perPage);
143+ {!q && <Examples />}
34144
35− return (
36− <div className="p9-win">
37− <div className="p9-win-title">
38− <span className="path">/usr/usenet/search</span>
39− <span className="meta">{total.toLocaleString()} results</span>
40− </div>
41− <div className="p9-bread">
42− <Link href="/">usenet</Link> / search
43− {group && (
44− <>
45− {" "}/ <Link href={`/group/${group}`}>{group}</Link>
46− </>
47− )}
48− </div>
49− <form className="p9-search" action="/search" method="GET">
50− <span>Search:</span>
51− <input type="text" name="q" defaultValue={q} />
52− {group && <input type="hidden" name="group" value={group} />}
53− <button type="submit">find</button>
54− </form>
55− {messages.length === 0 ? (
56− <div className="p9-empty">No results for &quot;{q}&quot;.</div>
57− ) : (
58− <table className="p9-table">
59− <thead>
60− <tr>
61− <th>group</th>
62− <th className="col-num">#</th>
63− <th>subject</th>
64− <th className="col-from">from</th>
65− </tr>
66− </thead>
67− <tbody>
68− {messages.map((m) => (
69− <tr key={m.id}>
70− <td>
71− <Link href={`/group/${m.newsgroup}`}>{m.newsgroup}</Link>
72− </td>
73− <td className="col-num">{m.original_id}</td>
74− <td>
75− <Link href={`/group/${m.newsgroup}/${m.original_id}`}>
76− {m.subject || "(no subject)"}
77− </Link>
78− </td>
79− <td className="col-from">{m.from_addr}</td>
80− </tr>
81− ))}
82− </tbody>
83− </table>
84− )}
85− {totalPages > 1 && (
86− <div className="p9-pager">
87− {page > 1 && (
88− <Link
89− href={`/search?q=${encodeURIComponent(q)}${group ? `&group=${group}` : ""}&page=${page - 1}`}
90− >
91− &#9666; prev
92− </Link>
145+ {res?.error && <div className="p9-error">{res.error}</div>}
146+
147+ {res && !res.error && (
148+ <>
149+ {(res.sortFallback || res.capped || !hasMetaIndex()) && (
150+ <div className="p9-status">
151+ {res.sortFallback && (
152+ <span className="warn">
153+ Too many matches to sort by date — showing best matches. Narrow the query (add
154+ words, group:, after:/before:) to sort by date.
155+ </span>
156+ )}
157+ {res.capped && !res.sortFallback && <span>Counting stopped at {fmt(res.total)}.</span>}
158+ {!hasMetaIndex() && <span>Header index missing — run npm run index:meta.</span>}
159+ </div>
93160 )}
94− <span>
95− page {page}/{totalPages}
96− </span>
97− {page < totalPages && (
98− <Link
99− href={`/search?q=${encodeURIComponent(q)}${group ? `&group=${group}` : ""}&page=${page + 1}`}
100− >
101− next &#9656;
102− </Link>
161+
162+ {res.hits.length === 0 ? (
163+ <div className="p9-empty">
164+ {res.total > 0 ? (
165+ <>
166+ Past the last page. <Link href={hrefFor(1)}>Back to page 1</Link>
167+ </>
168+ ) : (
169+ <>
170+ No messages match <b>{q}</b>
171+ {group ? <> in {group}</> : null}.
172+ {group && scopeGroup && (
173+ <>
174+ {" "}
175+ <Link href={searchHref({ q, sort: sortReq === "relevance" ? undefined : sortReq })}>
176+ Try all groups →
177+ </Link>
178+ </>
179+ )}
180+ </>
181+ )}
182+ </div>
183+ ) : (
184+ <ol className="p9-hits" start={(page - 1) * PER_PAGE + 1}>
185+ {res.hits.map((h) => {
186+ const who = authorName(decodeMime(h.from_addr));
187+ const authorKey = authorAddress(h.from_addr) || who;
188+ return (
189+ <li key={h.id}>
190+ <Link className="subj" href={messageHref(h.newsgroup, h.original_id)}>
191+ {h.subjectHl ? <Highlight text={decodeMime(h.subjectHl)} /> : decodeMime(h.subject) || "(no subject)"}
192+ </Link>
193+ <div className="meta">
194+ <Link href={groupHref(h.newsgroup)}>{h.newsgroup}</Link>
195+ <span>{fmtDate(h.date)}</span>
196+ {h.from_addr && (
197+ <Link
198+ href={searchHref({ q: `from:${queryValue(authorKey)}`, sort: "newest" })}
199+ title={h.from_addr}
200+ >
201+ {h.fromHl ? <Highlight text={decodeMime(h.fromHl)} /> : who}
202+ </Link>
203+ )}
204+ </div>
205+ {h.excerpt && (
206+ <p className="excerpt">
207+ <Highlight text={h.excerpt} />
208+ </p>
209+ )}
210+ </li>
211+ );
212+ })}
213+ </ol>
103214 )}
104− </div>
215+
216+ {totalPages > 1 && (
217+ <Pager
218+ page={page}
219+ totalPages={totalPages}
220+ href={hrefFor}
221+ action="/search"
222+ hidden={{
223+ q,
224+ group: scopeGroup,
225+ sort: sortReq === "relevance" ? undefined : sortReq,
226+ }}
227+ />
228+ )}
229+ </>
105230 )}
106− </div>
231+ </Window>
107232 );
108233 }
components/Pager.tsx+87−0
@@ -0,0 +1,87 @@
1+import Link from "next/link";
2+import { fmt } from "@/lib/format";
3+
4+/** Windowed pager: « ‹ 1 … 4 5 [6] 7 8 … 120 › » plus a jump-to-page box. */
5+export function Pager({
6+ page,
7+ totalPages,
8+ href,
9+ action,
10+ hidden = {},
11+ label,
12+}: {
13+ page: number;
14+ totalPages: number;
15+ href: (p: number) => string;
16+ /** form action for the jump box */
17+ action: string;
18+ /** extra params the jump box must carry */
19+ hidden?: Record<string, string | undefined>;
20+ label?: string;
21+}) {
22+ if (totalPages <= 1) return label ? <div className="p9-pager"><span className="count">{label}</span></div> : null;
23+
24+ const around = 2;
25+ const pages: (number | "gap")[] = [];
26+ const lo = Math.max(1, page - around);
27+ const hi = Math.min(totalPages, page + around);
28+ if (lo > 1) pages.push(1);
29+ if (lo > 2) pages.push("gap");
30+ for (let p = lo; p <= hi; p++) pages.push(p);
31+ if (hi < totalPages - 1) pages.push("gap");
32+ if (hi < totalPages) pages.push(totalPages);
33+
34+ return (
35+ <nav className="p9-pager" aria-label="Pagination">
36+ {page > 1 ? (
37+ <Link href={href(page - 1)} rel="prev" className="step">
38+ &#9666; prev
39+ </Link>
40+ ) : (
41+ <span className="step dim">&#9666; prev</span>
42+ )}
43+ <span className="pages">
44+ {pages.map((p, i) =>
45+ p === "gap" ? (
46+ <span key={`g${i}`} className="gap">
47+ …
48+ </span>
49+ ) : p === page ? (
50+ <span key={p} className="cur" aria-current="page">
51+ {fmt(p)}
52+ </span>
53+ ) : (
54+ <Link key={p} href={href(p)}>
55+ {fmt(p)}
56+ </Link>
57+ )
58+ )}
59+ </span>
60+ {page < totalPages ? (
61+ <Link href={href(page + 1)} rel="next" className="step">
62+ next &#9656;
63+ </Link>
64+ ) : (
65+ <span className="step dim">next &#9656;</span>
66+ )}
67+ <form action={action} method="GET" className="jump">
68+ {Object.entries(hidden).map(([k, v]) =>
69+ v ? <input key={k} type="hidden" name={k} value={v} /> : null
70+ )}
71+ <label>
72+ page{" "}
73+ <input
74+ type="number"
75+ name="page"
76+ min={1}
77+ max={totalPages}
78+ defaultValue={page}
79+ aria-label="Go to page"
80+ />{" "}
81+ / {fmt(totalPages)}
82+ </label>
83+ </form>
84+ {label && <span className="count">{label}</span>}
85+ </nav>
86+ );
87+}
components/Window.tsx+42−0
@@ -0,0 +1,42 @@
1+import Link from "next/link";
2+import type { ReactNode } from "react";
3+
4+/** A rio-style window: title bar, breadcrumb/tag bar, body. */
5+export function Window({
6+ path,
7+ meta,
8+ crumbs,
9+ children,
10+ wide,
11+}: {
12+ path: string;
13+ meta?: ReactNode;
14+ crumbs?: ReactNode;
15+ children: ReactNode;
16+ wide?: boolean;
17+}) {
18+ return (
19+ <main className={`p9-win${wide ? " wide" : ""}`}>
20+ <header className="p9-win-title">
21+ <h1 className="path" title={path}>
22+ {path}
23+ </h1>
24+ {meta && <span className="meta">{meta}</span>}
25+ </header>
26+ <nav className="p9-bread" aria-label="Breadcrumb">
27+ <span className="crumbs">
28+ <Link href="/">usenet</Link>
29+ {crumbs}
30+ </span>
31+ <span className="nav">
32+ <Link href="/">groups</Link>
33+ <Link href="/search">search</Link>
34+ <Link href="/help">help</Link>
35+ </span>
36+ </nav>
37+ {children}
38+ </main>
39+ );
40+}
41+
42+export const Sep = () => <span className="sep"> / </span>;
lib/db.ts+310−116
@@ -1,18 +1,34 @@
11 import Database from "better-sqlite3";
22 import path from "path";
3+import type { ParsedQuery } from "./query";
4+import { HL_OPEN } from "./format";
35
4−const DB_PATH = path.join(process.cwd(), "data", "usenet.db");
6+const DB_PATH = process.env.USENET_DB ?? path.join(process.cwd(), "data", "usenet.db");
57
68 let _db: Database.Database | null = null;
79
8−function getDb(): Database.Database {
10+function db(): Database.Database {
911 if (!_db) {
10− _db = new Database(DB_PATH, { readonly: true });
11− _db.pragma("journal_mode = WAL");
12+ _db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
13+ _db.pragma("query_only = ON");
14+ _db.pragma("cache_size = -131072"); // 128 MiB page cache
15+ _db.pragma("mmap_size = 2147483648"); // 2 GiB memory map
16+ _db.pragma("temp_store = MEMORY");
1217 }
1318 return _db;
1419 }
1520
21+// Prepared-statement cache: SQL text is the key.
22+const stmts = new Map<string, Database.Statement>();
23+function q(sql: string): Database.Statement {
24+ let s = stmts.get(sql);
25+ if (!s) {
26+ s = db().prepare(sql);
27+ stmts.set(sql, s);
28+ }
29+ return s;
30+}
31+
1632 export interface Newsgroup {
1733 name: string;
1834 message_count: number;
@@ -20,7 +36,7 @@ export interface Newsgroup { · 1 unmodified line
2036 last_date: string | null;
2137 }
2238
23−export interface Message {
39+export interface MessageRow {
2440 id: number;
2541 newsgroup: string;
2642 original_id: number;
@@ -28,145 +44,323 @@ export interface Message { · 1 unmodified line
2844 date: string;
2945 subject: string;
3046 message_id: string;
47+}
48+
49+export interface Message extends MessageRow {
3150 body: string;
3251 }
3352
53+/* ------------------------------------------------------------------ groups */
54+
55+let _groups: Newsgroup[] | null = null;
56+let _groupMap: Map<string, Newsgroup> | null = null;
57+
58+/** All newsgroups (the archive is static, so this is cached for the process). */
3459 export function getNewsgroups(): Newsgroup[] {
35− const db = getDb();
36− return db
37− .prepare(
38− `SELECT name, message_count, first_date, last_date
60+ if (!_groups) {
61+ _groups = q(
62+ `SELECT name, message_count,
63+ NULLIF(first_date, '') AS first_date,
64+ NULLIF(last_date, '') AS last_date
3965 FROM newsgroups ORDER BY name`
40− )
41− .all() as Newsgroup[];
66+ ).all() as Newsgroup[];
67+ _groupMap = new Map(_groups.map((g) => [g.name, g]));
68+ }
69+ return _groups;
4270 }
4371
4472 export function getNewsgroup(name: string): Newsgroup | undefined {
45− const db = getDb();
46− return db
47− .prepare(`SELECT * FROM newsgroups WHERE name = ?`)
48− .get(name) as Newsgroup | undefined;
73+ getNewsgroups();
74+ return _groupMap!.get(name);
75+}
76+
77+export function getTotals(): { groups: number; messages: number } {
78+ const g = getNewsgroups();
79+ return { groups: g.length, messages: g.reduce((s, x) => s + x.message_count, 0) };
80+}
81+
82+interface IdRange {
83+ lo: number;
84+ hi: number;
85+ /** every id in [lo, hi] belongs to this group */
86+ contiguous: boolean;
87+}
88+const ranges = new Map<string, IdRange>();
89+
90+/** Row-id span of a group — lets FTS5 seek straight to it instead of joining. */
91+function groupRange(name: string): IdRange | null {
92+ const hit = ranges.get(name);
93+ if (hit) return hit;
94+ const g = getNewsgroup(name);
95+ if (!g) return null;
96+ const r = q(
97+ `SELECT (SELECT id FROM messages WHERE newsgroup = ? ORDER BY id ASC LIMIT 1) AS lo,
98+ (SELECT id FROM messages WHERE newsgroup = ? ORDER BY id DESC LIMIT 1) AS hi`
99+ ).get(name, name) as { lo: number | null; hi: number | null };
100+ if (r.lo == null || r.hi == null) return null;
101+ const range = { lo: r.lo, hi: r.hi, contiguous: r.hi - r.lo + 1 === g.message_count };
102+ ranges.set(name, range);
103+ return range;
49104 }
50105
106+/* ---------------------------------------------------------------- messages */
107+
108+const LIST_COLS = `id, newsgroup, original_id, from_addr, date, subject, message_id`;
109+
51110 export function getMessages(
52111 newsgroup: string,
53112 page: number,
54− perPage: number = 50
55−): { messages: Message[]; total: number } {
56− const db = getDb();
57− const total = (
58− db
59− .prepare(`SELECT COUNT(*) as c FROM messages WHERE newsgroup = ?`)
60− .get(newsgroup) as { c: number }
61− ).c;
62− const messages = db
63− .prepare(
64− `SELECT id, newsgroup, original_id, from_addr, date, subject, message_id
65− FROM messages WHERE newsgroup = ?
66− ORDER BY original_id ASC
67− LIMIT ? OFFSET ?`
68− )
69− .all(newsgroup, perPage, (page - 1) * perPage) as Message[];
113+ perPage = 50,
114+ order: "asc" | "desc" = "asc"
115+): { messages: MessageRow[]; total: number } {
116+ const total = getNewsgroup(newsgroup)?.message_count ?? 0;
117+ const dir = order === "desc" ? "DESC" : "ASC";
118+ const messages = q(
119+ `SELECT ${LIST_COLS} FROM messages
120+ WHERE newsgroup = ? ORDER BY original_id ${dir} LIMIT ? OFFSET ?`
121+ ).all(newsgroup, perPage, (page - 1) * perPage) as MessageRow[];
70122 return { messages, total };
71123 }
72124
73−export function getMessage(
74− newsgroup: string,
75− originalId: number
76−): Message | undefined {
77− const db = getDb();
78− return db
79− .prepare(
80− `SELECT * FROM messages WHERE newsgroup = ? AND original_id = ?`
81− )
82− .get(newsgroup, originalId) as Message | undefined;
125+export function getMessage(newsgroup: string, originalId: number): Message | undefined {
126+ return q(`SELECT * FROM messages WHERE newsgroup = ? AND original_id = ?`).get(
127+ newsgroup,
128+ originalId
129+ ) as Message | undefined;
83130 }
84131
85132 export function getAdjacentIds(
86133 newsgroup: string,
87134 originalId: number
88−): { prev: number | null; next: number | null } {
89− const db = getDb();
90− const prev = db
91− .prepare(
92− `SELECT original_id FROM messages
93− WHERE newsgroup = ? AND original_id < ?
94− ORDER BY original_id DESC LIMIT 1`
95− )
96− .get(newsgroup, originalId) as { original_id: number } | undefined;
97− const next = db
98− .prepare(
99− `SELECT original_id FROM messages
100− WHERE newsgroup = ? AND original_id > ?
101− ORDER BY original_id ASC LIMIT 1`
102− )
103− .get(newsgroup, originalId) as { original_id: number } | undefined;
135+): { prev: number | null; next: number | null; position: number } {
136+ const prev = q(
137+ `SELECT original_id FROM messages WHERE newsgroup = ? AND original_id < ?
138+ ORDER BY original_id DESC LIMIT 1`
139+ ).get(newsgroup, originalId) as { original_id: number } | undefined;
140+ const next = q(
141+ `SELECT original_id FROM messages WHERE newsgroup = ? AND original_id > ?
142+ ORDER BY original_id ASC LIMIT 1`
143+ ).get(newsgroup, originalId) as { original_id: number } | undefined;
144+ const before = q(
145+ `SELECT COUNT(*) AS c FROM messages WHERE newsgroup = ? AND original_id < ?`
146+ ).get(newsgroup, originalId) as { c: number };
104147 return {
105148 prev: prev?.original_id ?? null,
106149 next: next?.original_id ?? null,
150+ position: before.c,
107151 };
108152 }
109153
154+/* ------------------------------------------------------------------ search */
155+
156+let _hasMeta: boolean | null = null;
157+/** meta_fts is built by `npm run index:meta`; search degrades gracefully without it. */
158+export function hasMetaIndex(): boolean {
159+ if (_hasMeta === null) {
160+ _hasMeta = !!q(
161+ `SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta_fts'`
162+ ).get();
163+ }
164+ return _hasMeta;
165+}
166+
167+export type SearchSort = "relevance" | "newest" | "oldest";
168+
169+export interface SearchHit extends MessageRow {
170+ /** body excerpt; \u0002…\u0003 delimit highlighted spans */
171+ excerpt: string;
172+ /** subject/from with highlight markers when they matched */
173+ subjectHl?: string;
174+ fromHl?: string;
175+}
176+
177+export interface SearchResult {
178+ hits: SearchHit[];
179+ total: number;
180+ /** total is a lower bound (counting was capped) */
181+ capped: boolean;
182+ /** requested sort could not be honoured (too many matches) */
183+ sortFallback: boolean;
184+ sort: SearchSort;
185+ ms: number;
186+ error?: string;
187+}
188+
189+/** Result sets larger than this are not re-sorted by date (it would need a full join). */
190+const DATE_SORT_LIMIT = 100_000;
191+/** When counting needs a join against `messages`, stop counting here. */
192+const JOIN_COUNT_CAP = 10_000;
193+
194+
195+function cleanExcerpt(body: string): string {
196+ // First meaningful lines: skip blank, quoted, attribution and header-ish lines.
197+ const out: string[] = [];
198+ for (const raw of body.split("\n")) {
199+ const line = raw.trim();
200+ if (!line || line.startsWith(">") || line.startsWith("|")) continue;
201+ if (/^(in article|on .{0,80} wrote:?$|.{0,80} writes:$)/i.test(line)) continue;
202+ out.push(line);
203+ if (out.join(" ").length > 260) break;
204+ }
205+ const s = out.join(" ");
206+ return s.length > 280 ? s.slice(0, 280).replace(/\s+\S*$/, "") + " …" : s;
207+}
208+
110209 export function searchMessages(
111− query: string,
112− newsgroup?: string,
113− page: number = 1,
114− perPage: number = 50
115−): { messages: Message[]; total: number } {
116− const db = getDb();
117− const ftsQuery = query
118− .split(/\s+/)
119− .filter(Boolean)
120− .map((t) => `"${t.replace(/"/g, '""')}"`)
121− .join(" ");
122−
123− if (newsgroup) {
124− const total = (
125− db
126− .prepare(
127− `SELECT COUNT(*) as c FROM messages_fts
128− JOIN messages ON messages.rowid = messages_fts.rowid
129− WHERE messages_fts MATCH ? AND messages.newsgroup = ?`
130− )
131− .get(ftsQuery, newsgroup) as { c: number }
132− ).c;
133− const messages = db
134− .prepare(
135− `SELECT messages.id, messages.newsgroup, messages.original_id,
136− messages.from_addr, messages.date, messages.subject,
137− messages.message_id,
138− snippet(messages_fts, 0, '<mark class="highlight">', '</mark>', '...', 40) as body
139− FROM messages_fts
140− JOIN messages ON messages.rowid = messages_fts.rowid
141− WHERE messages_fts MATCH ? AND messages.newsgroup = ?
142− ORDER BY rank
143− LIMIT ? OFFSET ?`
144− )
145− .all(ftsQuery, newsgroup, perPage, (page - 1) * perPage) as Message[];
146− return { messages, total };
210+ pq: ParsedQuery,
211+ opts: { page: number; perPage: number; sort: SearchSort }
212+): SearchResult {
213+ const t0 = performance.now();
214+ const done = (r: Omit<SearchResult, "ms">): SearchResult => ({
215+ ...r,
216+ ms: performance.now() - t0,
217+ });
218+ const empty = { hits: [], total: 0, capped: false, sortFallback: false, sort: opts.sort };
219+
220+ if (pq.error) return done({ ...empty, error: pq.error });
221+
222+ const meta = hasMetaIndex();
223+ if (!meta && (pq.meta.pos || pq.meta.negOnly)) {
224+ return done({
225+ ...empty,
226+ error: "from: and subject: need the header index — run `npm run index:meta` once.",
227+ });
147228 }
148229
149− const total = (
150− db
151− .prepare(
152− `SELECT COUNT(*) as c FROM messages_fts
153− JOIN messages ON messages.rowid = messages_fts.rowid
154− WHERE messages_fts MATCH ?`
155− )
156− .get(ftsQuery) as { c: number }
157− ).c;
158− const messages = db
159− .prepare(
160− `SELECT messages.id, messages.newsgroup, messages.original_id,
161− messages.from_addr, messages.date, messages.subject,
162− messages.message_id,
163− snippet(messages_fts, 0, '<mark class="highlight">', '</mark>', '...', 40) as body
164− FROM messages_fts
165− JOIN messages ON messages.rowid = messages_fts.rowid
166− WHERE messages_fts MATCH ?
167− ORDER BY rank
168− LIMIT ? OFFSET ?`
169− )
170− .all(ftsQuery, perPage, (page - 1) * perPage) as Message[];
171− return { messages, total };
230+ let groupFilter: string | null = null;
231+ let range: IdRange | null = null;
232+ if (pq.group) {
233+ if (!getNewsgroup(pq.group)) return done({ ...empty, error: `No newsgroup named “${pq.group}”.` });
234+ range = groupRange(pq.group);
235+ if (!range) return done(empty);
236+ if (!range.contiguous) groupFilter = pq.group;
237+ }
238+
239+ // Pick the driving index: body if it has positive terms, else headers.
240+ const bodyDrives = !!pq.body.pos;
241+ const T = bodyDrives ? "messages_fts" : "meta_fts";
242+ const where: string[] = [`${T} MATCH ?`];
243+ const params: unknown[] = [bodyDrives ? pq.body.pos : pq.meta.pos];
244+
245+ if (bodyDrives && pq.meta.pos) {
246+ where.push(`${T}.rowid IN (SELECT rowid FROM meta_fts WHERE meta_fts MATCH ?)`);
247+ params.push(pq.meta.pos);
248+ }
249+ if (!bodyDrives && pq.body.negOnly) {
250+ where.push(`${T}.rowid NOT IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)`);
251+ params.push(pq.body.negOnly);
252+ }
253+ if (pq.meta.negOnly) {
254+ where.push(`${T}.rowid NOT IN (SELECT rowid FROM meta_fts WHERE meta_fts MATCH ?)`);
255+ params.push(pq.meta.negOnly);
256+ }
257+ if (range) {
258+ where.push(`${T}.rowid BETWEEN ? AND ?`);
259+ params.push(range.lo, range.hi);
260+ }
261+
262+ const joinConds: string[] = [];
263+ const joinParams: unknown[] = [];
264+ if (groupFilter) {
265+ joinConds.push(`m.newsgroup = ?`);
266+ joinParams.push(groupFilter);
267+ }
268+ if (pq.after) {
269+ joinConds.push(`m.date >= ?`);
270+ joinParams.push(pq.after);
271+ }
272+ if (pq.before) {
273+ joinConds.push(`m.date < ? AND m.date <> ''`);
274+ joinParams.push(pq.before);
275+ }
276+ const filterJoin = joinConds.length > 0;
277+
278+ const from = (join: boolean) =>
279+ `FROM ${T}${join ? ` JOIN messages m ON m.id = ${T}.rowid` : ""}
280+ WHERE ${where.join(" AND ")}${join && joinConds.length ? ` AND ${joinConds.join(" AND ")}` : ""}`;
281+
282+ try {
283+ // ---- count
284+ let total: number;
285+ let capped = false;
286+ if (filterJoin) {
287+ total = (
288+ q(`SELECT COUNT(*) AS c FROM (SELECT 1 ${from(true)} LIMIT ${JOIN_COUNT_CAP + 1})`).get(
289+ ...params,
290+ ...joinParams
291+ ) as { c: number }
292+ ).c;
293+ if (total > JOIN_COUNT_CAP) {
294+ total = JOIN_COUNT_CAP;
295+ capped = true;
296+ }
297+ } else {
298+ total = (q(`SELECT COUNT(*) AS c ${from(false)}`).get(...params) as { c: number }).c;
299+ }
300+ if (total === 0) return done({ ...empty, total: 0 });
301+
302+ // ---- sort
303+ let sort = opts.sort;
304+ let sortFallback = false;
305+ if (sort !== "relevance" && (capped || total > DATE_SORT_LIMIT)) {
306+ sort = "relevance";
307+ sortFallback = true;
308+ }
309+ const needJoin = filterJoin || sort !== "relevance";
310+ const order =
311+ sort === "newest"
312+ ? `ORDER BY (m.date = '') ASC, m.date DESC, m.id DESC`
313+ : sort === "oldest"
314+ ? `ORDER BY (m.date = '') ASC, m.date ASC, m.id ASC`
315+ : `ORDER BY ${T}.rank`;
316+
317+ const ids = (
318+ q(`SELECT ${T}.rowid AS id ${from(needJoin)} ${order} LIMIT ? OFFSET ?`).all(
319+ ...params,
320+ ...(needJoin ? joinParams : []),
321+ opts.perPage,
322+ (opts.page - 1) * opts.perPage
323+ ) as { id: number }[]
324+ ).map((r) => r.id);
325+ if (ids.length === 0) return done({ ...empty, total, capped, sortFallback, sort });
326+
327+ // ---- hydrate (only the rows on this page)
328+ const rowStmt = q(`SELECT ${LIST_COLS}, substr(body, 1, 1500) AS head FROM messages WHERE id = ?`);
329+ const snipStmt = pq.body.pos
330+ ? q(
331+ `SELECT snippet(messages_fts, 0, char(2), char(3), ' … ', 32) AS s
332+ FROM messages_fts WHERE messages_fts MATCH ? AND rowid = ?`
333+ )
334+ : null;
335+ const hlStmt =
336+ meta && pq.meta.pos
337+ ? q(
338+ `SELECT highlight(meta_fts, 0, char(2), char(3)) AS s,
339+ highlight(meta_fts, 1, char(2), char(3)) AS f
340+ FROM meta_fts WHERE meta_fts MATCH ? AND rowid = ?`
341+ )
342+ : null;
343+
344+ const hits: SearchHit[] = ids.map((id) => {
345+ const row = rowStmt.get(id) as MessageRow & { head: string };
346+ const { head, ...rest } = row;
347+ const hit: SearchHit = { ...rest, excerpt: "" };
348+ if (snipStmt) {
349+ const s = snipStmt.get(pq.body.pos, id) as { s: string } | undefined;
350+ hit.excerpt = (s?.s ?? "").replace(/\s+/g, " ").trim();
351+ }
352+ if (!hit.excerpt) hit.excerpt = cleanExcerpt(head);
353+ if (hlStmt) {
354+ const h = hlStmt.get(pq.meta.pos, id) as { s: string; f: string } | undefined;
355+ if (h?.s?.includes(HL_OPEN)) hit.subjectHl = h.s;
356+ if (h?.f?.includes(HL_OPEN)) hit.fromHl = h.f;
357+ }
358+ return hit;
359+ });
360+
361+ return done({ hits, total, capped, sortFallback, sort });
362+ } catch (e) {
363+ const msg = e instanceof Error ? e.message : String(e);
364+ return done({ ...empty, error: `Search failed: ${msg}` });
365+ }
172366 }
lib/format.tsx+189−0
@@ -0,0 +1,189 @@
1+import type { ReactNode } from "react";
2+
3+export const HL_OPEN = "\u0002";
4+export const HL_CLOSE = "\u0003";
5+
6+/** Route params arrive percent-encoded (group names contain @, spaces, commas…). */
7+export function decodeParam(s: string): string {
8+ try {
9+ return decodeURIComponent(s);
10+ } catch {
11+ return s;
12+ }
13+}
14+
15+export const groupHref = (name: string, qs = "") =>
16+ `/group/${encodeURIComponent(name)}${qs}`;
17+
18+export const messageHref = (name: string, originalId: number) =>
19+ `/group/${encodeURIComponent(name)}/${originalId}`;
20+
21+export function searchHref(params: Record<string, string | number | undefined | null>) {
22+ const sp = new URLSearchParams();
23+ for (const [k, v] of Object.entries(params)) {
24+ if (v === undefined || v === null || v === "") continue;
25+ sp.set(k, String(v));
26+ }
27+ const s = sp.toString();
28+ return s ? `/search?${s}` : "/search";
29+}
30+
31+/** Quote a value for use inside a query (from:"Rob Pike"). */
32+export function queryValue(v: string): string {
33+ const clean = v.replace(/"/g, " ").replace(/\s+/g, " ").trim();
34+ return /\s/.test(clean) ? `"${clean}"` : clean;
35+}
36+
37+export const fmt = (n: number) => n.toLocaleString("en-US");
38+
39+export function fmtDate(d: string | null | undefined): string {
40+ return d ? d : "—";
41+}
42+
43+export function intParam(v: string | undefined, fallback = 1): number {
44+ const n = parseInt(v ?? "", 10);
45+ return Number.isFinite(n) && n > 0 ? n : fallback;
46+}
47+
48+/** Render text containing \u0002…\u0003 highlight markers as <mark>. */
49+export function Highlight({ text }: { text: string }): ReactNode {
50+ if (!text.includes(HL_OPEN)) return text;
51+ const out: ReactNode[] = [];
52+ let i = 0;
53+ let k = 0;
54+ while (i < text.length) {
55+ const a = text.indexOf(HL_OPEN, i);
56+ if (a === -1) {
57+ out.push(text.slice(i));
58+ break;
59+ }
60+ if (a > i) out.push(text.slice(i, a));
61+ const b = text.indexOf(HL_CLOSE, a + 1);
62+ const end = b === -1 ? text.length : b;
63+ out.push(<mark key={k++}>{text.slice(a + 1, end)}</mark>);
64+ i = end + 1;
65+ }
66+ return out;
67+}
68+
69+/** Pull the human name out of "addr (Name)" / "Name <addr>" / bare address. */
70+export function authorName(from: string): string {
71+ const paren = from.match(/\(([^)]+)\)\s*$/);
72+ if (paren) return paren[1].trim();
73+ const angle = from.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
74+ if (angle) return angle[1].trim();
75+ return from.trim();
76+}
77+
78+export function authorAddress(from: string): string {
79+ const angle = from.match(/<([^>]+)>/);
80+ if (angle) return angle[1];
81+ const bare = from.match(/[^\s()<>"]+@[^\s()<>"]+/);
82+ if (bare) return bare[0];
83+ const bang = from.match(/^[^\s()]+![^\s()]+/); // uucp paths
84+ if (bang) return bang[0];
85+ return "";
86+}
87+
88+/* ------------------------------------------------------------ message body */
89+
90+const URL_RE = /\b(?:https?|ftp):\/\/[^\s<>"'`]+[^\s<>"'`.,;:!?)\]}]/g;
91+
92+function linkify(text: string, keyBase: string): ReactNode[] {
93+ const out: ReactNode[] = [];
94+ let last = 0;
95+ let k = 0;
96+ for (const m of text.matchAll(URL_RE)) {
97+ const i = m.index ?? 0;
98+ if (i > last) out.push(text.slice(last, i));
99+ out.push(
100+ <a key={`${keyBase}-${k++}`} href={m[0]} rel="noopener noreferrer nofollow" target="_blank">
101+ {m[0]}
102+ </a>
103+ );
104+ last = i + m[0].length;
105+ }
106+ if (last < text.length) out.push(text.slice(last));
107+ return out;
108+}
109+
110+function lineClass(line: string, inSig: boolean): string {
111+ if (inSig) return "sig";
112+ const m = line.match(/^((?:\s?[>|])+)/);
113+ if (m) {
114+ const depth = (m[1].match(/[>|]/g) ?? []).length;
115+ return `q${((depth - 1) % 3) + 1}`;
116+ }
117+ if (/^in article <[^>]*>.*writes:\s*$/i.test(line) || /^.{1,100} (writes|wrote):\s*$/.test(line))
118+ return "attr";
119+ return "";
120+}
121+
122+/**
123+ * Message body → spans grouped by line class (quote depth, attribution,
124+ * signature). Consecutive lines with the same class share one span so huge
125+ * posts don't turn into tens of thousands of nodes.
126+ */
127+export function formatBody(body: string): ReactNode[] {
128+ const lines = body.replace(/\r\n?/g, "\n").replace(/\s+$/, "").split("\n");
129+ const out: ReactNode[] = [];
130+ let inSig = false;
131+ let cls: string | null = null;
132+ let buf: string[] = [];
133+ let k = 0;
134+ const flush = () => {
135+ if (!buf.length) return;
136+ const text = buf.join("\n") + "\n";
137+ const key = `b${k++}`;
138+ const kids = linkify(text, key);
139+ out.push(cls ? <span key={key} className={cls}>{kids}</span> : <span key={key}>{kids}</span>);
140+ buf = [];
141+ };
142+ for (const line of lines) {
143+ if (!inSig && (line === "-- " || line === "--")) inSig = true;
144+ const c = lineClass(line, inSig);
145+ if (c !== cls) {
146+ flush();
147+ cls = c;
148+ }
149+ buf.push(line);
150+ }
151+ flush();
152+ return out;
153+}
154+
155+/* -------------------------------------------------------------- hierarchy */
156+
157+/** Top-level hierarchy of a group name; crossposted/garbled names go to "misc". */
158+export function hierarchyOf(name: string): string {
159+ if (/[\s,@^]/.test(name)) return "(crossposts & strays)";
160+ const dot = name.indexOf(".");
161+ return dot === -1 ? "(top-level)" : name.slice(0, dot);
162+}
163+
164+/* ------------------------------------------------- RFC 2047 encoded words */
165+
166+const ENCODED_WORD = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/g;
167+
168+/** Decode MIME encoded-words (=?utf-8?b?…?=) in headers; leaves anything malformed alone. */
169+export function decodeMime(s: string): string {
170+ if (!s || !s.includes("=?")) return s;
171+ return s
172+ .replace(/(\?=)\s+(=\?)/g, "$1$2") // whitespace between adjacent encoded words is dropped
173+ .replace(ENCODED_WORD, (whole, charset: string, enc: string, text: string) => {
174+ try {
175+ const bytes =
176+ enc.toUpperCase() === "B"
177+ ? Buffer.from(text, "base64")
178+ : Buffer.from(
179+ text
180+ .replace(/_/g, " ")
181+ .replace(/=([0-9A-Fa-f]{2})/g, (_m, h: string) => String.fromCharCode(parseInt(h, 16))),
182+ "latin1"
183+ );
184+ return new TextDecoder(charset.split("*")[0].toLowerCase(), { fatal: false }).decode(bytes);
185+ } catch {
186+ return whole;
187+ }
188+ });
189+}
lib/query.ts+201−0
@@ -0,0 +1,201 @@
1+/**
2+ * Search query parser.
3+ *
4+ * Turns a user query into FTS5 expressions for the two indexes:
5+ * messages_fts(body) — free text
6+ * meta_fts(subject, from_addr) — subject: / from: terms
7+ *
8+ * Syntax
9+ * word must contain word
10+ * "a phrase" exact phrase
11+ * pref* prefix match
12+ * -word exclude (also -"phrase", -from:x)
13+ * a OR b either term
14+ * from:pike author (name or address) aliases: f:, by:, author:
15+ * subject:plan9 subject line aliases: s:, subj:, title:
16+ * group:comp.os.plan9 one newsgroup aliases: g:, in:
17+ * after:1990 before:1995-06 date bounds (YYYY, YYYY-MM, YYYY-MM-DD)
18+ */
19+
20+export type Field = "body" | "subject" | "from_addr";
21+
22+export interface IndexExpr {
23+ /** FTS5 expression for positive terms (NOT clauses folded in), or "" */
24+ pos: string;
25+ /** FTS5 expression of rows to exclude when this index has no positives, or "" */
26+ negOnly: string;
27+}
28+
29+export interface ParsedQuery {
30+ body: IndexExpr;
31+ meta: IndexExpr;
32+ group?: string;
33+ after?: string; // inclusive lower bound, YYYY-MM-DD
34+ before?: string; // exclusive upper bound, YYYY-MM-DD
35+ /** Positive plain terms, for echoing back to the user. */
36+ terms: string[];
37+ error?: string;
38+}
39+
40+interface Term {
41+ neg: boolean;
42+ text: string; // valid FTS5 phrase, possibly column-qualified
43+}
44+
45+type Special = "group" | "after" | "before";
46+
47+const FIELD_ALIASES: Record<string, Field | Special> = {
48+ from: "from_addr",
49+ f: "from_addr",
50+ author: "from_addr",
51+ by: "from_addr",
52+ subject: "subject",
53+ subj: "subject",
54+ s: "subject",
55+ title: "subject",
56+ group: "group",
57+ g: "group",
58+ in: "group",
59+ newsgroup: "group",
60+ after: "after",
61+ since: "after",
62+ before: "before",
63+ until: "before",
64+};
65+
66+const HAS_WORD = /[\p{L}\p{N}]/u;
67+
68+function phrase(raw: string): string | null {
69+ let prefix = false;
70+ let s = raw;
71+ if (s.endsWith("*")) {
72+ prefix = true;
73+ s = s.replace(/\*+$/, "");
74+ }
75+ if (!HAS_WORD.test(s)) return null;
76+ return `"${s.replace(/"/g, '""')}"${prefix ? "*" : ""}`;
77+}
78+
79+/** Normalise YYYY / YYYY-MM / YYYY-MM-DD. `end` rounds up to the start of the next period. */
80+export function parseDate(v: string, end: boolean): string | null {
81+ const m = v.match(/^(\d{4})(?:-(\d{1,2}))?(?:-(\d{1,2}))?$/);
82+ if (!m) return null;
83+ const y = +m[1];
84+ const mo = m[2] ? +m[2] : 1;
85+ const d = m[3] ? +m[3] : 1;
86+ if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
87+ let dt = new Date(Date.UTC(y, mo - 1, d));
88+ if (end) {
89+ if (m[3]) dt = new Date(Date.UTC(y, mo - 1, d + 1));
90+ else if (m[2]) dt = new Date(Date.UTC(y, mo, 1));
91+ else dt = new Date(Date.UTC(y + 1, 0, 1));
92+ }
93+ return dt.toISOString().slice(0, 10);
94+}
95+
96+function buildExpr(terms: Term[], orAfter: Set<number>): IndexExpr {
97+ const pos: string[] = [];
98+ const neg: string[] = [];
99+ let cur: string[] = [];
100+ const flush = () => {
101+ if (cur.length) pos.push(cur.length > 1 ? `(${cur.join(" OR ")})` : cur[0]);
102+ cur = [];
103+ };
104+ terms.forEach((t, i) => {
105+ if (t.neg) {
106+ neg.push(t.text);
107+ return;
108+ }
109+ cur.push(t.text);
110+ if (!orAfter.has(i)) flush();
111+ });
112+ flush();
113+
114+ const negExpr = neg.length > 1 ? `(${neg.join(" OR ")})` : (neg[0] ?? "");
115+ if (pos.length) {
116+ const p = pos.length > 1 ? pos.join(" AND ") : pos[0];
117+ return { pos: negExpr ? `(${p}) NOT ${negExpr}` : p, negOnly: "" };
118+ }
119+ return { pos: "", negOnly: negExpr };
120+}
121+
122+export function parseQuery(input: string): ParsedQuery {
123+ const re = /(-)?(?:([A-Za-z_]+):)?(?:"([^"]*)"?|(\S+))/g;
124+ const out: ParsedQuery = {
125+ body: { pos: "", negOnly: "" },
126+ meta: { pos: "", negOnly: "" },
127+ terms: [],
128+ };
129+ const body: Term[] = [];
130+ const meta: Term[] = [];
131+ const bodyOr = new Set<number>();
132+ const metaOr = new Set<number>();
133+ let pendingOr = false;
134+ let lastList: Term[] | null = null;
135+
136+ let m: RegExpExecArray | null;
137+ while ((m = re.exec(input))) {
138+ if (m[0] === "") {
139+ re.lastIndex++;
140+ continue;
141+ }
142+ const [, minus, rawField, quoted, bare] = m;
143+ const neg = !!minus;
144+ const value = quoted ?? bare ?? "";
145+
146+ if (!rawField && quoted === undefined && !neg && (value === "OR" || value === "|")) {
147+ pendingOr = true;
148+ continue;
149+ }
150+
151+ const alias = rawField ? FIELD_ALIASES[rawField.toLowerCase()] : undefined;
152+ // Unknown "foo:bar" (a URL, "Re:") is just text.
153+ const text = rawField && !alias ? `${rawField}:${value}` : value;
154+
155+ if (alias === "group") {
156+ if (value) out.group = value;
157+ pendingOr = false;
158+ continue;
159+ }
160+ if (alias === "after" || alias === "before") {
161+ const d = parseDate(value, alias === "before");
162+ if (!d) out.error = `Bad date “${value}” — use YYYY, YYYY-MM or YYYY-MM-DD.`;
163+ else if (alias === "after") out.after = d;
164+ else out.before = d;
165+ pendingOr = false;
166+ continue;
167+ }
168+
169+ const ph = phrase(text);
170+ if (!ph) {
171+ pendingOr = false;
172+ continue;
173+ }
174+ const field: Field = (alias as Field | undefined) ?? "body";
175+ const isMeta = field !== "body";
176+ const list = isMeta ? meta : body;
177+ const orSet = isMeta ? metaOr : bodyOr;
178+
179+ // OR links two positive terms that land in the same index.
180+ if (pendingOr && !neg && lastList === list && list.length && !list[list.length - 1].neg) {
181+ orSet.add(list.length - 1);
182+ }
183+ list.push({ neg, text: isMeta ? `${field} : ${ph}` : ph });
184+ lastList = list;
185+ pendingOr = false;
186+ if (!neg) out.terms.push(text.replace(/\*+$/, ""));
187+ }
188+
189+ out.body = buildExpr(body, bodyOr);
190+ out.meta = buildExpr(meta, metaOr);
191+
192+ if (!out.error && !out.body.pos && !out.meta.pos) {
193+ if (out.body.negOnly || out.meta.negOnly)
194+ out.error = "Exclusions need something to exclude from — add a word, phrase, from: or subject: term.";
195+ else if (out.group || out.after || out.before)
196+ out.error = "Add a word, phrase, from: or subject: term to search within that filter.";
197+ }
198+ if (!out.error && out.after && out.before && out.after >= out.before)
199+ out.error = "after: must be earlier than before:.";
200+ return out;
201+}
next.config.ts+1−0
@@ -2,6 +2,7 @@ import type { NextConfig } from "next"; · 1 unmodified line
22
33 const nextConfig: NextConfig = {
44 serverExternalPackages: ["better-sqlite3"],
5+ devIndicators: false,
56 };
67
78 export default nextConfig;
package.json+6−1
@@ -9,7 +9,8 @@ · 8 unmodified lines
99 "import": "tsx scripts/import.ts",
1010 "import:mbox": "tsx scripts/import-mbox.ts",
1111 "import:utzoo": "tsx scripts/import-utzoo.ts",
12− "resplit": "tsx scripts/resplit.ts"
12+ "resplit": "tsx scripts/resplit.ts",
13+ "index:meta": "tsx scripts/index-meta.ts"
1314 },
1415 "dependencies": {
1516 "better-sqlite3": "^12.9.0",
@@ -24,5 +25,9 @@ · 8 unmodified lines
2425 "@types/react-dom": "^19",
2526 "tsx": "^4.21.0",
2627 "typescript": "^5"
28+ },
29+ "allowScripts": {
30+ "better-sqlite3@12.9.0": true,
31+ "esbuild@0.27.7": true
2732 }
2833 }
scripts/index-meta.ts+44−0
@@ -0,0 +1,44 @@
1+/**
2+ * Build (or rebuild) the header search index.
3+ *
4+ * The importers only index message bodies (messages_fts). This adds
5+ * meta_fts, an external-content FTS5 index over subject + from_addr,
6+ * which powers subject:/from: search and makes header matches rank.
7+ *
8+ * Safe to re-run: the index is derived entirely from `messages`.
9+ *
10+ * npm run index:meta
11+ */
12+import Database from "better-sqlite3";
13+import path from "path";
14+
15+const DB_PATH = process.argv[2] ?? path.join(process.cwd(), "data", "usenet.db");
16+const db = new Database(DB_PATH);
17+db.pragma("journal_mode = WAL");
18+db.pragma("cache_size = -262144"); // 256 MiB
19+
20+const t0 = Date.now();
21+const log = (s: string) =>
22+ console.log(`[${((Date.now() - t0) / 1000).toFixed(1)}s] ${s}`);
23+
24+log(`db: ${DB_PATH}`);
25+db.exec(`
26+ CREATE VIRTUAL TABLE IF NOT EXISTS meta_fts USING fts5(
27+ subject,
28+ from_addr,
29+ content='messages',
30+ content_rowid='id'
31+ );
32+`);
33+
34+log("rebuilding meta_fts (subject, from_addr)...");
35+db.exec(`INSERT INTO meta_fts(meta_fts) VALUES('rebuild')`);
36+log("optimizing...");
37+db.exec(`INSERT INTO meta_fts(meta_fts) VALUES('optimize')`);
38+
39+log("refreshing planner statistics...");
40+db.exec(`ANALYZE newsgroups; ANALYZE messages;`);
41+
42+const n = (db.prepare(`SELECT COUNT(*) AS c FROM messages`).get() as { c: number }).c;
43+log(`done — ${n.toLocaleString()} messages indexed`);
44+db.close();