wacrawl-dashboard 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +220 -105
  2. package/apps/api/dist/demo/demoArchive.js +371 -0
  3. package/apps/api/dist/demo/demoCast.js +138 -0
  4. package/apps/api/dist/demo/demoMedia.js +108 -0
  5. package/apps/api/dist/index.js +109 -29
  6. package/apps/api/dist/lib/archiveSchema.js +57 -0
  7. package/apps/api/dist/lib/archiveSync.js +151 -0
  8. package/apps/api/dist/lib/chatKind.js +41 -0
  9. package/apps/api/dist/lib/displayName.js +20 -0
  10. package/apps/api/dist/lib/mediaFsPath.js +27 -0
  11. package/apps/api/dist/lib/percentile.js +23 -0
  12. package/apps/api/dist/lib/query.js +33 -7
  13. package/apps/api/dist/lib/rollupDb.js +253 -0
  14. package/apps/api/dist/lib/searchFilters.js +141 -0
  15. package/apps/api/dist/lib/selfIdentity.js +42 -0
  16. package/apps/api/dist/lib/statsCache.js +83 -0
  17. package/apps/api/dist/lib/timezone.js +102 -0
  18. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  19. package/apps/api/dist/pathsResolve.js +17 -3
  20. package/apps/api/dist/routes/data.js +141 -77
  21. package/apps/api/dist/routes/settings.js +3 -0
  22. package/apps/api/dist/routes/stats/profiles.js +522 -0
  23. package/apps/api/dist/routes/stats/reports.js +555 -0
  24. package/apps/api/dist/routes/stats/shared.js +75 -0
  25. package/apps/api/dist/routes/stats.js +273 -323
  26. package/apps/api/dist/routes/sync.js +60 -0
  27. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  28. package/apps/api/package.json +31 -0
  29. package/apps/web/dist/apple-touch-icon.png +0 -0
  30. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  31. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  32. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  33. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  34. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  35. package/apps/web/dist/favicon-16x16.png +0 -0
  36. package/apps/web/dist/favicon-32x32.png +0 -0
  37. package/apps/web/dist/favicon-512x512.png +0 -0
  38. package/apps/web/dist/favicon.ico +0 -0
  39. package/apps/web/dist/favicon.svg +59 -0
  40. package/apps/web/dist/index.html +11 -2
  41. package/bin/wacrawl-dashboard.js +64 -15
  42. package/package.json +20 -4
  43. package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
  44. package/apps/web/dist/assets/index-DJiLhgJZ.css +0 -1
@@ -23,13 +23,24 @@ export function dashboardPathsFile() {
23
23
  }
24
24
  return path.join(os.homedir(), '.wacrawl', 'dashboard-paths.json');
25
25
  }
26
+ let cachedOverride = null;
27
+ export function invalidateDashboardPathsCache() {
28
+ cachedOverride = null;
29
+ }
26
30
  export function readDashboardPathsOverride() {
31
+ const filePath = dashboardPathsFile();
32
+ if (cachedOverride && cachedOverride.filePath === filePath) {
33
+ return cachedOverride.value;
34
+ }
27
35
  try {
28
- const raw = fs.readFileSync(dashboardPathsFile(), 'utf8');
36
+ const raw = fs.readFileSync(filePath, 'utf8');
29
37
  const parsed = JSON.parse(raw);
30
- return parsed && typeof parsed === 'object' ? parsed : null;
38
+ const value = parsed && typeof parsed === 'object' ? parsed : null;
39
+ cachedOverride = { filePath, value };
40
+ return value;
31
41
  }
32
42
  catch {
43
+ cachedOverride = { filePath, value: null };
33
44
  return null;
34
45
  }
35
46
  }
@@ -37,14 +48,17 @@ export function writeDashboardPathsOverride(data) {
37
48
  const filePath = dashboardPathsFile();
38
49
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
50
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
51
+ cachedOverride = { filePath, value: { ...data } };
40
52
  }
41
53
  export function clearDashboardPathsOverride() {
54
+ const filePath = dashboardPathsFile();
42
55
  try {
43
- fs.unlinkSync(dashboardPathsFile());
56
+ fs.unlinkSync(filePath);
44
57
  }
45
58
  catch {
46
59
  // Missing file is fine.
47
60
  }
61
+ cachedOverride = { filePath, value: null };
48
62
  }
49
63
  export function resolvePathsFromEnv() {
50
64
  const DEFAULT_DB = path.join(os.homedir(), '.wacrawl', 'wacrawl.db');
@@ -1,9 +1,20 @@
1
1
  import { Router } from 'express';
2
2
  import { getDb } from '../db.js';
3
- import { cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../lib/displayName.js';
4
- import { resolveArchiveMediaPath } from '../lib/mediaFsPath.js';
5
- import { parseLimit, parseOffset, unixSecondsToIso } from '../lib/query.js';
3
+ import { chatKindSql, parseChatKind } from '../lib/chatKind.js';
4
+ import { canonicalJidSql, cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins, isPersonJidSql } from '../lib/displayName.js';
5
+ import { mediaResponseHeaders, resolveArchiveMediaPath } from '../lib/mediaFsPath.js';
6
+ import { nameLikePattern, parseLimit, parseOffset, unixSecondsToIso } from '../lib/query.js';
7
+ import { parseSearchFilters } from '../lib/searchFilters.js';
6
8
  import { hasMessageSearchIndex, MESSAGE_SEARCH_FTS_TABLE } from '../lib/searchIndex.js';
9
+ /**
10
+ * Socket errors that mean "the client went away", not "the server failed".
11
+ * EPIPE and ECONNRESET surface when we are still writing a file the browser has
12
+ * already stopped reading; ECONNABORTED is send()'s own name for the same thing.
13
+ */
14
+ const CLIENT_DISCONNECT_CODES = new Set(['ECONNABORTED', 'ECONNRESET', 'EPIPE']);
15
+ export function isClientDisconnect(errCode) {
16
+ return errCode !== undefined && CLIENT_DISCONNECT_CODES.has(errCode);
17
+ }
7
18
  function asPagination(data, limit, offset, total) {
8
19
  return { data, pagination: { limit, offset, total } };
9
20
  }
@@ -23,9 +34,6 @@ function mapMessage(row) {
23
34
  mediaPath: row.mediaPath,
24
35
  };
25
36
  }
26
- function escapeLike(value) {
27
- return value.replace(/[\\%_]/g, (match) => `\\${match}`);
28
- }
29
37
  function buildFtsQuery(query) {
30
38
  const terms = query
31
39
  .toLowerCase()
@@ -40,7 +48,9 @@ function buildFtsQuery(query) {
40
48
  function formatSearchResults(rows, query, limit, offset, total) {
41
49
  return asPagination(rows.map((row) => {
42
50
  const message = mapMessage(row);
43
- const text = row.text ?? row.chatName ?? '';
51
+ // With no query there is nothing a chat name could be explaining, so a
52
+ // caption-less photo stays caption-less instead of showing the chat's name.
53
+ const text = row.text ?? (query ? row.chatName : null) ?? '';
44
54
  const ftsSnippet = row.snippet?.trim();
45
55
  const snippetSource = ftsSnippet || (text.toLowerCase().includes(query.toLowerCase())
46
56
  ? text
@@ -54,27 +64,20 @@ function formatSearchResults(rows, query, limit, offset, total) {
54
64
  export function getPeople(params, db = getDb()) {
55
65
  const limit = parseLimit(params.limit, 50, 200);
56
66
  const offset = parseOffset(params.offset);
57
- const total = db.prepare(`
58
- SELECT COUNT(*) AS count
59
- FROM (
60
- SELECT CASE WHEN from_me = 1 THEN chat_jid ELSE COALESCE(sender_jid, chat_jid) END AS jid
61
- FROM messages
62
- UNION
63
- SELECT jid FROM contacts
64
- )
65
- WHERE jid IS NOT NULL AND jid <> ''
66
- `).get().count;
67
- const rows = db.prepare(`
67
+ const like = nameLikePattern(params.q);
68
+ const targetJid = typeof params.jid === 'string' && params.jid.trim() ? params.jid.trim() : undefined;
69
+ const rawCounterpartySql = 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END';
70
+ const peopleSql = `
68
71
  WITH normalized_messages AS (
69
72
  SELECT
70
- CASE WHEN from_me = 1 THEN chat_jid ELSE COALESCE(sender_jid, chat_jid) END AS jid,
73
+ ${canonicalJidSql('contacts', rawCounterpartySql)} AS jid,
71
74
  ${contactDisplayNameSql('contacts')} AS contact_name,
72
- ${cleanDisplayNameSql('CASE WHEN from_me = 1 THEN messages.chat_name ELSE messages.sender_name END')} AS message_name,
75
+ ${cleanDisplayNameSql('CASE WHEN messages.from_me = 1 THEN messages.chat_name ELSE messages.sender_name END')} AS message_name,
73
76
  ${cleanDisplayNameSql(`
74
77
  COALESCE(
75
78
  chats.name,
76
79
  CASE
77
- WHEN messages.chat_jid = CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END
80
+ WHEN messages.chat_jid = ${rawCounterpartySql}
78
81
  THEN messages.chat_name
79
82
  ELSE NULL
80
83
  END
@@ -84,8 +87,8 @@ export function getPeople(params, db = getDb()) {
84
87
  ts,
85
88
  media_type
86
89
  FROM messages
87
- LEFT JOIN chats ON chats.jid = CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END
88
- ${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
90
+ LEFT JOIN chats ON chats.jid = ${rawCounterpartySql}
91
+ ${contactLeftJoins('contacts', rawCounterpartySql)}
89
92
  ),
90
93
  all_people AS (
91
94
  SELECT jid, ${contactDisplayNameSql('contacts', false)} AS contact_name, NULL AS message_name, NULL AS direct_chat_name, NULL AS from_me, NULL AS ts, NULL AS media_type
@@ -102,11 +105,34 @@ export function getPeople(params, db = getDb()) {
102
105
  SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS sentByThem,
103
106
  MAX(ts) AS lastMessageTs
104
107
  FROM all_people
105
- WHERE jid IS NOT NULL AND jid <> ''
108
+ WHERE ${isPersonJidSql('jid')} ${targetJid ? 'AND jid = @targetJid' : ''}
106
109
  GROUP BY jid
110
+ ${like ? `
111
+ HAVING name LIKE @like ESCAPE '\\'
112
+ OR SUM(CASE WHEN contact_name LIKE @like ESCAPE '\\'
113
+ OR message_name LIKE @like ESCAPE '\\'
114
+ OR direct_chat_name LIKE @like ESCAPE '\\' THEN 1 ELSE 0 END) > 0
115
+ ` : ''}
116
+ `;
117
+ // The unfiltered count needs no names, which makes it much cheaper than the page.
118
+ const total = (like || targetJid
119
+ ? db.prepare(`SELECT COUNT(*) AS count FROM (${peopleSql})`).get({ like, targetJid })
120
+ : db.prepare(`
121
+ SELECT COUNT(*) AS count
122
+ FROM (
123
+ SELECT ${canonicalJidSql('contacts', rawCounterpartySql)} AS jid
124
+ FROM messages
125
+ ${contactLeftJoins('contacts', rawCounterpartySql)}
126
+ UNION
127
+ SELECT jid FROM contacts
128
+ )
129
+ WHERE ${isPersonJidSql('jid')}
130
+ `).get());
131
+ const rows = db.prepare(`
132
+ ${peopleSql}
107
133
  ORDER BY messageCount DESC, name COLLATE NOCASE ASC
108
134
  LIMIT @limit OFFSET @offset
109
- `).all({ limit, offset });
135
+ `).all({ limit, offset, like, targetJid });
110
136
  return asPagination(rows.map((row) => ({
111
137
  jid: row.jid,
112
138
  name: row.name ?? row.jid,
@@ -115,18 +141,39 @@ export function getPeople(params, db = getDb()) {
115
141
  sentByMe: row.sentByMe ?? 0,
116
142
  sentByThem: row.sentByThem ?? 0,
117
143
  lastMessageAt: unixSecondsToIso(row.lastMessageTs),
118
- })), limit, offset, total);
144
+ })), limit, offset, total.count);
145
+ }
146
+ /**
147
+ * A chat matches on the name the list shows or on its contact's own name, which
148
+ * the list hides whenever the chat carries a name of its own.
149
+ */
150
+ function chatNameMatchSql() {
151
+ return `
152
+ (
153
+ COALESCE(
154
+ ${cleanDisplayNameSql('chats.name')},
155
+ (SELECT ${cleanDisplayNameSql('MAX(chat_name)')} FROM messages WHERE chat_jid = chats.jid),
156
+ ${cleanDisplayNameSql('chats.jid')}
157
+ ) LIKE @like ESCAPE '\\'
158
+ OR EXISTS (
159
+ SELECT 1
160
+ FROM contacts AS named_contact
161
+ WHERE (named_contact.jid = chats.jid OR named_contact.lid = chats.jid)
162
+ AND ${contactDisplayNameSql('named_contact', false)} LIKE @like ESCAPE '\\'
163
+ )
164
+ )
165
+ `;
119
166
  }
120
167
  export function getChats(params, db = getDb()) {
121
168
  const limit = parseLimit(params.limit, 50, 200);
122
169
  const offset = parseOffset(params.offset);
123
- const kind = params.kind === 'direct' || params.kind === 'group' ? params.kind : undefined;
170
+ const kind = parseChatKind(params.kind);
124
171
  const targetJid = typeof params.jid === 'string' && params.jid.trim() ? params.jid.trim() : undefined;
125
- let where = kind ? 'WHERE chats.kind = @kind' : 'WHERE chats.kind IN (\'direct\', \'group\')';
126
- if (targetJid) {
127
- where = 'WHERE chats.jid = @targetJid';
128
- }
129
- const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({ kind, targetJid }).count;
172
+ const like = nameLikePattern(params.q);
173
+ const where = targetJid
174
+ ? 'WHERE chats.jid = @targetJid'
175
+ : `WHERE ${chatKindSql(kind)} ${like ? `AND ${chatNameMatchSql()}` : ''}`;
176
+ const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({ targetJid, like }).count;
130
177
  const rows = db.prepare(`
131
178
  WITH chat_page AS (
132
179
  SELECT
@@ -178,7 +225,7 @@ export function getChats(params, db = getDb()) {
178
225
  LEFT JOIN latest ON latest.chat_jid = chat_page.jid
179
226
  ${contactLeftJoins('chat_contacts', 'chat_page.jid')}
180
227
  ORDER BY COALESCE(message_stats.lastMessageTs, chat_page.last_message_at, 0) DESC, name COLLATE NOCASE ASC
181
- `).all({ kind, limit, offset, targetJid });
228
+ `).all({ limit, offset, targetJid, like });
182
229
  return asPagination(rows.map((row) => ({
183
230
  jid: row.jid,
184
231
  kind: row.kind,
@@ -265,21 +312,57 @@ export function getMediaItems(params, db = getDb()) {
265
312
  fileUrl: `/api/media/file?path=${encodeURIComponent(row.mediaPath)}`,
266
313
  })), limit, offset, total);
267
314
  }
315
+ const SEARCH_COLUMNS_SQL = `
316
+ messages.rowid AS id,
317
+ msg_id AS msgId,
318
+ chat_jid AS chatJid,
319
+ COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
320
+ sender_jid AS senderJid,
321
+ COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
322
+ ts,
323
+ from_me AS fromMe,
324
+ text,
325
+ message_type AS messageType,
326
+ media_type AS mediaType,
327
+ media_path AS mediaPath
328
+ `;
329
+ const SEARCH_JOINS_SQL = `
330
+ LEFT JOIN chats ON chats.jid = messages.chat_jid
331
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
332
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
333
+ `;
268
334
  export function searchMessages(params, db = getDb()) {
269
335
  const query = typeof params.q === 'string' ? params.q.trim() : '';
270
336
  const limit = parseLimit(params.limit, 50, 100);
271
337
  const offset = parseOffset(params.offset);
272
- if (query.length < 2) {
338
+ const { conditions, bindings } = parseSearchFilters(params, db);
339
+ // A filter is a question in its own right, so only an unfiltered search needs
340
+ // enough of a query to be worth running.
341
+ if (conditions.length === 0 && query.length < 2) {
273
342
  return asPagination([], limit, offset, 0);
274
343
  }
344
+ const andFilters = conditions.map((condition) => `AND (${condition})`).join('\n');
345
+ if (!query) {
346
+ const total = db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE 1 = 1 ${andFilters}`).get(bindings).count;
347
+ const rows = db.prepare(`
348
+ SELECT ${SEARCH_COLUMNS_SQL}
349
+ FROM messages
350
+ ${SEARCH_JOINS_SQL}
351
+ WHERE 1 = 1 ${andFilters}
352
+ ORDER BY ts DESC, messages.rowid DESC
353
+ LIMIT @limit OFFSET @offset
354
+ `).all({ ...bindings, limit, offset });
355
+ return formatSearchResults(rows, query, limit, offset, total);
356
+ }
275
357
  const ftsQuery = buildFtsQuery(query);
276
358
  if (ftsQuery && hasMessageSearchIndex(db)) {
277
359
  try {
278
360
  const total = db.prepare(`
279
361
  SELECT COUNT(*) AS count
280
362
  FROM ${MESSAGE_SEARCH_FTS_TABLE}
281
- WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
282
- `).get({ ftsQuery }).count;
363
+ JOIN messages ON messages.rowid = ${MESSAGE_SEARCH_FTS_TABLE}.rowid
364
+ WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery ${andFilters}
365
+ `).get({ ...bindings, ftsQuery }).count;
283
366
  const rows = db.prepare(`
284
367
  WITH matched AS (
285
368
  SELECT
@@ -289,34 +372,22 @@ export function searchMessages(params, db = getDb()) {
289
372
  WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
290
373
  )
291
374
  SELECT
292
- messages.rowid AS id,
293
- msg_id AS msgId,
294
- chat_jid AS chatJid,
295
- COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
296
- sender_jid AS senderJid,
297
- COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
298
- ts,
299
- from_me AS fromMe,
300
- text,
301
- message_type AS messageType,
302
- media_type AS mediaType,
303
- media_path AS mediaPath,
375
+ ${SEARCH_COLUMNS_SQL},
304
376
  matched.snippet AS snippet
305
377
  FROM matched
306
378
  JOIN messages ON messages.rowid = matched.rowid
307
- LEFT JOIN chats ON chats.jid = messages.chat_jid
308
- ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
309
- ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
379
+ ${SEARCH_JOINS_SQL}
380
+ WHERE 1 = 1 ${andFilters}
310
381
  ORDER BY ts DESC, messages.rowid DESC
311
382
  LIMIT @limit OFFSET @offset
312
- `).all({ ftsQuery, limit, offset });
383
+ `).all({ ...bindings, ftsQuery, limit, offset });
313
384
  return formatSearchResults(rows, query, limit, offset, total);
314
385
  }
315
386
  catch {
316
387
  // If an older archive has an incompatible FTS table, keep search working.
317
388
  }
318
389
  }
319
- const like = `%${escapeLike(query)}%`;
390
+ const like = nameLikePattern(query);
320
391
  const where = `
321
392
  (messages.text LIKE @like ESCAPE '\\'
322
393
  OR messages.chat_name LIKE @like ESCAPE '\\'
@@ -324,37 +395,22 @@ export function searchMessages(params, db = getDb()) {
324
395
  OR chats.name LIKE @like ESCAPE '\\'
325
396
  OR ${contactDisplayNameSql('chat_contacts')} LIKE @like ESCAPE '\\'
326
397
  OR ${contactDisplayNameSql('sender_contacts')} LIKE @like ESCAPE '\\')
398
+ ${andFilters}
327
399
  `;
328
400
  const total = db.prepare(`
329
401
  SELECT COUNT(*) AS count
330
402
  FROM messages
331
- LEFT JOIN chats ON chats.jid = messages.chat_jid
332
- ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
333
- ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
403
+ ${SEARCH_JOINS_SQL}
334
404
  WHERE ${where}
335
- `).get({ like }).count;
405
+ `).get({ ...bindings, like }).count;
336
406
  const rows = db.prepare(`
337
- SELECT
338
- messages.rowid AS id,
339
- msg_id AS msgId,
340
- chat_jid AS chatJid,
341
- COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
342
- sender_jid AS senderJid,
343
- COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
344
- ts,
345
- from_me AS fromMe,
346
- text,
347
- message_type AS messageType,
348
- media_type AS mediaType,
349
- media_path AS mediaPath
407
+ SELECT ${SEARCH_COLUMNS_SQL}
350
408
  FROM messages
351
- LEFT JOIN chats ON chats.jid = messages.chat_jid
352
- ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
353
- ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
409
+ ${SEARCH_JOINS_SQL}
354
410
  WHERE ${where}
355
411
  ORDER BY ts DESC, messages.rowid DESC
356
412
  LIMIT @limit OFFSET @offset
357
- `).all({ like, limit, offset });
413
+ `).all({ ...bindings, like, limit, offset });
358
414
  return formatSearchResults(rows, query, limit, offset, total);
359
415
  }
360
416
  export const dataRouter = Router();
@@ -394,10 +450,18 @@ dataRouter.get('/media/file', (req, res, next) => {
394
450
  res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid media path.' } });
395
451
  return;
396
452
  }
397
- res.sendFile(absolutePath, (err) => {
453
+ // Passed to sendFile rather than set here so an error response doesn't carry them.
454
+ res.sendFile(absolutePath, { headers: mediaResponseHeaders(absolutePath) }, (err) => {
398
455
  if (!err)
399
456
  return;
400
457
  const errCode = err.code;
458
+ // Browsers hang up on media requests constantly — <video> probing for
459
+ // metadata, the user scrolling a thumbnail out of view, seeking to a new
460
+ // range. The socket is gone and the response is half-written, so there is
461
+ // no one to report to and nothing left to send.
462
+ if (isClientDisconnect(errCode) || res.headersSent) {
463
+ return;
464
+ }
401
465
  if (errCode === 'ENOENT') {
402
466
  res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Media file was not found on disk.' } });
403
467
  return;
@@ -422,13 +486,13 @@ dataRouter.get('/search', (req, res) => {
422
486
  dataRouter.get('/messages/:id/offset', (req, res) => {
423
487
  const id = Number(req.params.id);
424
488
  if (isNaN(id)) {
425
- res.status(400).json({ error: 'Invalid message ID' });
489
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid message ID.' } });
426
490
  return;
427
491
  }
428
492
  const db = getDb();
429
493
  const msg = db.prepare('SELECT chat_jid, ts FROM messages WHERE rowid = ?').get(id);
430
494
  if (!msg) {
431
- res.status(404).json({ error: 'Message not found' });
495
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Message not found.' } });
432
496
  return;
433
497
  }
434
498
  const countRow = db.prepare(`
@@ -1,5 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import { closeDb } from '../db.js';
3
+ import { clearStatsCache } from '../lib/statsCache.js';
3
4
  import { clearDashboardPathsOverride, mergePathPatch, readDashboardPathsOverride, resolvePathsFromEnv, resolvePathsForRequest, writeDashboardPathsOverride, } from '../pathsResolve.js';
4
5
  export const settingsRouter = Router();
5
6
  settingsRouter.get('/paths', (req, res) => {
@@ -28,11 +29,13 @@ settingsRouter.post('/paths', (req, res) => {
28
29
  };
29
30
  writeDashboardPathsOverride(patch);
30
31
  closeDb();
32
+ clearStatsCache();
31
33
  const effective = mergePathPatch(resolvePathsFromEnv(), patch);
32
34
  res.json({ ok: true, effective });
33
35
  });
34
36
  settingsRouter.delete('/paths', (_req, res) => {
35
37
  clearDashboardPathsOverride();
36
38
  closeDb();
39
+ clearStatsCache();
37
40
  res.json({ ok: true, effective: resolvePathsFromEnv() });
38
41
  });