search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: split db module, extract dashboard to CF Pages, upgrade zat

- split db/mod.zig (527→110 lines) into activity, search, stats, write
- extract dashboard HTML/CSS/JS to site/ for Cloudflare Pages
- add /api/dashboard JSON endpoint, /dashboard redirects to CF Pages
- upgrade zat to 0.1.0 (tangled.sh), use CommitAction enum in tap.zig
- link stack section in README to bsky post

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz 4693f2ff e364d524

+962 -794
+1 -1
README.md
··· 33 33 34 34 search returns three entity types: `article` (document in a publication), `looseleaf` (standalone document), `publication` (newsletter itself). tag filtering applies to documents only. 35 35 36 - ## stack 36 + ## [stack](https://bsky.app/profile/zzstoatzz.io/post/3mbij5ip4ws2a) 37 37 38 38 - [Fly.io](https://fly.io) hosts backend + tap 39 39 - [Turso](https://turso.tech) cloud SQLite
+2 -2
backend/build.zig.zon
··· 13 13 .hash = "zql-0.0.1-alpha-xNRI4IRNAABUb9gLat5FWUaZDD5HvxAxet_-elgR_A_y", 14 14 }, 15 15 .zat = .{ 16 - .url = "https://github.com/zzstoatzz/zat/archive/main.tar.gz", 17 - .hash = "zat-0.0.2-5PuC7hJbAQAx5-PTup-GhBRIRAKqjbyBlIQq8YTEObTu", 16 + .url = "https://tangled.sh/zzstoatzz.io/zat/archive/main", 17 + .hash = "zat-0.1.0-5PuC7ntmAQA9_8rALQwWad2riXWTY9p_ohVOD54_Y-2c", 18 18 }, 19 19 }, 20 20 .paths = .{
+34 -287
backend/src/dashboard.zig
··· 129 129 return try output.toOwnedSlice(); 130 130 } 131 131 132 - /// Generate dashboard HTML with stats and charts 133 - pub fn render(alloc: Allocator, data: Data) ![]const u8 { 134 - var buf: std.ArrayList(u8) = .{}; 135 - const w = buf.writer(alloc); 132 + /// Generate dashboard data as JSON for API endpoint 133 + pub fn toJson(alloc: Allocator, data: Data) ![]const u8 { 134 + var output: std.Io.Writer.Allocating = .init(alloc); 135 + errdefer output.deinit(); 136 + 137 + var jw: json.Stringify = .{ .writer = &output.writer }; 138 + try jw.beginObject(); 139 + 140 + try jw.objectField("startedAt"); 141 + try jw.write(data.started_at); 142 + 143 + try jw.objectField("searches"); 144 + try jw.write(data.searches); 136 145 137 - try w.writeAll( 138 - \\<!DOCTYPE html> 139 - \\<html lang="en"> 140 - \\<head> 141 - \\ <meta charset="UTF-8"> 142 - \\ <meta name="viewport" content="width=device-width, initial-scale=1.0"> 143 - \\ <title>leaflet search / stats</title> 144 - \\ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='4' y='18' width='6' height='10' fill='%231B7340'/><rect x='13' y='12' width='6' height='16' fill='%231B7340'/><rect x='22' y='6' width='6' height='22' fill='%231B7340'/></svg>"> 145 - \\ <style> 146 - \\ * { box-sizing: border-box; margin: 0; padding: 0; } 147 - \\ body { 148 - \\ font-family: monospace; 149 - \\ background: #0a0a0a; 150 - \\ color: #ccc; 151 - \\ min-height: 100vh; 152 - \\ padding: 1rem; 153 - \\ font-size: 14px; 154 - \\ line-height: 1.6; 155 - \\ } 156 - \\ .container { max-width: 600px; margin: 0 auto; } 157 - \\ a { color: #1B7340; text-decoration: none; } 158 - \\ a:hover { color: #2a9d5c; } 159 - \\ h1 { 160 - \\ font-size: 12px; 161 - \\ font-weight: normal; 162 - \\ margin-bottom: 1.5rem; 163 - \\ } 164 - \\ h1 a.title { color: #888; } 165 - \\ h1 a.title:hover { color: #fff; } 166 - \\ h1 .dim { color: #555; } 167 - \\ section { margin-bottom: 2rem; } 168 - \\ .section-title { 169 - \\ font-size: 11px; 170 - \\ color: #555; 171 - \\ margin-bottom: 0.75rem; 172 - \\ } 173 - \\ .metrics { 174 - \\ display: flex; 175 - \\ gap: 1.5rem; 176 - \\ margin-bottom: 1rem; 177 - \\ } 178 - \\ .metric-value { 179 - \\ font-size: 16px; 180 - \\ color: #888; 181 - \\ font-weight: normal; 182 - \\ } 183 - \\ .metric-label { 184 - \\ font-size: 10px; 185 - \\ color: #444; 186 - \\ text-transform: uppercase; 187 - \\ letter-spacing: 0.5px; 188 - \\ } 189 - \\ .chart-box { 190 - \\ background: #111; 191 - \\ border: 1px solid #222; 192 - \\ padding: 1rem; 193 - \\ margin-bottom: 1rem; 194 - \\ } 195 - \\ .chart-header { 196 - \\ display: flex; 197 - \\ justify-content: space-between; 198 - \\ font-size: 11px; 199 - \\ color: #666; 200 - \\ margin-bottom: 0.75rem; 201 - \\ } 202 - \\ .timeline { 203 - \\ display: flex; 204 - \\ align-items: flex-end; 205 - \\ gap: 2px; 206 - \\ height: 60px; 207 - \\ } 208 - \\ .bar { 209 - \\ flex: 1; 210 - \\ background: #1B7340; 211 - \\ min-height: 2px; 212 - \\ } 213 - \\ .bar:hover { background: #2a9d5c; } 214 - \\ .doc-row { 215 - \\ display: flex; 216 - \\ justify-content: space-between; 217 - \\ font-size: 12px; 218 - \\ padding: 0.25rem 0; 219 - \\ border-bottom: 1px solid #1a1a1a; 220 - \\ } 221 - \\ .doc-row:last-child { border-bottom: none; } 222 - \\ .doc-type { color: #888; } 223 - \\ .doc-count { color: #ccc; } 224 - \\ .pub-row { 225 - \\ display: flex; 226 - \\ justify-content: space-between; 227 - \\ font-size: 12px; 228 - \\ padding: 0.25rem 0; 229 - \\ border-bottom: 1px solid #1a1a1a; 230 - \\ } 231 - \\ .pub-row:last-child { border-bottom: none; } 232 - \\ .pub-name { color: #888; } 233 - \\ .pub-count { color: #666; } 234 - \\ .tags { 235 - \\ display: flex; 236 - \\ flex-wrap: wrap; 237 - \\ gap: 0.5rem; 238 - \\ } 239 - \\ .tag { 240 - \\ font-size: 11px; 241 - \\ padding: 3px 8px; 242 - \\ background: #151515; 243 - \\ border: 1px solid #252525; 244 - \\ border-radius: 3px; 245 - \\ color: #777; 246 - \\ } 247 - \\ .tag:hover { 248 - \\ background: #1a1a1a; 249 - \\ border-color: #333; 250 - \\ color: #aaa; 251 - \\ } 252 - \\ .tag .n { color: #444; margin-left: 4px; } 253 - \\ .live { font-size: 11px; color: #555; } 254 - \\ .live span { color: #4ade80; } 255 - \\ footer { 256 - \\ margin-top: 2rem; 257 - \\ padding-top: 1rem; 258 - \\ border-top: 1px solid #222; 259 - \\ font-size: 11px; 260 - \\ color: #444; 261 - \\ } 262 - \\ footer a { color: #555; } 263 - \\ footer a:hover { color: #888; } 264 - \\ </style> 265 - \\</head> 266 - \\<body> 267 - \\ <div class="container"> 268 - \\ <h1><a href="https://leaflet-search.pages.dev" class="title">leaflet search</a> <span class="dim">/ stats</span></h1> 269 - \\ 270 - \\ <section> 271 - \\ <div class="metrics"> 272 - \\ <div> 273 - \\ <div class="metric-value" id="age">--</div> 274 - \\ <div class="metric-label">uptime</div> 275 - \\ </div> 276 - \\ <div> 277 - \\ <div class="metric-value"> 278 - ); 146 + try jw.objectField("publications"); 147 + try jw.write(data.publications); 279 148 280 - try w.print("{d}", .{data.searches}); 281 - try w.writeAll( 282 - \\</div> 283 - \\ <div class="metric-label">searches</div> 284 - \\ </div> 285 - \\ <div> 286 - \\ <div class="metric-value"> 287 - ); 149 + try jw.objectField("articles"); 150 + try jw.write(data.articles); 288 151 289 - try w.print("{d}", .{data.publications}); 290 - try w.writeAll( 291 - \\</div> 292 - \\ <div class="metric-label">publications</div> 293 - \\ </div> 294 - \\ </div> 295 - \\ <div class="live" id="live"></div> 296 - \\ </section> 297 - \\ 298 - \\ <section> 299 - \\ <div class="section-title">documents</div> 300 - \\ <div class="chart-box"> 301 - \\ <div class="doc-row"> 302 - \\ <span class="doc-type">articles</span> 303 - \\ <span class="doc-count"> 304 - ); 152 + try jw.objectField("looseleafs"); 153 + try jw.write(data.looseleafs); 305 154 306 - try w.print("{d}", .{data.articles}); 307 - try w.writeAll( 308 - \\</span> 309 - \\ </div> 310 - \\ <div class="doc-row"> 311 - \\ <span class="doc-type">looseleafs</span> 312 - \\ <span class="doc-count"> 313 - ); 155 + // use beginWriteRaw/endWriteRaw for pre-formatted JSON arrays 156 + try jw.objectField("tags"); 157 + try jw.beginWriteRaw(); 158 + try jw.writer.writeAll(data.tags_json); 159 + jw.endWriteRaw(); 314 160 315 - try w.print("{d}", .{data.looseleafs}); 316 - try w.writeAll( 317 - \\</span> 318 - \\ </div> 319 - \\ </div> 320 - \\ </section> 321 - \\ 322 - \\ <section> 323 - \\ <div class="section-title">activity (last 30 days)</div> 324 - \\ <div class="chart-box"> 325 - \\ <div class="timeline" id="timeline"></div> 326 - \\ </div> 327 - \\ </section> 328 - \\ 329 - \\ <section> 330 - \\ <div class="section-title">top publications</div> 331 - \\ <div class="chart-box"> 332 - \\ <div id="pubs"></div> 333 - \\ </div> 334 - \\ </section> 335 - \\ 336 - \\ <section> 337 - \\ <div class="section-title">tags</div> 338 - \\ <div class="tags" id="tags"></div> 339 - \\ </section> 340 - \\ 341 - \\ <footer> 342 - \\ <a href="https://leaflet-search.pages.dev">← back</a> · source on <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search">tangled</a> 343 - \\ </footer> 344 - \\ </div> 345 - \\ 346 - \\ <script> 347 - \\ const startedAt = 348 - ); 161 + try jw.objectField("timeline"); 162 + try jw.beginWriteRaw(); 163 + try jw.writer.writeAll(data.timeline_json); 164 + jw.endWriteRaw(); 349 165 350 - try w.print("{d}", .{data.started_at}); 351 - try w.writeAll(" * 1000;\n const tags = "); 352 - try w.writeAll(data.tags_json); 353 - try w.writeAll(";\n const timeline = "); 354 - try w.writeAll(data.timeline_json); 355 - try w.writeAll(";\n const pubs = "); 356 - try w.writeAll(data.top_pubs_json); 357 - try w.writeAll( 358 - \\; 359 - \\ 360 - \\ function formatAge(ms) { 361 - \\ const s = Math.floor(ms / 1000); 362 - \\ const d = Math.floor(s / 86400); 363 - \\ const h = Math.floor((s % 86400) / 3600); 364 - \\ const m = Math.floor((s % 3600) / 60); 365 - \\ const sec = s % 60; 366 - \\ if (d > 0) return d + 'd ' + h + 'h ' + m + 'm ' + sec + 's'; 367 - \\ if (h > 0) return h + 'h ' + m + 'm ' + sec + 's'; 368 - \\ return m + 'm ' + sec + 's'; 369 - \\ } 370 - \\ function updateAge() { 371 - \\ document.getElementById('age').textContent = formatAge(Date.now() - startedAt); 372 - \\ } 373 - \\ updateAge(); 374 - \\ setInterval(updateAge, 1000); 375 - \\ 376 - \\ // timeline 377 - \\ const timelineEl = document.getElementById('timeline'); 378 - \\ if (timeline.length > 0) { 379 - \\ const max = Math.max(...timeline.map(d => d.count)); 380 - \\ [...timeline].reverse().forEach(d => { 381 - \\ const h = max > 0 ? (d.count / max * 100) : 0; 382 - \\ const bar = document.createElement('div'); 383 - \\ bar.className = 'bar'; 384 - \\ bar.style.height = Math.max(h, 3) + '%'; 385 - \\ bar.title = d.date + ': ' + d.count; 386 - \\ timelineEl.appendChild(bar); 387 - \\ }); 388 - \\ } 389 - \\ 390 - \\ // publications 391 - \\ const pubsEl = document.getElementById('pubs'); 392 - \\ pubs.forEach(p => { 393 - \\ const row = document.createElement('div'); 394 - \\ row.className = 'pub-row'; 395 - \\ row.innerHTML = '<span class="pub-name">' + p.name + '</span><span class="pub-count">' + p.count + '</span>'; 396 - \\ pubsEl.appendChild(row); 397 - \\ }); 398 - \\ 399 - \\ // tags 400 - \\ document.getElementById('tags').innerHTML = tags.slice(0, 20).map(t => 401 - \\ '<a class="tag" href="https://leaflet-search.pages.dev/?tag=' + encodeURIComponent(t.tag) + '">' + 402 - \\ t.tag + '<span class="n">' + t.count + '</span></a>' 403 - \\ ).join(''); 404 - \\ 405 - \\ // live activity - just a number 406 - \\ const liveEl = document.getElementById('live'); 407 - \\ let lastN = -1; 408 - \\ async function pollLive() { 409 - \\ try { 410 - \\ const r = await fetch('/activity'); 411 - \\ const c = await r.json(); 412 - \\ const n = c.reduce((a,b) => a+b, 0); 413 - \\ if (n !== lastN) { 414 - \\ liveEl.innerHTML = n > 0 ? '<span>' + n + '</span> req/6s' : ''; 415 - \\ lastN = n; 416 - \\ } 417 - \\ } catch(e) {} 418 - \\ } 419 - \\ pollLive(); setInterval(pollLive, 1000); 420 - \\ </script> 421 - \\</body> 422 - \\</html> 423 - ); 166 + try jw.objectField("topPubs"); 167 + try jw.beginWriteRaw(); 168 + try jw.writer.writeAll(data.top_pubs_json); 169 + jw.endWriteRaw(); 424 170 425 - return buf.toOwnedSlice(alloc); 171 + try jw.endObject(); 172 + return try output.toOwnedSlice(); 426 173 }
+41
backend/src/db/activity.zig
··· 1 + const std = @import("std"); 2 + 3 + // ring buffer for real-time search activity 4 + pub const SLOTS = 60; 5 + const TICK_MS = 100; 6 + 7 + var counts: [SLOTS]u16 = .{0} ** SLOTS; 8 + var slot: usize = 0; 9 + var mutex: std.Thread.Mutex = .{}; 10 + var thread: ?std.Thread = null; 11 + 12 + fn tickLoop() void { 13 + while (true) { 14 + std.Thread.sleep(TICK_MS * std.time.ns_per_ms); 15 + mutex.lock(); 16 + slot = (slot + 1) % SLOTS; 17 + counts[slot] = 0; 18 + mutex.unlock(); 19 + } 20 + } 21 + 22 + pub fn init() void { 23 + thread = std.Thread.spawn(.{}, tickLoop, .{}) catch null; 24 + } 25 + 26 + pub fn getCounts() [SLOTS]u16 { 27 + mutex.lock(); 28 + defer mutex.unlock(); 29 + var result: [SLOTS]u16 = undefined; 30 + for (0..SLOTS) |i| { 31 + const idx = (slot + 1 + i) % SLOTS; 32 + result[i] = counts[idx]; 33 + } 34 + return result; 35 + } 36 + 37 + pub fn record() void { 38 + mutex.lock(); 39 + defer mutex.unlock(); 40 + counts[slot] +|= 1; 41 + }
+58 -475
backend/src/db/mod.zig
··· 1 1 const std = @import("std"); 2 - const json = std.json; 3 2 const Allocator = std.mem.Allocator; 4 3 5 - const zql = @import("zql"); 6 4 const Client = @import("Client.zig"); 7 5 const schema = @import("schema.zig"); 8 6 const result = @import("result.zig"); 9 7 10 - // activity tracking - ring buffer for real-time search activity 11 - const ACTIVITY_SLOTS = 60; 12 - const ACTIVITY_TICK_MS = 100; 13 - var activity_counts: [ACTIVITY_SLOTS]u16 = .{0} ** ACTIVITY_SLOTS; 14 - var activity_slot: usize = 0; 15 - var activity_mutex: std.Thread.Mutex = .{}; 16 - var activity_thread: ?std.Thread = null; 17 - 18 - fn activityTickLoop() void { 19 - while (true) { 20 - std.Thread.sleep(ACTIVITY_TICK_MS * std.time.ns_per_ms); 21 - activity_mutex.lock(); 22 - activity_slot = (activity_slot + 1) % ACTIVITY_SLOTS; 23 - activity_counts[activity_slot] = 0; 24 - activity_mutex.unlock(); 25 - } 26 - } 8 + // submodules 9 + pub const activity = @import("activity.zig"); 10 + const search_ = @import("search.zig"); 11 + const stats_ = @import("stats.zig"); 12 + const write_ = @import("write.zig"); 27 13 28 - pub fn initActivity() void { 29 - activity_thread = std.Thread.spawn(.{}, activityTickLoop, .{}) catch null; 30 - } 31 - 32 - pub fn getActivityCounts() [ACTIVITY_SLOTS]u16 { 33 - activity_mutex.lock(); 34 - defer activity_mutex.unlock(); 35 - var result_arr: [ACTIVITY_SLOTS]u16 = undefined; 36 - for (0..ACTIVITY_SLOTS) |i| { 37 - const idx = (activity_slot + 1 + i) % ACTIVITY_SLOTS; 38 - result_arr[i] = activity_counts[idx]; 39 - } 40 - return result_arr; 41 - } 42 - 43 - fn recordActivity() void { 44 - activity_mutex.lock(); 45 - defer activity_mutex.unlock(); 46 - activity_counts[activity_slot] +|= 1; 47 - } 48 - 14 + // re-exports 49 15 pub const Row = result.Row; 50 16 pub const BatchResult = result.BatchResult; 51 17 pub const Statement = Client.Statement; 18 + pub const Stats = stats_.Stats; 52 19 20 + // global state 53 21 var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; 54 22 var client: ?Client = null; 55 23 ··· 63 31 return null; 64 32 } 65 33 34 + // activity (direct re-export) 35 + pub const initActivity = activity.init; 36 + pub const getActivityCounts = activity.getCounts; 37 + 38 + // search 39 + pub fn search(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8) ![]const u8 { 40 + const c = &(client orelse return error.NotInitialized); 41 + return search_.search(c, alloc, query, tag_filter); 42 + } 43 + 44 + pub fn findSimilar(alloc: Allocator, uri: []const u8, limit: usize) ![]const u8 { 45 + const c = &(client orelse return error.NotInitialized); 46 + return search_.findSimilar(c, alloc, uri, limit); 47 + } 48 + 49 + // stats 50 + pub fn getTags(alloc: Allocator) ![]const u8 { 51 + const c = &(client orelse return error.NotInitialized); 52 + return stats_.getTags(c, alloc); 53 + } 54 + 55 + pub fn getStats() Stats { 56 + const c = &(client orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }); 57 + return stats_.getStats(c); 58 + } 59 + 60 + pub fn recordSearch(query: []const u8) void { 61 + const c = &(client orelse return); 62 + stats_.recordSearch(c, query); 63 + } 64 + 65 + pub fn recordError() void { 66 + const c = &(client orelse return); 67 + stats_.recordError(c); 68 + } 69 + 70 + pub fn getPopular(alloc: Allocator, limit: usize) ![]const u8 { 71 + const c = &(client orelse return error.NotInitialized); 72 + return stats_.getPopular(c, alloc, limit); 73 + } 74 + 75 + // write 66 76 pub fn insertDocument( 67 77 uri: []const u8, 68 78 did: []const u8, ··· 73 83 publication_uri: ?[]const u8, 74 84 tags: []const []const u8, 75 85 ) !void { 76 - var c = &(client orelse return error.NotInitialized); 77 - 78 - try c.exec( 79 - "INSERT OR REPLACE INTO documents (uri, did, rkey, title, content, created_at, publication_uri) VALUES (?, ?, ?, ?, ?, ?, ?)", 80 - &.{ uri, did, rkey, title, content, created_at orelse "", publication_uri orelse "" }, 81 - ); 82 - 83 - // update FTS index 84 - c.exec("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 85 - c.exec( 86 - "INSERT INTO documents_fts (uri, title, content) VALUES (?, ?, ?)", 87 - &.{ uri, title, content }, 88 - ) catch {}; 89 - 90 - // update tags 91 - c.exec("DELETE FROM document_tags WHERE document_uri = ?", &.{uri}) catch {}; 92 - for (tags) |tag| { 93 - c.exec( 94 - "INSERT OR IGNORE INTO document_tags (document_uri, tag) VALUES (?, ?)", 95 - &.{ uri, tag }, 96 - ) catch {}; 97 - } 86 + const c = &(client orelse return error.NotInitialized); 87 + return write_.insertDocument(c, uri, did, rkey, title, content, created_at, publication_uri, tags); 98 88 } 99 89 100 90 pub fn insertPublication( ··· 105 95 description: ?[]const u8, 106 96 base_path: ?[]const u8, 107 97 ) !void { 108 - var c = &(client orelse return error.NotInitialized); 109 - 110 - try c.exec( 111 - "INSERT OR REPLACE INTO publications (uri, did, rkey, name, description, base_path) VALUES (?, ?, ?, ?, ?, ?)", 112 - &.{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 113 - ); 114 - 115 - // update FTS index 116 - c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 117 - c.exec( 118 - "INSERT INTO publications_fts (uri, name, description) VALUES (?, ?, ?)", 119 - &.{ uri, name, description orelse "" }, 120 - ) catch {}; 98 + const c = &(client orelse return error.NotInitialized); 99 + return write_.insertPublication(c, uri, did, rkey, name, description, base_path); 121 100 } 122 101 123 102 pub fn deleteDocument(uri: []const u8) void { 124 - var c = &(client orelse return); 125 - // record tombstone 126 - var ts_buf: [20]u8 = undefined; 127 - const ts = std.fmt.bufPrint(&ts_buf, "{d}", .{std.time.timestamp()}) catch "0"; 128 - c.exec( 129 - "INSERT OR REPLACE INTO tombstones (uri, record_type, deleted_at) VALUES (?, 'document', ?)", 130 - &.{ uri, ts }, 131 - ) catch {}; 132 - // delete record 133 - c.exec("DELETE FROM documents WHERE uri = ?", &.{uri}) catch {}; 134 - c.exec("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 135 - c.exec("DELETE FROM document_tags WHERE document_uri = ?", &.{uri}) catch {}; 103 + const c = &(client orelse return); 104 + write_.deleteDocument(c, uri); 136 105 } 137 106 138 107 pub fn deletePublication(uri: []const u8) void { 139 - var c = &(client orelse return); 140 - // record tombstone 141 - var ts_buf: [20]u8 = undefined; 142 - const ts = std.fmt.bufPrint(&ts_buf, "{d}", .{std.time.timestamp()}) catch "0"; 143 - c.exec( 144 - "INSERT OR REPLACE INTO tombstones (uri, record_type, deleted_at) VALUES (?, 'publication', ?)", 145 - &.{ uri, ts }, 146 - ) catch {}; 147 - // delete record 148 - c.exec("DELETE FROM publications WHERE uri = ?", &.{uri}) catch {}; 149 - c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 150 - } 151 - 152 - // JSON output types for search results 153 - const SearchResultJson = struct { 154 - type: []const u8, 155 - uri: []const u8, 156 - did: []const u8, 157 - title: []const u8, 158 - snippet: []const u8, 159 - createdAt: []const u8 = "", 160 - rkey: []const u8, 161 - basePath: []const u8, 162 - }; 163 - 164 - const TagJson = struct { tag: []const u8, count: i64 }; 165 - const PopularJson = struct { query: []const u8, count: i64 }; 166 - 167 - /// Document search result (internal) 168 - const Doc = struct { 169 - uri: []const u8, 170 - did: []const u8, 171 - title: []const u8, 172 - snippet: []const u8, 173 - createdAt: []const u8, 174 - rkey: []const u8, 175 - basePath: []const u8, 176 - hasPublication: bool, 177 - 178 - fn fromRow(row: Row) Doc { 179 - return .{ 180 - .uri = row.text(0), 181 - .did = row.text(1), 182 - .title = row.text(2), 183 - .snippet = row.text(3), 184 - .createdAt = row.text(4), 185 - .rkey = row.text(5), 186 - .basePath = row.text(6), 187 - .hasPublication = row.int(7) != 0, 188 - }; 189 - } 190 - 191 - fn toJson(self: Doc) SearchResultJson { 192 - return .{ 193 - .type = if (self.hasPublication) "article" else "looseleaf", 194 - .uri = self.uri, 195 - .did = self.did, 196 - .title = self.title, 197 - .snippet = self.snippet, 198 - .createdAt = self.createdAt, 199 - .rkey = self.rkey, 200 - .basePath = self.basePath, 201 - }; 202 - } 203 - }; 204 - 205 - const DocsByTag = zql.Query( 206 - \\SELECT d.uri, d.did, d.title, '' as snippet, 207 - \\ d.created_at, d.rkey, p.base_path, 208 - \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 209 - \\FROM documents d 210 - \\LEFT JOIN publications p ON d.publication_uri = p.uri 211 - \\JOIN document_tags dt ON d.uri = dt.document_uri 212 - \\WHERE dt.tag = :tag 213 - \\ORDER BY d.created_at DESC LIMIT 40 214 - ); 215 - 216 - const DocsByFtsAndTag = zql.Query( 217 - \\SELECT f.uri, d.did, d.title, 218 - \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 219 - \\ d.created_at, d.rkey, p.base_path, 220 - \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 221 - \\FROM documents_fts f 222 - \\JOIN documents d ON f.uri = d.uri 223 - \\LEFT JOIN publications p ON d.publication_uri = p.uri 224 - \\JOIN document_tags dt ON d.uri = dt.document_uri 225 - \\WHERE documents_fts MATCH :query AND dt.tag = :tag 226 - \\ORDER BY rank LIMIT 40 227 - ); 228 - 229 - const DocsByFts = zql.Query( 230 - \\SELECT f.uri, d.did, d.title, 231 - \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 232 - \\ d.created_at, d.rkey, p.base_path, 233 - \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 234 - \\FROM documents_fts f 235 - \\JOIN documents d ON f.uri = d.uri 236 - \\LEFT JOIN publications p ON d.publication_uri = p.uri 237 - \\WHERE documents_fts MATCH :query 238 - \\ORDER BY rank LIMIT 40 239 - ); 240 - 241 - /// Publication search result (internal) 242 - const Pub = struct { 243 - uri: []const u8, 244 - did: []const u8, 245 - name: []const u8, 246 - snippet: []const u8, 247 - rkey: []const u8, 248 - basePath: []const u8, 249 - 250 - fn fromRow(row: Row) Pub { 251 - return .{ 252 - .uri = row.text(0), 253 - .did = row.text(1), 254 - .name = row.text(2), 255 - .snippet = row.text(3), 256 - .rkey = row.text(4), 257 - .basePath = row.text(5), 258 - }; 259 - } 260 - 261 - fn toJson(self: Pub) SearchResultJson { 262 - return .{ 263 - .type = "publication", 264 - .uri = self.uri, 265 - .did = self.did, 266 - .title = self.name, 267 - .snippet = self.snippet, 268 - .rkey = self.rkey, 269 - .basePath = self.basePath, 270 - }; 271 - } 272 - }; 273 - 274 - const PubSearch = zql.Query( 275 - \\SELECT f.uri, p.did, p.name, 276 - \\ snippet(publications_fts, 2, '', '', '...', 32) as snippet, 277 - \\ p.rkey, p.base_path 278 - \\FROM publications_fts f 279 - \\JOIN publications p ON f.uri = p.uri 280 - \\WHERE publications_fts MATCH :query 281 - \\ORDER BY rank LIMIT 10 282 - ); 283 - 284 - const TagsQuery = zql.Query( 285 - \\SELECT tag, COUNT(*) as count 286 - \\FROM document_tags 287 - \\GROUP BY tag 288 - \\ORDER BY count DESC 289 - \\LIMIT 100 290 - ); 291 - 292 - pub fn search(alloc: Allocator, query: []const u8, tag_filter: ?[]const u8) ![]const u8 { 293 - var c = &(client orelse return error.NotInitialized); 294 - 295 - var output: std.Io.Writer.Allocating = .init(alloc); 296 - errdefer output.deinit(); 297 - 298 - var jw: json.Stringify = .{ .writer = &output.writer }; 299 - try jw.beginArray(); 300 - 301 - const fts_query = try buildFtsQuery(alloc, query); 302 - 303 - // search documents 304 - var doc_result = if (query.len == 0 and tag_filter != null) 305 - c.query(DocsByTag.positional, DocsByTag.bind(.{ .tag = tag_filter.? })) catch null 306 - else if (tag_filter) |tag| 307 - c.query(DocsByFtsAndTag.positional, DocsByFtsAndTag.bind(.{ .query = fts_query, .tag = tag })) catch null 308 - else 309 - c.query(DocsByFts.positional, DocsByFts.bind(.{ .query = fts_query })) catch null; 310 - 311 - if (doc_result) |*res| { 312 - defer res.deinit(); 313 - for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 314 - } 315 - 316 - // publications are excluded when filtering by tag (tags only apply to documents) 317 - if (tag_filter == null) { 318 - var pub_result = c.query( 319 - PubSearch.positional, 320 - PubSearch.bind(.{ .query = fts_query }), 321 - ) catch null; 322 - 323 - if (pub_result) |*res| { 324 - defer res.deinit(); 325 - for (res.rows) |row| try jw.write(Pub.fromRow(row).toJson()); 326 - } 327 - } 328 - 329 - try jw.endArray(); 330 - return try output.toOwnedSlice(); 331 - } 332 - 333 - pub fn getTags(alloc: Allocator) ![]const u8 { 334 - var c = &(client orelse return error.NotInitialized); 335 - 336 - var output: std.Io.Writer.Allocating = .init(alloc); 337 - errdefer output.deinit(); 338 - 339 - var res = c.query(TagsQuery.positional, &.{}) catch { 340 - try output.writer.writeAll("{\"error\":\"failed to fetch tags\"}"); 341 - return try output.toOwnedSlice(); 342 - }; 343 - defer res.deinit(); 344 - 345 - var jw: json.Stringify = .{ .writer = &output.writer }; 346 - try jw.beginArray(); 347 - for (res.rows) |row| try jw.write(TagJson{ .tag = row.text(0), .count = row.int(1) }); 348 - try jw.endArray(); 349 - return try output.toOwnedSlice(); 350 - } 351 - 352 - pub fn getStats() struct { documents: i64, publications: i64, searches: i64, errors: i64, started_at: i64 } { 353 - var c = &(client orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }); 354 - 355 - var res = c.query( 356 - \\SELECT 357 - \\ (SELECT COUNT(*) FROM documents) as docs, 358 - \\ (SELECT COUNT(*) FROM publications) as pubs, 359 - \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 360 - \\ (SELECT total_errors FROM stats WHERE id = 1) as errors, 361 - \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at 362 - , &.{}) catch return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }; 363 - defer res.deinit(); 364 - 365 - const row = res.first() orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }; 366 - return .{ .documents = row.int(0), .publications = row.int(1), .searches = row.int(2), .errors = row.int(3), .started_at = row.int(4) }; 367 - } 368 - 369 - pub fn recordSearch(query: []const u8) void { 370 - recordActivity(); 371 - var c = &(client orelse return); 372 - c.exec("UPDATE stats SET total_searches = total_searches + 1 WHERE id = 1", &.{}) catch {}; 373 - 374 - // track popular searches (skip empty/very short queries) 375 - if (query.len >= 2) { 376 - c.exec( 377 - "INSERT INTO popular_searches (query, count) VALUES (?, 1) ON CONFLICT(query) DO UPDATE SET count = count + 1", 378 - &.{query}, 379 - ) catch {}; 380 - } 381 - } 382 - 383 - pub fn recordError() void { 384 - var c = &(client orelse return); 385 - c.exec("UPDATE stats SET total_errors = total_errors + 1 WHERE id = 1", &.{}) catch {}; 386 - } 387 - 388 - pub fn getPopular(alloc: Allocator, limit: usize) ![]const u8 { 389 - var c = &(client orelse return error.NotInitialized); 390 - 391 - var output: std.Io.Writer.Allocating = .init(alloc); 392 - errdefer output.deinit(); 393 - 394 - var buf: [8]u8 = undefined; 395 - const limit_str = std.fmt.bufPrint(&buf, "{d}", .{limit}) catch "3"; 396 - 397 - var res = c.query( 398 - "SELECT query, count FROM popular_searches ORDER BY count DESC LIMIT ?", 399 - &.{limit_str}, 400 - ) catch { 401 - try output.writer.writeAll("[]"); 402 - return try output.toOwnedSlice(); 403 - }; 404 - defer res.deinit(); 405 - 406 - var jw: json.Stringify = .{ .writer = &output.writer }; 407 - try jw.beginArray(); 408 - for (res.rows) |row| try jw.write(PopularJson{ .query = row.text(0), .count = row.int(1) }); 409 - try jw.endArray(); 410 - return try output.toOwnedSlice(); 411 - } 412 - 413 - /// Build FTS5 query with OR between terms: "cat dog" -> "cat OR dog*" 414 - /// Uses OR for better recall with BM25 ranking (more matches = higher score) 415 - /// Quoted queries are passed through as phrase matches: "exact phrase" -> "exact phrase" 416 - fn buildFtsQuery(alloc: Allocator, query: []const u8) ![]const u8 { 417 - if (query.len == 0) return ""; 418 - 419 - // normalize: trim whitespace 420 - var start: usize = 0; 421 - var end: usize = query.len; 422 - while (start < end and query[start] == ' ') start += 1; 423 - while (end > start and query[end - 1] == ' ') end -= 1; 424 - if (start >= end) return ""; 425 - 426 - const trimmed = query[start..end]; 427 - 428 - // quoted phrase: pass through to FTS5 for exact phrase matching 429 - if (trimmed.len >= 2 and trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') { 430 - return try alloc.dupe(u8, trimmed); 431 - } 432 - 433 - // count words and total length 434 - var word_count: usize = 0; 435 - var total_word_len: usize = 0; 436 - var in_word = false; 437 - for (trimmed) |c| { 438 - const is_sep = (c == ' ' or c == '.'); 439 - if (is_sep) { 440 - in_word = false; 441 - } else { 442 - if (!in_word) word_count += 1; 443 - in_word = true; 444 - total_word_len += 1; 445 - } 446 - } 447 - 448 - if (word_count == 0) return ""; 449 - 450 - // single word: just add prefix wildcard 451 - if (word_count == 1) { 452 - const buf = try alloc.alloc(u8, total_word_len + 1); 453 - var pos: usize = 0; 454 - for (trimmed) |c| { 455 - if (c != ' ' and c != '.') { 456 - buf[pos] = c; 457 - pos += 1; 458 - } 459 - } 460 - buf[pos] = '*'; 461 - return buf; 462 - } 463 - 464 - // multiple words: join with " OR ", prefix on last 465 - // size = word chars + (n-1) * 4 for " OR " + 1 for "*" 466 - const buf_len = total_word_len + (word_count - 1) * 4 + 1; 467 - const buf = try alloc.alloc(u8, buf_len); 468 - 469 - var pos: usize = 0; 470 - var current_word: usize = 0; 471 - in_word = false; 472 - 473 - for (trimmed) |c| { 474 - const is_sep = (c == ' ' or c == '.'); 475 - if (is_sep) { 476 - if (in_word) { 477 - // end of word - add " OR " if not last 478 - current_word += 1; 479 - if (current_word < word_count) { 480 - @memcpy(buf[pos .. pos + 4], " OR "); 481 - pos += 4; 482 - } 483 - } 484 - in_word = false; 485 - } else { 486 - buf[pos] = c; 487 - pos += 1; 488 - in_word = true; 489 - } 490 - } 491 - buf[pos] = '*'; 492 - return buf; 493 - } 494 - 495 - /// Find documents similar to a given document using vector similarity 496 - pub fn findSimilar(alloc: Allocator, uri: []const u8, limit: usize) ![]const u8 { 497 - var c = &(client orelse return error.NotInitialized); 498 - 499 - var output: std.Io.Writer.Allocating = .init(alloc); 500 - errdefer output.deinit(); 501 - 502 - var limit_buf: [8]u8 = undefined; 503 - const limit_str = std.fmt.bufPrint(&limit_buf, "{d}", .{limit + 1}) catch "6"; // +1 to exclude self 504 - 505 - // vector similarity search using the document's embedding 506 - // note: CAST required because Hrana sends all values as text 507 - var res = c.query( 508 - \\SELECT d.uri, d.did, d.title, '' as snippet, 509 - \\ d.created_at, d.rkey, COALESCE(p.base_path, '') as base_path, 510 - \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 511 - \\FROM vector_top_k('documents_embedding_idx', 512 - \\ (SELECT embedding FROM documents WHERE uri = ?), CAST(? AS INTEGER)) AS v 513 - \\JOIN documents d ON d.rowid = v.id 514 - \\LEFT JOIN publications p ON d.publication_uri = p.uri 515 - \\WHERE d.uri != ? 516 - , &.{ uri, limit_str, uri }) catch { 517 - try output.writer.writeAll("[]"); 518 - return try output.toOwnedSlice(); 519 - }; 520 - defer res.deinit(); 521 - 522 - var jw: json.Stringify = .{ .writer = &output.writer }; 523 - try jw.beginArray(); 524 - for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 525 - try jw.endArray(); 526 - return try output.toOwnedSlice(); 108 + const c = &(client orelse return); 109 + write_.deletePublication(c, uri); 527 110 }
+289
backend/src/db/search.zig
··· 1 + const std = @import("std"); 2 + const json = std.json; 3 + const Allocator = std.mem.Allocator; 4 + const zql = @import("zql"); 5 + const Client = @import("Client.zig"); 6 + const result = @import("result.zig"); 7 + const Row = result.Row; 8 + 9 + // JSON output type for search results 10 + const SearchResultJson = struct { 11 + type: []const u8, 12 + uri: []const u8, 13 + did: []const u8, 14 + title: []const u8, 15 + snippet: []const u8, 16 + createdAt: []const u8 = "", 17 + rkey: []const u8, 18 + basePath: []const u8, 19 + }; 20 + 21 + /// Document search result (internal) 22 + const Doc = struct { 23 + uri: []const u8, 24 + did: []const u8, 25 + title: []const u8, 26 + snippet: []const u8, 27 + createdAt: []const u8, 28 + rkey: []const u8, 29 + basePath: []const u8, 30 + hasPublication: bool, 31 + 32 + fn fromRow(row: Row) Doc { 33 + return .{ 34 + .uri = row.text(0), 35 + .did = row.text(1), 36 + .title = row.text(2), 37 + .snippet = row.text(3), 38 + .createdAt = row.text(4), 39 + .rkey = row.text(5), 40 + .basePath = row.text(6), 41 + .hasPublication = row.int(7) != 0, 42 + }; 43 + } 44 + 45 + fn toJson(self: Doc) SearchResultJson { 46 + return .{ 47 + .type = if (self.hasPublication) "article" else "looseleaf", 48 + .uri = self.uri, 49 + .did = self.did, 50 + .title = self.title, 51 + .snippet = self.snippet, 52 + .createdAt = self.createdAt, 53 + .rkey = self.rkey, 54 + .basePath = self.basePath, 55 + }; 56 + } 57 + }; 58 + 59 + const DocsByTag = zql.Query( 60 + \\SELECT d.uri, d.did, d.title, '' as snippet, 61 + \\ d.created_at, d.rkey, p.base_path, 62 + \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 63 + \\FROM documents d 64 + \\LEFT JOIN publications p ON d.publication_uri = p.uri 65 + \\JOIN document_tags dt ON d.uri = dt.document_uri 66 + \\WHERE dt.tag = :tag 67 + \\ORDER BY d.created_at DESC LIMIT 40 68 + ); 69 + 70 + const DocsByFtsAndTag = zql.Query( 71 + \\SELECT f.uri, d.did, d.title, 72 + \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 73 + \\ d.created_at, d.rkey, p.base_path, 74 + \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 75 + \\FROM documents_fts f 76 + \\JOIN documents d ON f.uri = d.uri 77 + \\LEFT JOIN publications p ON d.publication_uri = p.uri 78 + \\JOIN document_tags dt ON d.uri = dt.document_uri 79 + \\WHERE documents_fts MATCH :query AND dt.tag = :tag 80 + \\ORDER BY rank LIMIT 40 81 + ); 82 + 83 + const DocsByFts = zql.Query( 84 + \\SELECT f.uri, d.did, d.title, 85 + \\ snippet(documents_fts, 2, '', '', '...', 32) as snippet, 86 + \\ d.created_at, d.rkey, p.base_path, 87 + \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 88 + \\FROM documents_fts f 89 + \\JOIN documents d ON f.uri = d.uri 90 + \\LEFT JOIN publications p ON d.publication_uri = p.uri 91 + \\WHERE documents_fts MATCH :query 92 + \\ORDER BY rank LIMIT 40 93 + ); 94 + 95 + /// Publication search result (internal) 96 + const Pub = struct { 97 + uri: []const u8, 98 + did: []const u8, 99 + name: []const u8, 100 + snippet: []const u8, 101 + rkey: []const u8, 102 + basePath: []const u8, 103 + 104 + fn fromRow(row: Row) Pub { 105 + return .{ 106 + .uri = row.text(0), 107 + .did = row.text(1), 108 + .name = row.text(2), 109 + .snippet = row.text(3), 110 + .rkey = row.text(4), 111 + .basePath = row.text(5), 112 + }; 113 + } 114 + 115 + fn toJson(self: Pub) SearchResultJson { 116 + return .{ 117 + .type = "publication", 118 + .uri = self.uri, 119 + .did = self.did, 120 + .title = self.name, 121 + .snippet = self.snippet, 122 + .rkey = self.rkey, 123 + .basePath = self.basePath, 124 + }; 125 + } 126 + }; 127 + 128 + const PubSearch = zql.Query( 129 + \\SELECT f.uri, p.did, p.name, 130 + \\ snippet(publications_fts, 2, '', '', '...', 32) as snippet, 131 + \\ p.rkey, p.base_path 132 + \\FROM publications_fts f 133 + \\JOIN publications p ON f.uri = p.uri 134 + \\WHERE publications_fts MATCH :query 135 + \\ORDER BY rank LIMIT 10 136 + ); 137 + 138 + pub fn search(c: *Client, alloc: Allocator, query: []const u8, tag_filter: ?[]const u8) ![]const u8 { 139 + var output: std.Io.Writer.Allocating = .init(alloc); 140 + errdefer output.deinit(); 141 + 142 + var jw: json.Stringify = .{ .writer = &output.writer }; 143 + try jw.beginArray(); 144 + 145 + const fts_query = try buildFtsQuery(alloc, query); 146 + 147 + // search documents 148 + var doc_result = if (query.len == 0 and tag_filter != null) 149 + c.query(DocsByTag.positional, DocsByTag.bind(.{ .tag = tag_filter.? })) catch null 150 + else if (tag_filter) |tag| 151 + c.query(DocsByFtsAndTag.positional, DocsByFtsAndTag.bind(.{ .query = fts_query, .tag = tag })) catch null 152 + else 153 + c.query(DocsByFts.positional, DocsByFts.bind(.{ .query = fts_query })) catch null; 154 + 155 + if (doc_result) |*res| { 156 + defer res.deinit(); 157 + for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 158 + } 159 + 160 + // publications are excluded when filtering by tag (tags only apply to documents) 161 + if (tag_filter == null) { 162 + var pub_result = c.query( 163 + PubSearch.positional, 164 + PubSearch.bind(.{ .query = fts_query }), 165 + ) catch null; 166 + 167 + if (pub_result) |*res| { 168 + defer res.deinit(); 169 + for (res.rows) |row| try jw.write(Pub.fromRow(row).toJson()); 170 + } 171 + } 172 + 173 + try jw.endArray(); 174 + return try output.toOwnedSlice(); 175 + } 176 + 177 + /// Find documents similar to a given document using vector similarity 178 + pub fn findSimilar(c: *Client, alloc: Allocator, uri: []const u8, limit: usize) ![]const u8 { 179 + var output: std.Io.Writer.Allocating = .init(alloc); 180 + errdefer output.deinit(); 181 + 182 + var limit_buf: [8]u8 = undefined; 183 + const limit_str = std.fmt.bufPrint(&limit_buf, "{d}", .{limit + 1}) catch "6"; // +1 to exclude self 184 + 185 + // vector similarity search using the document's embedding 186 + // note: CAST required because Hrana sends all values as text 187 + var res = c.query( 188 + \\SELECT d.uri, d.did, d.title, '' as snippet, 189 + \\ d.created_at, d.rkey, COALESCE(p.base_path, '') as base_path, 190 + \\ CASE WHEN d.publication_uri != '' THEN 1 ELSE 0 END as has_publication 191 + \\FROM vector_top_k('documents_embedding_idx', 192 + \\ (SELECT embedding FROM documents WHERE uri = ?), CAST(? AS INTEGER)) AS v 193 + \\JOIN documents d ON d.rowid = v.id 194 + \\LEFT JOIN publications p ON d.publication_uri = p.uri 195 + \\WHERE d.uri != ? 196 + , &.{ uri, limit_str, uri }) catch { 197 + try output.writer.writeAll("[]"); 198 + return try output.toOwnedSlice(); 199 + }; 200 + defer res.deinit(); 201 + 202 + var jw: json.Stringify = .{ .writer = &output.writer }; 203 + try jw.beginArray(); 204 + for (res.rows) |row| try jw.write(Doc.fromRow(row).toJson()); 205 + try jw.endArray(); 206 + return try output.toOwnedSlice(); 207 + } 208 + 209 + /// Build FTS5 query with OR between terms: "cat dog" -> "cat OR dog*" 210 + /// Uses OR for better recall with BM25 ranking (more matches = higher score) 211 + /// Quoted queries are passed through as phrase matches: "exact phrase" -> "exact phrase" 212 + pub fn buildFtsQuery(alloc: Allocator, query: []const u8) ![]const u8 { 213 + if (query.len == 0) return ""; 214 + 215 + // normalize: trim whitespace 216 + var start: usize = 0; 217 + var end: usize = query.len; 218 + while (start < end and query[start] == ' ') start += 1; 219 + while (end > start and query[end - 1] == ' ') end -= 1; 220 + if (start >= end) return ""; 221 + 222 + const trimmed = query[start..end]; 223 + 224 + // quoted phrase: pass through to FTS5 for exact phrase matching 225 + if (trimmed.len >= 2 and trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') { 226 + return try alloc.dupe(u8, trimmed); 227 + } 228 + 229 + // count words and total length 230 + var word_count: usize = 0; 231 + var total_word_len: usize = 0; 232 + var in_word = false; 233 + for (trimmed) |c| { 234 + const is_sep = (c == ' ' or c == '.'); 235 + if (is_sep) { 236 + in_word = false; 237 + } else { 238 + if (!in_word) word_count += 1; 239 + in_word = true; 240 + total_word_len += 1; 241 + } 242 + } 243 + 244 + if (word_count == 0) return ""; 245 + 246 + // single word: just add prefix wildcard 247 + if (word_count == 1) { 248 + const buf = try alloc.alloc(u8, total_word_len + 1); 249 + var pos: usize = 0; 250 + for (trimmed) |c| { 251 + if (c != ' ' and c != '.') { 252 + buf[pos] = c; 253 + pos += 1; 254 + } 255 + } 256 + buf[pos] = '*'; 257 + return buf; 258 + } 259 + 260 + // multiple words: join with " OR ", prefix on last 261 + // size = word chars + (n-1) * 4 for " OR " + 1 for "*" 262 + const buf_len = total_word_len + (word_count - 1) * 4 + 1; 263 + const buf = try alloc.alloc(u8, buf_len); 264 + 265 + var pos: usize = 0; 266 + var current_word: usize = 0; 267 + in_word = false; 268 + 269 + for (trimmed) |c| { 270 + const is_sep = (c == ' ' or c == '.'); 271 + if (is_sep) { 272 + if (in_word) { 273 + // end of word - add " OR " if not last 274 + current_word += 1; 275 + if (current_word < word_count) { 276 + @memcpy(buf[pos .. pos + 4], " OR "); 277 + pos += 4; 278 + } 279 + } 280 + in_word = false; 281 + } else { 282 + buf[pos] = c; 283 + pos += 1; 284 + in_word = true; 285 + } 286 + } 287 + buf[pos] = '*'; 288 + return buf; 289 + }
+103
backend/src/db/stats.zig
··· 1 + const std = @import("std"); 2 + const json = std.json; 3 + const Allocator = std.mem.Allocator; 4 + const zql = @import("zql"); 5 + const Client = @import("Client.zig"); 6 + const activity = @import("activity.zig"); 7 + 8 + const TagJson = struct { tag: []const u8, count: i64 }; 9 + const PopularJson = struct { query: []const u8, count: i64 }; 10 + 11 + const TagsQuery = zql.Query( 12 + \\SELECT tag, COUNT(*) as count 13 + \\FROM document_tags 14 + \\GROUP BY tag 15 + \\ORDER BY count DESC 16 + \\LIMIT 100 17 + ); 18 + 19 + pub fn getTags(c: *Client, alloc: Allocator) ![]const u8 { 20 + var output: std.Io.Writer.Allocating = .init(alloc); 21 + errdefer output.deinit(); 22 + 23 + var res = c.query(TagsQuery.positional, &.{}) catch { 24 + try output.writer.writeAll("{\"error\":\"failed to fetch tags\"}"); 25 + return try output.toOwnedSlice(); 26 + }; 27 + defer res.deinit(); 28 + 29 + var jw: json.Stringify = .{ .writer = &output.writer }; 30 + try jw.beginArray(); 31 + for (res.rows) |row| try jw.write(TagJson{ .tag = row.text(0), .count = row.int(1) }); 32 + try jw.endArray(); 33 + return try output.toOwnedSlice(); 34 + } 35 + 36 + pub const Stats = struct { 37 + documents: i64, 38 + publications: i64, 39 + searches: i64, 40 + errors: i64, 41 + started_at: i64, 42 + }; 43 + 44 + pub fn getStats(c: *Client) Stats { 45 + var res = c.query( 46 + \\SELECT 47 + \\ (SELECT COUNT(*) FROM documents) as docs, 48 + \\ (SELECT COUNT(*) FROM publications) as pubs, 49 + \\ (SELECT total_searches FROM stats WHERE id = 1) as searches, 50 + \\ (SELECT total_errors FROM stats WHERE id = 1) as errors, 51 + \\ (SELECT service_started_at FROM stats WHERE id = 1) as started_at 52 + , &.{}) catch return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }; 53 + defer res.deinit(); 54 + 55 + const row = res.first() orelse return .{ .documents = 0, .publications = 0, .searches = 0, .errors = 0, .started_at = 0 }; 56 + return .{ 57 + .documents = row.int(0), 58 + .publications = row.int(1), 59 + .searches = row.int(2), 60 + .errors = row.int(3), 61 + .started_at = row.int(4), 62 + }; 63 + } 64 + 65 + pub fn recordSearch(c: *Client, query: []const u8) void { 66 + activity.record(); 67 + c.exec("UPDATE stats SET total_searches = total_searches + 1 WHERE id = 1", &.{}) catch {}; 68 + 69 + // track popular searches (skip empty/very short queries) 70 + if (query.len >= 2) { 71 + c.exec( 72 + "INSERT INTO popular_searches (query, count) VALUES (?, 1) ON CONFLICT(query) DO UPDATE SET count = count + 1", 73 + &.{query}, 74 + ) catch {}; 75 + } 76 + } 77 + 78 + pub fn recordError(c: *Client) void { 79 + c.exec("UPDATE stats SET total_errors = total_errors + 1 WHERE id = 1", &.{}) catch {}; 80 + } 81 + 82 + pub fn getPopular(c: *Client, alloc: Allocator, limit: usize) ![]const u8 { 83 + var output: std.Io.Writer.Allocating = .init(alloc); 84 + errdefer output.deinit(); 85 + 86 + var buf: [8]u8 = undefined; 87 + const limit_str = std.fmt.bufPrint(&buf, "{d}", .{limit}) catch "3"; 88 + 89 + var res = c.query( 90 + "SELECT query, count FROM popular_searches ORDER BY count DESC LIMIT ?", 91 + &.{limit_str}, 92 + ) catch { 93 + try output.writer.writeAll("[]"); 94 + return try output.toOwnedSlice(); 95 + }; 96 + defer res.deinit(); 97 + 98 + var jw: json.Stringify = .{ .writer = &output.writer }; 99 + try jw.beginArray(); 100 + for (res.rows) |row| try jw.write(PopularJson{ .query = row.text(0), .count = row.int(1) }); 101 + try jw.endArray(); 102 + return try output.toOwnedSlice(); 103 + }
+84
backend/src/db/write.zig
··· 1 + const std = @import("std"); 2 + const Client = @import("Client.zig"); 3 + 4 + pub fn insertDocument( 5 + c: *Client, 6 + uri: []const u8, 7 + did: []const u8, 8 + rkey: []const u8, 9 + title: []const u8, 10 + content: []const u8, 11 + created_at: ?[]const u8, 12 + publication_uri: ?[]const u8, 13 + tags: []const []const u8, 14 + ) !void { 15 + try c.exec( 16 + "INSERT OR REPLACE INTO documents (uri, did, rkey, title, content, created_at, publication_uri) VALUES (?, ?, ?, ?, ?, ?, ?)", 17 + &.{ uri, did, rkey, title, content, created_at orelse "", publication_uri orelse "" }, 18 + ); 19 + 20 + // update FTS index 21 + c.exec("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 22 + c.exec( 23 + "INSERT INTO documents_fts (uri, title, content) VALUES (?, ?, ?)", 24 + &.{ uri, title, content }, 25 + ) catch {}; 26 + 27 + // update tags 28 + c.exec("DELETE FROM document_tags WHERE document_uri = ?", &.{uri}) catch {}; 29 + for (tags) |tag| { 30 + c.exec( 31 + "INSERT OR IGNORE INTO document_tags (document_uri, tag) VALUES (?, ?)", 32 + &.{ uri, tag }, 33 + ) catch {}; 34 + } 35 + } 36 + 37 + pub fn insertPublication( 38 + c: *Client, 39 + uri: []const u8, 40 + did: []const u8, 41 + rkey: []const u8, 42 + name: []const u8, 43 + description: ?[]const u8, 44 + base_path: ?[]const u8, 45 + ) !void { 46 + try c.exec( 47 + "INSERT OR REPLACE INTO publications (uri, did, rkey, name, description, base_path) VALUES (?, ?, ?, ?, ?, ?)", 48 + &.{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 49 + ); 50 + 51 + // update FTS index 52 + c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 53 + c.exec( 54 + "INSERT INTO publications_fts (uri, name, description) VALUES (?, ?, ?)", 55 + &.{ uri, name, description orelse "" }, 56 + ) catch {}; 57 + } 58 + 59 + pub fn deleteDocument(c: *Client, uri: []const u8) void { 60 + // record tombstone 61 + var ts_buf: [20]u8 = undefined; 62 + const ts = std.fmt.bufPrint(&ts_buf, "{d}", .{std.time.timestamp()}) catch "0"; 63 + c.exec( 64 + "INSERT OR REPLACE INTO tombstones (uri, record_type, deleted_at) VALUES (?, 'document', ?)", 65 + &.{ uri, ts }, 66 + ) catch {}; 67 + // delete record 68 + c.exec("DELETE FROM documents WHERE uri = ?", &.{uri}) catch {}; 69 + c.exec("DELETE FROM documents_fts WHERE uri = ?", &.{uri}) catch {}; 70 + c.exec("DELETE FROM document_tags WHERE document_uri = ?", &.{uri}) catch {}; 71 + } 72 + 73 + pub fn deletePublication(c: *Client, uri: []const u8) void { 74 + // record tombstone 75 + var ts_buf: [20]u8 = undefined; 76 + const ts = std.fmt.bufPrint(&ts_buf, "{d}", .{std.time.timestamp()}) catch "0"; 77 + c.exec( 78 + "INSERT OR REPLACE INTO tombstones (uri, record_type, deleted_at) VALUES (?, 'publication', ?)", 79 + &.{ uri, ts }, 80 + ) catch {}; 81 + // delete record 82 + c.exec("DELETE FROM publications WHERE uri = ?", &.{uri}) catch {}; 83 + c.exec("DELETE FROM publications_fts WHERE uri = ?", &.{uri}) catch {}; 84 + }
+11 -9
backend/src/server.zig
··· 56 56 try handlePopular(request); 57 57 } else if (mem.eql(u8, target, "/dashboard")) { 58 58 try handleDashboard(request); 59 + } else if (mem.eql(u8, target, "/api/dashboard")) { 60 + try handleDashboardApi(request); 59 61 } else if (mem.startsWith(u8, target, "/similar")) { 60 62 try handleSimilar(request, target); 61 63 } else if (mem.eql(u8, target, "/activity")) { ··· 175 177 }); 176 178 } 177 179 178 - fn handleDashboard(request: *http.Server.Request) !void { 180 + fn handleDashboardApi(request: *http.Server.Request) !void { 179 181 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 180 182 defer arena.deinit(); 181 183 const alloc = arena.allocator(); 182 184 183 185 const data = dashboard.fetch(alloc) catch { 184 - try sendNotFound(request); 186 + try sendJson(request, "{\"error\":\"failed to fetch dashboard data\"}"); 185 187 return; 186 188 }; 187 189 188 - const html = dashboard.render(alloc, data) catch { 189 - try sendNotFound(request); 190 + const json_response = dashboard.toJson(alloc, data) catch { 191 + try sendJson(request, "{\"error\":\"failed to serialize dashboard data\"}"); 190 192 return; 191 193 }; 192 194 193 - try sendHtml(request, html); 195 + try sendJson(request, json_response); 194 196 } 195 197 196 - fn sendHtml(request: *http.Server.Request, body: []const u8) !void { 197 - try request.respond(body, .{ 198 - .status = .ok, 198 + fn handleDashboard(request: *http.Server.Request) !void { 199 + try request.respond("", .{ 200 + .status = .moved_permanently, 199 201 .extra_headers = &.{ 200 - .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 202 + .{ .name = "location", .value = "https://leaflet-search.pages.dev/dashboard.html" }, 201 203 }, 202 204 }); 203 205 }
+23 -20
backend/src/tap.zig
··· 100 100 /// TAP record envelope - extracted via zat.json.extractAt 101 101 const TapRecord = struct { 102 102 collection: []const u8, 103 - action: []const u8, 103 + action: zat.CommitAction, 104 104 did: []const u8, 105 105 rkey: []const u8, 106 106 }; ··· 139 139 const uri = try std.fmt.allocPrint(allocator, "at://{s}/{s}/{s}", .{ did.raw, rec.collection, rec.rkey }); 140 140 defer allocator.free(uri); 141 141 142 - if (mem.eql(u8, rec.action, "create") or mem.eql(u8, rec.action, "update")) { 143 - const record_obj = zat.json.getObject(parsed.value, "record.record") orelse return; 142 + switch (rec.action) { 143 + .create, .update => { 144 + const record_obj = zat.json.getObject(parsed.value, "record.record") orelse return; 144 145 145 - if (mem.eql(u8, rec.collection, DOCUMENT_COLLECTION)) { 146 - processDocument(allocator, uri, did.raw, rec.rkey, record_obj) catch |err| { 147 - std.debug.print("document processing error: {}\n", .{err}); 148 - }; 149 - } else if (mem.eql(u8, rec.collection, PUBLICATION_COLLECTION)) { 150 - processPublication(allocator, uri, did.raw, rec.rkey, record_obj) catch |err| { 151 - std.debug.print("publication processing error: {}\n", .{err}); 152 - }; 153 - } 154 - } else if (mem.eql(u8, rec.action, "delete")) { 155 - if (mem.eql(u8, rec.collection, DOCUMENT_COLLECTION)) { 156 - db.deleteDocument(uri); 157 - std.debug.print("deleted document: {s}\n", .{uri}); 158 - } else if (mem.eql(u8, rec.collection, PUBLICATION_COLLECTION)) { 159 - db.deletePublication(uri); 160 - std.debug.print("deleted publication: {s}\n", .{uri}); 161 - } 146 + if (mem.eql(u8, rec.collection, DOCUMENT_COLLECTION)) { 147 + processDocument(allocator, uri, did.raw, rec.rkey, record_obj) catch |err| { 148 + std.debug.print("document processing error: {}\n", .{err}); 149 + }; 150 + } else if (mem.eql(u8, rec.collection, PUBLICATION_COLLECTION)) { 151 + processPublication(allocator, uri, did.raw, rec.rkey, record_obj) catch |err| { 152 + std.debug.print("publication processing error: {}\n", .{err}); 153 + }; 154 + } 155 + }, 156 + .delete => { 157 + if (mem.eql(u8, rec.collection, DOCUMENT_COLLECTION)) { 158 + db.deleteDocument(uri); 159 + std.debug.print("deleted document: {s}\n", .{uri}); 160 + } else if (mem.eql(u8, rec.collection, PUBLICATION_COLLECTION)) { 161 + db.deletePublication(uri); 162 + std.debug.print("deleted publication: {s}\n", .{uri}); 163 + } 164 + }, 162 165 } 163 166 } 164 167
+137
site/dashboard.css
··· 1 + * { box-sizing: border-box; margin: 0; padding: 0; } 2 + 3 + body { 4 + font-family: monospace; 5 + background: #0a0a0a; 6 + color: #ccc; 7 + min-height: 100vh; 8 + padding: 1rem; 9 + font-size: 14px; 10 + line-height: 1.6; 11 + } 12 + 13 + .container { max-width: 600px; margin: 0 auto; } 14 + 15 + a { color: #1B7340; text-decoration: none; } 16 + a:hover { color: #2a9d5c; } 17 + 18 + h1 { 19 + font-size: 12px; 20 + font-weight: normal; 21 + margin-bottom: 1.5rem; 22 + } 23 + h1 a.title { color: #888; } 24 + h1 a.title:hover { color: #fff; } 25 + h1 .dim { color: #555; } 26 + 27 + section { margin-bottom: 2rem; } 28 + 29 + .section-title { 30 + font-size: 11px; 31 + color: #555; 32 + margin-bottom: 0.75rem; 33 + } 34 + 35 + .metrics { 36 + display: flex; 37 + gap: 1.5rem; 38 + margin-bottom: 1rem; 39 + } 40 + 41 + .metric-value { 42 + font-size: 16px; 43 + color: #888; 44 + font-weight: normal; 45 + } 46 + 47 + .metric-label { 48 + font-size: 10px; 49 + color: #444; 50 + text-transform: uppercase; 51 + letter-spacing: 0.5px; 52 + } 53 + 54 + .chart-box { 55 + background: #111; 56 + border: 1px solid #222; 57 + padding: 1rem; 58 + margin-bottom: 1rem; 59 + } 60 + 61 + .chart-header { 62 + display: flex; 63 + justify-content: space-between; 64 + font-size: 11px; 65 + color: #666; 66 + margin-bottom: 0.75rem; 67 + } 68 + 69 + .timeline { 70 + display: flex; 71 + align-items: flex-end; 72 + gap: 2px; 73 + height: 60px; 74 + } 75 + 76 + .bar { 77 + flex: 1; 78 + background: #1B7340; 79 + min-height: 2px; 80 + } 81 + .bar:hover { background: #2a9d5c; } 82 + 83 + .doc-row { 84 + display: flex; 85 + justify-content: space-between; 86 + font-size: 12px; 87 + padding: 0.25rem 0; 88 + border-bottom: 1px solid #1a1a1a; 89 + } 90 + .doc-row:last-child { border-bottom: none; } 91 + .doc-type { color: #888; } 92 + .doc-count { color: #ccc; } 93 + 94 + .pub-row { 95 + display: flex; 96 + justify-content: space-between; 97 + font-size: 12px; 98 + padding: 0.25rem 0; 99 + border-bottom: 1px solid #1a1a1a; 100 + } 101 + .pub-row:last-child { border-bottom: none; } 102 + .pub-name { color: #888; } 103 + .pub-count { color: #666; } 104 + 105 + .tags { 106 + display: flex; 107 + flex-wrap: wrap; 108 + gap: 0.5rem; 109 + } 110 + 111 + .tag { 112 + font-size: 11px; 113 + padding: 3px 8px; 114 + background: #151515; 115 + border: 1px solid #252525; 116 + border-radius: 3px; 117 + color: #777; 118 + } 119 + .tag:hover { 120 + background: #1a1a1a; 121 + border-color: #333; 122 + color: #aaa; 123 + } 124 + .tag .n { color: #444; margin-left: 4px; } 125 + 126 + .live { font-size: 11px; color: #555; } 127 + .live span { color: #4ade80; } 128 + 129 + footer { 130 + margin-top: 2rem; 131 + padding-top: 1rem; 132 + border-top: 1px solid #222; 133 + font-size: 11px; 134 + color: #444; 135 + } 136 + footer a { color: #555; } 137 + footer a:hover { color: #888; }
+72
site/dashboard.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>leaflet search / stats</title> 7 + <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='4' y='18' width='6' height='10' fill='%231B7340'/><rect x='13' y='12' width='6' height='16' fill='%231B7340'/><rect x='22' y='6' width='6' height='22' fill='%231B7340'/></svg>"> 8 + <link rel="stylesheet" href="dashboard.css"> 9 + </head> 10 + <body> 11 + <div class="container"> 12 + <h1><a href="https://leaflet-search.pages.dev" class="title">leaflet search</a> <span class="dim">/ stats</span></h1> 13 + 14 + <section> 15 + <div class="metrics"> 16 + <div> 17 + <div class="metric-value" id="age">--</div> 18 + <div class="metric-label">uptime</div> 19 + </div> 20 + <div> 21 + <div class="metric-value" id="searches">--</div> 22 + <div class="metric-label">searches</div> 23 + </div> 24 + <div> 25 + <div class="metric-value" id="publications">--</div> 26 + <div class="metric-label">publications</div> 27 + </div> 28 + </div> 29 + <div class="live" id="live"></div> 30 + </section> 31 + 32 + <section> 33 + <div class="section-title">documents</div> 34 + <div class="chart-box"> 35 + <div class="doc-row"> 36 + <span class="doc-type">articles</span> 37 + <span class="doc-count" id="articles">--</span> 38 + </div> 39 + <div class="doc-row"> 40 + <span class="doc-type">looseleafs</span> 41 + <span class="doc-count" id="looseleafs">--</span> 42 + </div> 43 + </div> 44 + </section> 45 + 46 + <section> 47 + <div class="section-title">activity (last 30 days)</div> 48 + <div class="chart-box"> 49 + <div class="timeline" id="timeline"></div> 50 + </div> 51 + </section> 52 + 53 + <section> 54 + <div class="section-title">top publications</div> 55 + <div class="chart-box"> 56 + <div id="pubs"></div> 57 + </div> 58 + </section> 59 + 60 + <section> 61 + <div class="section-title">tags</div> 62 + <div class="tags" id="tags"></div> 63 + </section> 64 + 65 + <footer> 66 + <a href="https://leaflet-search.pages.dev">back</a> · source on <a href="https://tangled.sh/@zzstoatzz.io/leaflet-search">tangled</a> 67 + </footer> 68 + </div> 69 + 70 + <script src="dashboard.js"></script> 71 + </body> 72 + </html>
+107
site/dashboard.js
··· 1 + const API_BASE = 'https://leaflet-search-backend.fly.dev'; 2 + 3 + let startedAt = 0; 4 + 5 + function formatAge(ms) { 6 + const s = Math.floor(ms / 1000); 7 + const d = Math.floor(s / 86400); 8 + const h = Math.floor((s % 86400) / 3600); 9 + const m = Math.floor((s % 3600) / 60); 10 + const sec = s % 60; 11 + if (d > 0) return d + 'd ' + h + 'h ' + m + 'm ' + sec + 's'; 12 + if (h > 0) return h + 'h ' + m + 'm ' + sec + 's'; 13 + return m + 'm ' + sec + 's'; 14 + } 15 + 16 + function updateAge() { 17 + if (startedAt > 0) { 18 + document.getElementById('age').textContent = formatAge(Date.now() - startedAt); 19 + } 20 + } 21 + 22 + function renderTimeline(timeline) { 23 + const el = document.getElementById('timeline'); 24 + if (!timeline || timeline.length === 0) return; 25 + 26 + const max = Math.max(...timeline.map(d => d.count)); 27 + [...timeline].reverse().forEach(d => { 28 + const h = max > 0 ? (d.count / max * 100) : 0; 29 + const bar = document.createElement('div'); 30 + bar.className = 'bar'; 31 + bar.style.height = Math.max(h, 3) + '%'; 32 + bar.title = d.date + ': ' + d.count; 33 + el.appendChild(bar); 34 + }); 35 + } 36 + 37 + function renderPubs(pubs) { 38 + const el = document.getElementById('pubs'); 39 + if (!pubs) return; 40 + 41 + pubs.forEach(p => { 42 + const row = document.createElement('div'); 43 + row.className = 'pub-row'; 44 + row.innerHTML = '<span class="pub-name">' + escapeHtml(p.name) + '</span><span class="pub-count">' + p.count + '</span>'; 45 + el.appendChild(row); 46 + }); 47 + } 48 + 49 + function renderTags(tags) { 50 + const el = document.getElementById('tags'); 51 + if (!tags) return; 52 + 53 + el.innerHTML = tags.slice(0, 20).map(t => 54 + '<a class="tag" href="https://leaflet-search.pages.dev/?tag=' + encodeURIComponent(t.tag) + '">' + 55 + escapeHtml(t.tag) + '<span class="n">' + t.count + '</span></a>' 56 + ).join(''); 57 + } 58 + 59 + function escapeHtml(str) { 60 + return str 61 + .replace(/&/g, '&amp;') 62 + .replace(/</g, '&lt;') 63 + .replace(/>/g, '&gt;') 64 + .replace(/"/g, '&quot;') 65 + .replace(/'/g, '&#39;'); 66 + } 67 + 68 + async function fetchDashboard() { 69 + try { 70 + const r = await fetch(API_BASE + '/api/dashboard'); 71 + const data = await r.json(); 72 + 73 + startedAt = data.startedAt * 1000; 74 + updateAge(); 75 + 76 + document.getElementById('searches').textContent = data.searches; 77 + document.getElementById('publications').textContent = data.publications; 78 + document.getElementById('articles').textContent = data.articles; 79 + document.getElementById('looseleafs').textContent = data.looseleafs; 80 + 81 + renderTimeline(data.timeline); 82 + renderPubs(data.topPubs); 83 + renderTags(data.tags); 84 + } catch (e) { 85 + console.error('failed to fetch dashboard:', e); 86 + } 87 + } 88 + 89 + let lastN = -1; 90 + async function pollLive() { 91 + try { 92 + const r = await fetch(API_BASE + '/activity'); 93 + const c = await r.json(); 94 + const n = c.reduce((a, b) => a + b, 0); 95 + if (n !== lastN) { 96 + const el = document.getElementById('live'); 97 + el.innerHTML = n > 0 ? '<span>' + n + '</span> req/6s' : ''; 98 + lastN = n; 99 + } 100 + } catch (e) {} 101 + } 102 + 103 + // init 104 + fetchDashboard(); 105 + setInterval(updateAge, 1000); 106 + pollLive(); 107 + setInterval(pollLive, 1000);