wacrawl-dashboard 0.1.1 → 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 (46) hide show
  1. package/README.md +220 -89
  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 +37 -3
  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/searchIndex.js +3 -3
  16. package/apps/api/dist/lib/selfIdentity.js +42 -0
  17. package/apps/api/dist/lib/statsCache.js +83 -0
  18. package/apps/api/dist/lib/timezone.js +120 -5
  19. package/apps/api/dist/lib/urlExtract.js +111 -0
  20. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  21. package/apps/api/dist/pathsResolve.js +17 -3
  22. package/apps/api/dist/routes/data.js +168 -78
  23. package/apps/api/dist/routes/settings.js +3 -0
  24. package/apps/api/dist/routes/stats/profiles.js +522 -0
  25. package/apps/api/dist/routes/stats/reports.js +555 -0
  26. package/apps/api/dist/routes/stats/shared.js +75 -0
  27. package/apps/api/dist/routes/stats.js +486 -97
  28. package/apps/api/dist/routes/sync.js +60 -0
  29. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  30. package/apps/api/package.json +31 -0
  31. package/apps/web/dist/apple-touch-icon.png +0 -0
  32. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  33. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  34. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  35. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  36. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  37. package/apps/web/dist/favicon-16x16.png +0 -0
  38. package/apps/web/dist/favicon-32x32.png +0 -0
  39. package/apps/web/dist/favicon-512x512.png +0 -0
  40. package/apps/web/dist/favicon.ico +0 -0
  41. package/apps/web/dist/favicon.svg +59 -0
  42. package/apps/web/dist/index.html +11 -2
  43. package/bin/wacrawl-dashboard.js +64 -15
  44. package/package.json +25 -4
  45. package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
  46. package/apps/web/dist/assets/index-Dfsoi6vG.js +0 -43
@@ -0,0 +1,111 @@
1
+ const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi;
2
+ const TRAILING_PUNCT_RE = /[.,;:!?)}\]]+$/;
3
+ const DOMAIN_CATEGORIES = {
4
+ 'youtube.com': 'video',
5
+ 'youtu.be': 'video',
6
+ 'vimeo.com': 'video',
7
+ 'dailymotion.com': 'video',
8
+ 'twitch.tv': 'video',
9
+ 'tiktok.com': 'video',
10
+ 'vm.tiktok.com': 'video',
11
+ 'twitter.com': 'social',
12
+ 'x.com': 'social',
13
+ 'facebook.com': 'social',
14
+ 'fb.com': 'social',
15
+ 'instagram.com': 'social',
16
+ 'linkedin.com': 'social',
17
+ 'reddit.com': 'social',
18
+ 'threads.net': 'social',
19
+ 'mastodon.social': 'social',
20
+ 'bsky.app': 'social',
21
+ 'pinterest.com': 'social',
22
+ 'snapchat.com': 'social',
23
+ 'tumblr.com': 'social',
24
+ 'wa.me': 'social',
25
+ 'cnn.com': 'news',
26
+ 'bbc.com': 'news',
27
+ 'bbc.co.uk': 'news',
28
+ 'nytimes.com': 'news',
29
+ 'theguardian.com': 'news',
30
+ 'reuters.com': 'news',
31
+ 'apnews.com': 'news',
32
+ 'washingtonpost.com': 'news',
33
+ 'aljazeera.com': 'news',
34
+ 'news.google.com': 'news',
35
+ 'news.yahoo.com': 'news',
36
+ 'techcrunch.com': 'news',
37
+ 'theverge.com': 'news',
38
+ 'arstechnica.com': 'news',
39
+ 'wired.com': 'news',
40
+ 'medium.com': 'news',
41
+ 'substack.com': 'news',
42
+ 'bloomberg.com': 'news',
43
+ 'cnbc.com': 'news',
44
+ 'foxnews.com': 'news',
45
+ 'nbcnews.com': 'news',
46
+ 'ynet.co.il': 'news',
47
+ 'haaretz.com': 'news',
48
+ 'amazon.com': 'shopping',
49
+ 'amazon.co.uk': 'shopping',
50
+ 'amazon.de': 'shopping',
51
+ 'ebay.com': 'shopping',
52
+ 'etsy.com': 'shopping',
53
+ 'aliexpress.com': 'shopping',
54
+ 'walmart.com': 'shopping',
55
+ 'target.com': 'shopping',
56
+ 'shopify.com': 'shopping',
57
+ 'wish.com': 'shopping',
58
+ 'spotify.com': 'music',
59
+ 'open.spotify.com': 'music',
60
+ 'soundcloud.com': 'music',
61
+ 'music.apple.com': 'music',
62
+ 'music.youtube.com': 'music',
63
+ 'deezer.com': 'music',
64
+ 'bandcamp.com': 'music',
65
+ 'github.com': 'dev',
66
+ 'gitlab.com': 'dev',
67
+ 'stackoverflow.com': 'dev',
68
+ 'npmjs.com': 'dev',
69
+ 'pypi.org': 'dev',
70
+ 'dev.to': 'dev',
71
+ 'codepen.io': 'dev',
72
+ 'jsfiddle.net': 'dev',
73
+ 'codesandbox.io': 'dev',
74
+ 'hackernews.com': 'dev',
75
+ 'news.ycombinator.com': 'dev',
76
+ 'wikipedia.org': 'reference',
77
+ 'en.wikipedia.org': 'reference',
78
+ 'docs.google.com': 'reference',
79
+ 'drive.google.com': 'reference',
80
+ 'maps.google.com': 'reference',
81
+ 'translate.google.com': 'reference',
82
+ 'google.com': 'reference',
83
+ 'bing.com': 'reference',
84
+ 'duckduckgo.com': 'reference',
85
+ };
86
+ export function extractUrls(text) {
87
+ if (!text)
88
+ return [];
89
+ const matches = text.match(URL_RE);
90
+ if (!matches)
91
+ return [];
92
+ return matches.map((url) => url.replace(TRAILING_PUNCT_RE, ''));
93
+ }
94
+ export function parseDomain(url) {
95
+ try {
96
+ const hostname = new URL(url).hostname.toLowerCase();
97
+ return hostname.replace(/^www\./, '');
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ export function categorizeDomain(domain) {
104
+ if (DOMAIN_CATEGORIES[domain])
105
+ return DOMAIN_CATEGORIES[domain];
106
+ for (const [known, category] of Object.entries(DOMAIN_CATEGORIES)) {
107
+ if (domain.endsWith(`.${known}`))
108
+ return category;
109
+ }
110
+ return 'other';
111
+ }
@@ -1,6 +1,15 @@
1
- import { eng } from 'stopword';
1
+ import { eng, heb } from 'stopword';
2
2
  /** Messaging / link / attachment noise beyond standard English stopwords. */
3
3
  const CHAT_TECH_URL_EXTRA = [
4
+ // Tracking parameters. Every shared link carries them, so they outrank real
5
+ // vocabulary in any chat where people paste articles: 'utm' and 'lid' were
6
+ // the top two terms of a 43-message group.
7
+ 'fbclid',
8
+ 'gclid',
9
+ 'igsh',
10
+ 'lid',
11
+ 'mibextid',
12
+ 'utm',
4
13
  'apk',
5
14
  'asp',
6
15
  'au',
@@ -113,6 +122,30 @@ const EXTRA_EN_STOP = [
113
122
  'yet',
114
123
  ];
115
124
  /**
116
- * Tokens dropped in "useful" word-cloud mode (lowercase; message tokens are `[a-z0-9]{3,}`).
125
+ * Hebrew chat filler beyond the standard list.
126
+ *
127
+ * Hebrew glues its prepositions and conjunctions onto the following word
128
+ * (ב/ל/מ/ו/ש/ה), so a stopword list can only catch the standalone forms;
129
+ * stripping those prefixes needs a real morphological analyzer and is out of
130
+ * scope here. These are the free-standing tokens that otherwise dominate.
117
131
  */
118
- export const USEFUL_WORD_STOP_SET = new Set([...eng, ...CHAT_TECH_URL_EXTRA, ...CHAT_FILLER_EXTRA, ...EXTRA_EN_STOP]);
132
+ const CHAT_FILLER_EXTRA_HE = [
133
+ 'אבל', 'אוקיי', 'אולי', 'אותו', 'אותי', 'איזה', 'איך', 'אין', 'אלא', 'אנחנו',
134
+ 'אני', 'אתה', 'אתם', 'באמת', 'בבקשה', 'בסדר', 'בוקר', 'גם', 'הוא', 'היא',
135
+ 'היה', 'הם', 'זה', 'טוב', 'יום', 'יופי', 'יש', 'כבר', 'ככה', 'כן',
136
+ 'כמו', 'לא', 'להיות', 'לי', 'לך', 'מה', 'מתי', 'סבבה', 'עכשיו', 'על',
137
+ 'עם', 'פשוט', 'רק', 'שוב', 'שלום', 'שלי', 'שלך', 'תודה', 'תהיה', 'תוכל',
138
+ ];
139
+ /**
140
+ * Tokens dropped in "useful" word-cloud mode.
141
+ *
142
+ * Lowercased to match the tokenizer, which is a no-op for scripts without case.
143
+ */
144
+ export const USEFUL_WORD_STOP_SET = new Set([
145
+ ...eng,
146
+ ...heb,
147
+ ...CHAT_TECH_URL_EXTRA,
148
+ ...CHAT_FILLER_EXTRA,
149
+ ...CHAT_FILLER_EXTRA_HE,
150
+ ...EXTRA_EN_STOP,
151
+ ]);
@@ -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, contactMatchSql } 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,11 +87,11 @@ 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
- LEFT JOIN contacts ON ${contactMatchSql('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
- SELECT jid, ${contactDisplayNameSql('contacts')} AS contact_name, NULL AS message_name, NULL AS direct_chat_name, NULL AS from_me, NULL AS ts, NULL AS media_type
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
92
95
  FROM contacts
93
96
  UNION ALL
94
97
  SELECT jid, contact_name, message_name, direct_chat_name, from_me, ts, media_type FROM normalized_messages
@@ -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,14 +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;
124
- const where = kind ? 'WHERE chats.kind = @kind' : 'WHERE chats.kind IN (\'direct\', \'group\')';
125
- const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({ kind }).count;
170
+ const kind = parseChatKind(params.kind);
171
+ const targetJid = typeof params.jid === 'string' && params.jid.trim() ? params.jid.trim() : undefined;
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;
126
177
  const rows = db.prepare(`
127
178
  WITH chat_page AS (
128
179
  SELECT
@@ -172,9 +223,9 @@ export function getChats(params, db = getDb()) {
172
223
  FROM chat_page
173
224
  LEFT JOIN message_stats ON message_stats.chat_jid = chat_page.jid
174
225
  LEFT JOIN latest ON latest.chat_jid = chat_page.jid
175
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'chat_page.jid')}
226
+ ${contactLeftJoins('chat_contacts', 'chat_page.jid')}
176
227
  ORDER BY COALESCE(message_stats.lastMessageTs, chat_page.last_message_at, 0) DESC, name COLLATE NOCASE ASC
177
- `).all({ kind, limit, offset });
228
+ `).all({ limit, offset, targetJid, like });
178
229
  return asPagination(rows.map((row) => ({
179
230
  jid: row.jid,
180
231
  kind: row.kind,
@@ -207,8 +258,8 @@ export function getChatMessages(chatJid, params, db = getDb()) {
207
258
  media_path AS mediaPath
208
259
  FROM messages
209
260
  LEFT JOIN chats ON chats.jid = messages.chat_jid
210
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
211
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
261
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
262
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
212
263
  WHERE chat_jid = @chatJid
213
264
  ORDER BY ts DESC, messages.rowid DESC
214
265
  LIMIT @limit OFFSET @offset
@@ -240,8 +291,8 @@ export function getMediaItems(params, db = getDb()) {
240
291
  media_size AS mediaSize
241
292
  FROM messages
242
293
  LEFT JOIN chats ON chats.jid = messages.chat_jid
243
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
244
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
294
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
295
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
245
296
  WHERE ${where}
246
297
  ORDER BY ts DESC, messages.rowid DESC
247
298
  LIMIT @limit OFFSET @offset
@@ -261,21 +312,57 @@ export function getMediaItems(params, db = getDb()) {
261
312
  fileUrl: `/api/media/file?path=${encodeURIComponent(row.mediaPath)}`,
262
313
  })), limit, offset, total);
263
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
+ `;
264
334
  export function searchMessages(params, db = getDb()) {
265
335
  const query = typeof params.q === 'string' ? params.q.trim() : '';
266
336
  const limit = parseLimit(params.limit, 50, 100);
267
337
  const offset = parseOffset(params.offset);
268
- 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) {
269
342
  return asPagination([], limit, offset, 0);
270
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
+ }
271
357
  const ftsQuery = buildFtsQuery(query);
272
358
  if (ftsQuery && hasMessageSearchIndex(db)) {
273
359
  try {
274
360
  const total = db.prepare(`
275
361
  SELECT COUNT(*) AS count
276
362
  FROM ${MESSAGE_SEARCH_FTS_TABLE}
277
- WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
278
- `).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;
279
366
  const rows = db.prepare(`
280
367
  WITH matched AS (
281
368
  SELECT
@@ -285,34 +372,22 @@ export function searchMessages(params, db = getDb()) {
285
372
  WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
286
373
  )
287
374
  SELECT
288
- messages.rowid AS id,
289
- msg_id AS msgId,
290
- chat_jid AS chatJid,
291
- COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
292
- sender_jid AS senderJid,
293
- COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
294
- ts,
295
- from_me AS fromMe,
296
- text,
297
- message_type AS messageType,
298
- media_type AS mediaType,
299
- media_path AS mediaPath,
375
+ ${SEARCH_COLUMNS_SQL},
300
376
  matched.snippet AS snippet
301
377
  FROM matched
302
378
  JOIN messages ON messages.rowid = matched.rowid
303
- LEFT JOIN chats ON chats.jid = messages.chat_jid
304
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
305
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
379
+ ${SEARCH_JOINS_SQL}
380
+ WHERE 1 = 1 ${andFilters}
306
381
  ORDER BY ts DESC, messages.rowid DESC
307
382
  LIMIT @limit OFFSET @offset
308
- `).all({ ftsQuery, limit, offset });
383
+ `).all({ ...bindings, ftsQuery, limit, offset });
309
384
  return formatSearchResults(rows, query, limit, offset, total);
310
385
  }
311
386
  catch {
312
387
  // If an older archive has an incompatible FTS table, keep search working.
313
388
  }
314
389
  }
315
- const like = `%${escapeLike(query)}%`;
390
+ const like = nameLikePattern(query);
316
391
  const where = `
317
392
  (messages.text LIKE @like ESCAPE '\\'
318
393
  OR messages.chat_name LIKE @like ESCAPE '\\'
@@ -320,37 +395,22 @@ export function searchMessages(params, db = getDb()) {
320
395
  OR chats.name LIKE @like ESCAPE '\\'
321
396
  OR ${contactDisplayNameSql('chat_contacts')} LIKE @like ESCAPE '\\'
322
397
  OR ${contactDisplayNameSql('sender_contacts')} LIKE @like ESCAPE '\\')
398
+ ${andFilters}
323
399
  `;
324
400
  const total = db.prepare(`
325
401
  SELECT COUNT(*) AS count
326
402
  FROM messages
327
- LEFT JOIN chats ON chats.jid = messages.chat_jid
328
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
329
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
403
+ ${SEARCH_JOINS_SQL}
330
404
  WHERE ${where}
331
- `).get({ like }).count;
405
+ `).get({ ...bindings, like }).count;
332
406
  const rows = db.prepare(`
333
- SELECT
334
- messages.rowid AS id,
335
- msg_id AS msgId,
336
- chat_jid AS chatJid,
337
- COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
338
- sender_jid AS senderJid,
339
- COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
340
- ts,
341
- from_me AS fromMe,
342
- text,
343
- message_type AS messageType,
344
- media_type AS mediaType,
345
- media_path AS mediaPath
407
+ SELECT ${SEARCH_COLUMNS_SQL}
346
408
  FROM messages
347
- LEFT JOIN chats ON chats.jid = messages.chat_jid
348
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
349
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
409
+ ${SEARCH_JOINS_SQL}
350
410
  WHERE ${where}
351
411
  ORDER BY ts DESC, messages.rowid DESC
352
412
  LIMIT @limit OFFSET @offset
353
- `).all({ like, limit, offset });
413
+ `).all({ ...bindings, like, limit, offset });
354
414
  return formatSearchResults(rows, query, limit, offset, total);
355
415
  }
356
416
  export const dataRouter = Router();
@@ -390,10 +450,18 @@ dataRouter.get('/media/file', (req, res, next) => {
390
450
  res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid media path.' } });
391
451
  return;
392
452
  }
393
- 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) => {
394
455
  if (!err)
395
456
  return;
396
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
+ }
397
465
  if (errCode === 'ENOENT') {
398
466
  res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Media file was not found on disk.' } });
399
467
  return;
@@ -415,3 +483,25 @@ dataRouter.get('/media/file', (req, res, next) => {
415
483
  dataRouter.get('/search', (req, res) => {
416
484
  res.json(searchMessages(req.query));
417
485
  });
486
+ dataRouter.get('/messages/:id/offset', (req, res) => {
487
+ const id = Number(req.params.id);
488
+ if (isNaN(id)) {
489
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid message ID.' } });
490
+ return;
491
+ }
492
+ const db = getDb();
493
+ const msg = db.prepare('SELECT chat_jid, ts FROM messages WHERE rowid = ?').get(id);
494
+ if (!msg) {
495
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Message not found.' } });
496
+ return;
497
+ }
498
+ const countRow = db.prepare(`
499
+ SELECT COUNT(*) AS count
500
+ FROM messages
501
+ WHERE chat_jid = ? AND (ts > ? OR (ts = ? AND rowid > ?))
502
+ `).get(msg.chat_jid, msg.ts, msg.ts, id);
503
+ res.json({
504
+ chatJid: msg.chat_jid,
505
+ offset: countRow.count
506
+ });
507
+ });
@@ -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
  });