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
Unified · Split
| @@ -37,6 +37,9 @@ npm run import /path/to/usenet/archive · 36 unmodified lines | ||
| 37 | 37 | # Re-split concatenated blobs (if importing raw Google Groups data) |
| 38 | 38 | npx tsx scripts/resplit.ts |
| 39 | 39 | |
| 40 | +# Build the subject/author index (after any import; ~25s, safe to re-run) | |
| 41 | +npm run index:meta | |
| 42 | + | |
| 40 | 43 | # Run dev server |
| 41 | 44 | npm run dev |
| 42 | 45 | |
| @@ -44,6 +47,12 @@ npm run dev · 1 unmodified line | ||
| 44 | 47 | npm run build && npm start |
| 45 | 48 | ``` |
| 46 | 49 | |
| 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 | + | |
| 47 | 56 | ## Schema |
| 48 | 57 | |
| 49 | 58 | ```sql |
| @@ -66,6 +75,7 @@ CREATE TABLE messages ( · 16 unmodified lines | ||
| 66 | 75 | ); |
| 67 | 76 | |
| 68 | 77 | 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'); | |
| 69 | 79 | ``` |
| 70 | 80 | |
| 71 | 81 | ## License |
| @@ -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 | +} | |
| @@ -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 | + | |
| 1 | 23 | * { box-sizing: border-box; } |
| 24 | +html { -webkit-text-size-adjust: 100%; } | |
| 2 | 25 | 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; | |
| 8 | 31 | 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; | |
| 10 | 45 | } |
| 46 | +h1, h2 { font: inherit; margin: 0; } | |
| 47 | + | |
| 48 | +/* ---------- window chrome ---------- */ | |
| 11 | 49 | .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; | |
| 15 | 53 | margin: 0 auto; |
| 54 | + box-shadow: 0 1px 0 rgba(0, 0, 0, .08); | |
| 16 | 55 | } |
| 56 | +.p9-win.wide { max-width: 1180px; } | |
| 17 | 57 | .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; | |
| 22 | 61 | display: flex; |
| 23 | 62 | 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; | |
| 36 | 66 | } |
| 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; | |
| 40 | 74 | } |
| 41 | −.p9-tagbar .hdr-label { color: #555; } | |
| 75 | +.p9-win-title .meta { color: var(--dim); font-size: 12px; white-space: nowrap; } | |
| 76 | + | |
| 42 | 77 | .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; | |
| 48 | 86 | } |
| 49 | −.p9-bread a { color: #000; text-decoration: none; } | |
| 87 | +.p9-bread a { text-decoration: none; } | |
| 50 | 88 | .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 | +} | |
| 52 | 141 | .p9-table th { |
| 53 | 142 | 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; | |
| 59 | 148 | white-space: nowrap; |
| 60 | − background: #EAFFFF; | |
| 149 | + background: var(--tag); | |
| 150 | + position: sticky; | |
| 151 | + top: 0; | |
| 152 | + z-index: 1; | |
| 61 | 153 | } |
| 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; } | |
| 62 | 156 | .p9-table td { |
| 63 | − padding: 2px 8px; | |
| 64 | − border-bottom: 1px solid #deded8; | |
| 157 | + padding: 3px 10px; | |
| 158 | + border-bottom: 1px solid var(--hair); | |
| 65 | 159 | vertical-align: top; |
| 160 | + overflow: hidden; | |
| 161 | + text-overflow: ellipsis; | |
| 66 | 162 | } |
| 67 | 163 | .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; | |
| 81 | 185 | 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; | |
| 84 | 216 | } |
| 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 | + | |
| 87 | 224 | .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; | |
| 90 | 229 | white-space: pre-wrap; |
| 91 | − word-break: break-word; | |
| 92 | − line-height: 1.55; | |
| 230 | + overflow-wrap: anywhere; | |
| 93 | 231 | margin: 0; |
| 94 | − background: #FFFFF5; | |
| 232 | + background: var(--paper); | |
| 233 | + tab-size: 8; | |
| 95 | 234 | } |
| 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; | |
| 103 | 247 | display: flex; |
| 104 | − gap: 16px; | |
| 248 | + flex-wrap: wrap; | |
| 249 | + gap: 6px 16px; | |
| 105 | 250 | align-items: center; |
| 106 | 251 | } |
| 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; } | |
| 109 | 254 | .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); | |
| 117 | 268 | display: flex; |
| 118 | − gap: 8px; | |
| 119 | − align-items: center; | |
| 269 | + gap: 16px; | |
| 270 | + flex-wrap: wrap; | |
| 120 | 271 | } |
| 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; | |
| 129 | 287 | } |
| 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; | |
| 132 | 314 | 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; } | |
| 137 | 340 | } |
| 138 | −.p9-search button:hover { background: #EEEEE5; } | |
| 139 | −.highlight { background: #FFFFAA; } | |
| @@ -1,92 +1,151 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | import Link from "next/link"; |
| 2 | 3 | import { notFound } from "next/navigation"; |
| 3 | 4 | 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"; | |
| 4 | 18 | |
| 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; | |
| 27 | 20 | |
| 28 | −export default async function MessagePage({ | |
| 29 | − params, | |
| 30 | −}: { | |
| 21 | +interface Props { | |
| 31 | 22 | 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 | +} | |
| 36 | 24 | |
| 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); | |
| 37 | 30 | 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}` : ""); | |
| 39 | 66 | |
| 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); | |
| 41 | 71 | |
| 42 | 72 | 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> | |
| 65 | 118 | {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 | + </> | |
| 70 | 123 | )} |
| 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"> | |
| 74 | 131 | {prev !== null ? ( |
| 75 | − <Link href={`/group/${name}/${prev}`}>◂ prev</Link> | |
| 132 | + <Link href={messageHref(name, prev)} rel="prev"> | |
| 133 | + ◂ prev | |
| 134 | + </Link> | |
| 76 | 135 | ) : ( |
| 77 | − <span style={{ color: "#999" }}>◂ prev</span> | |
| 136 | + <span className="dim">◂ prev</span> | |
| 78 | 137 | )} |
| 79 | − <Link href={`/group/${name}`}>index</Link> | |
| 138 | + <Link href={indexHref} rel="up"> | |
| 139 | + index | |
| 140 | + </Link> | |
| 80 | 141 | {next !== null ? ( |
| 81 | − <Link href={`/group/${name}/${next}`} className="right"> | |
| 142 | + <Link href={messageHref(name, next)} rel="next" className="right"> | |
| 82 | 143 | next ▸ |
| 83 | 144 | </Link> |
| 84 | 145 | ) : ( |
| 85 | − <span className="right" style={{ color: "#999" }}> | |
| 86 | − next ▸ | |
| 87 | − </span> | |
| 146 | + <span className="right dim">next ▸</span> | |
| 88 | 147 | )} |
| 89 | − </div> | |
| 90 | − </div> | |
| 148 | + </nav> | |
| 149 | + </Window> | |
| 91 | 150 | ); |
| 92 | 151 | } |
| @@ -1,71 +1,127 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | import Link from "next/link"; |
| 2 | 3 | import { notFound } from "next/navigation"; |
| 3 | 4 | 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"; | |
| 4 | 17 | |
| 5 | −export default async function GroupPage({ | |
| 6 | − params, | |
| 7 | − searchParams, | |
| 8 | −}: { | |
| 18 | +const PER_PAGE = 50; | |
| 19 | + | |
| 20 | +interface Props { | |
| 9 | 21 | 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; | |
| 14 | 33 | const group = getNewsgroup(name); |
| 15 | 34 | if (!group) notFound(); |
| 16 | 35 | |
| 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); | |
| 21 | 51 | |
| 22 | 52 | 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} ·{" "} | |
| 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> | |
| 31 | 86 | </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> | |
| 52 | 98 | </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}`}>◂ prev</Link> | |
| 60 | − )} | |
| 61 | − <span> | |
| 62 | − page {page}/{totalPages} | |
| 63 | − </span> | |
| 64 | − {page < totalPages && ( | |
| 65 | − <Link href={`/group/${name}?page=${page + 1}`}>next ▸</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> | |
| 68 | 115 | )} |
| 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> | |
| 70 | 126 | ); |
| 71 | 127 | } |
| @@ -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's other posts and <b>thread</b> finds messages with the same subject. | |
| 74 | + </p> | |
| 75 | + </div> | |
| 76 | + </Window> | |
| 77 | + ); | |
| 78 | +} | |
| @@ -1,25 +1,65 @@ | ||
| 1 | −import type { Metadata } from "next"; | |
| 1 | +import type { Metadata, Viewport } from "next"; | |
| 2 | 2 | import { IBM_Plex_Mono } from "next/font/google"; |
| 3 | 3 | import "./globals.css"; |
| 4 | 4 | |
| 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", | |
| 8 | 11 | }); |
| 9 | 12 | |
| 10 | 13 | 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.", | |
| 13 | 16 | }; |
| 14 | 17 | |
| 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 }>) { | |
| 20 | 54 | 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> | |
| 23 | 63 | </html> |
| 24 | 64 | ); |
| 25 | 65 | } |
| @@ -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 | +} | |
| @@ -1,43 +1,244 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | 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"; | |
| 3 | 6 | |
| 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"; | |
| 7 | 9 | |
| 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 | + ); | |
| 8 | 78 | 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 · {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 = ( | |
| 19 | 167 | <table className="p9-table"> |
| 20 | 168 | <thead> |
| 21 | 169 | <tr> |
| 22 | − <th>newsgroup</th> | |
| 170 | + <th>hierarchy</th> | |
| 171 | + <th className="col-count hide-sm">groups</th> | |
| 23 | 172 | <th className="col-count">messages</th> |
| 24 | 173 | <th className="col-date">first</th> |
| 25 | 174 | <th className="col-date">last</th> |
| 26 | 175 | </tr> |
| 27 | 176 | </thead> |
| 28 | 177 | <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> | |
| 33 | 193 | </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> | |
| 37 | 196 | </tr> |
| 38 | 197 | ))} |
| 39 | 198 | </tbody> |
| 40 | 199 | </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, "phrases", 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> | |
| 42 | 243 | ); |
| 43 | 244 | } |
| @@ -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 | +} | |
| @@ -1,108 +1,233 @@ | ||
| 1 | +import type { Metadata } from "next"; | |
| 1 | 2 | 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"; | |
| 3 | 20 | |
| 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 | |
| 12 | 23 | |
| 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> | |
| 25 | 139 | <button type="submit">find</button> |
| 26 | 140 | </form> |
| 27 | − <div className="p9-empty">Enter a search query above.</div> | |
| 28 | 141 | </div> |
| 29 | − ); | |
| 30 | − } | |
| 31 | 142 | |
| 32 | − const { messages, total } = searchMessages(q, group, page, perPage); | |
| 33 | − const totalPages = Math.ceil(total / perPage); | |
| 143 | + {!q && <Examples />} | |
| 34 | 144 | |
| 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 "{q}".</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 | − ◂ 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> | |
| 93 | 160 | )} |
| 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 ▸ | |
| 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> | |
| 103 | 214 | )} |
| 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 | + </> | |
| 105 | 230 | )} |
| 106 | − </div> | |
| 231 | + </Window> | |
| 107 | 232 | ); |
| 108 | 233 | } |
| @@ -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 | + ◂ prev | |
| 39 | + </Link> | |
| 40 | + ) : ( | |
| 41 | + <span className="step dim">◂ 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 ▸ | |
| 63 | + </Link> | |
| 64 | + ) : ( | |
| 65 | + <span className="step dim">next ▸</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 | +} | |
| @@ -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>; | |
| @@ -1,18 +1,34 @@ | ||
| 1 | 1 | import Database from "better-sqlite3"; |
| 2 | 2 | import path from "path"; |
| 3 | +import type { ParsedQuery } from "./query"; | |
| 4 | +import { HL_OPEN } from "./format"; | |
| 3 | 5 | |
| 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"); | |
| 5 | 7 | |
| 6 | 8 | let _db: Database.Database | null = null; |
| 7 | 9 | |
| 8 | −function getDb(): Database.Database { | |
| 10 | +function db(): Database.Database { | |
| 9 | 11 | 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"); | |
| 12 | 17 | } |
| 13 | 18 | return _db; |
| 14 | 19 | } |
| 15 | 20 | |
| 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 | + | |
| 16 | 32 | export interface Newsgroup { |
| 17 | 33 | name: string; |
| 18 | 34 | message_count: number; |
| @@ -20,7 +36,7 @@ export interface Newsgroup { · 1 unmodified line | ||
| 20 | 36 | last_date: string | null; |
| 21 | 37 | } |
| 22 | 38 | |
| 23 | −export interface Message { | |
| 39 | +export interface MessageRow { | |
| 24 | 40 | id: number; |
| 25 | 41 | newsgroup: string; |
| 26 | 42 | original_id: number; |
| @@ -28,145 +44,323 @@ export interface Message { · 1 unmodified line | ||
| 28 | 44 | date: string; |
| 29 | 45 | subject: string; |
| 30 | 46 | message_id: string; |
| 47 | +} | |
| 48 | + | |
| 49 | +export interface Message extends MessageRow { | |
| 31 | 50 | body: string; |
| 32 | 51 | } |
| 33 | 52 | |
| 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). */ | |
| 34 | 59 | 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 | |
| 39 | 65 | 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; | |
| 42 | 70 | } |
| 43 | 71 | |
| 44 | 72 | 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; | |
| 49 | 104 | } |
| 50 | 105 | |
| 106 | +/* ---------------------------------------------------------------- messages */ | |
| 107 | + | |
| 108 | +const LIST_COLS = `id, newsgroup, original_id, from_addr, date, subject, message_id`; | |
| 109 | + | |
| 51 | 110 | export function getMessages( |
| 52 | 111 | newsgroup: string, |
| 53 | 112 | 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[]; | |
| 70 | 122 | return { messages, total }; |
| 71 | 123 | } |
| 72 | 124 | |
| 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; | |
| 83 | 130 | } |
| 84 | 131 | |
| 85 | 132 | export function getAdjacentIds( |
| 86 | 133 | newsgroup: string, |
| 87 | 134 | 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 }; | |
| 104 | 147 | return { |
| 105 | 148 | prev: prev?.original_id ?? null, |
| 106 | 149 | next: next?.original_id ?? null, |
| 150 | + position: before.c, | |
| 107 | 151 | }; |
| 108 | 152 | } |
| 109 | 153 | |
| 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 | + | |
| 110 | 209 | 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 | + }); | |
| 147 | 228 | } |
| 148 | 229 | |
| 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 | + } | |
| 172 | 366 | } |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | const nextConfig: NextConfig = { |
| 4 | 4 | serverExternalPackages: ["better-sqlite3"], |
| 5 | + devIndicators: false, | |
| 5 | 6 | }; |
| 6 | 7 | |
| 7 | 8 | export default nextConfig; |
| @@ -9,7 +9,8 @@ · 8 unmodified lines | ||
| 9 | 9 | "import": "tsx scripts/import.ts", |
| 10 | 10 | "import:mbox": "tsx scripts/import-mbox.ts", |
| 11 | 11 | "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" | |
| 13 | 14 | }, |
| 14 | 15 | "dependencies": { |
| 15 | 16 | "better-sqlite3": "^12.9.0", |
| @@ -24,5 +25,9 @@ · 8 unmodified lines | ||
| 24 | 25 | "@types/react-dom": "^19", |
| 25 | 26 | "tsx": "^4.21.0", |
| 26 | 27 | "typescript": "^5" |
| 28 | + }, | |
| 29 | + "allowScripts": { | |
| 30 | + "better-sqlite3@12.9.0": true, | |
| 31 | + "esbuild@0.27.7": true | |
| 27 | 32 | } |
| 28 | 33 | } |
| @@ -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(); | |