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.
- package/README.md +220 -105
- package/apps/api/dist/demo/demoArchive.js +371 -0
- package/apps/api/dist/demo/demoCast.js +138 -0
- package/apps/api/dist/demo/demoMedia.js +108 -0
- package/apps/api/dist/index.js +109 -29
- package/apps/api/dist/lib/archiveSchema.js +57 -0
- package/apps/api/dist/lib/archiveSync.js +151 -0
- package/apps/api/dist/lib/chatKind.js +41 -0
- package/apps/api/dist/lib/displayName.js +20 -0
- package/apps/api/dist/lib/mediaFsPath.js +27 -0
- package/apps/api/dist/lib/percentile.js +23 -0
- package/apps/api/dist/lib/query.js +33 -7
- package/apps/api/dist/lib/rollupDb.js +253 -0
- package/apps/api/dist/lib/searchFilters.js +141 -0
- package/apps/api/dist/lib/selfIdentity.js +42 -0
- package/apps/api/dist/lib/statsCache.js +83 -0
- package/apps/api/dist/lib/timezone.js +102 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
- package/apps/api/dist/pathsResolve.js +17 -3
- package/apps/api/dist/routes/data.js +141 -77
- package/apps/api/dist/routes/settings.js +3 -0
- package/apps/api/dist/routes/stats/profiles.js +522 -0
- package/apps/api/dist/routes/stats/reports.js +555 -0
- package/apps/api/dist/routes/stats/shared.js +75 -0
- package/apps/api/dist/routes/stats.js +273 -323
- package/apps/api/dist/routes/sync.js +60 -0
- package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
- package/apps/api/package.json +31 -0
- package/apps/web/dist/apple-touch-icon.png +0 -0
- package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
- package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
- package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
- package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
- package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
- package/apps/web/dist/favicon-16x16.png +0 -0
- package/apps/web/dist/favicon-32x32.png +0 -0
- package/apps/web/dist/favicon-512x512.png +0 -0
- package/apps/web/dist/favicon.ico +0 -0
- package/apps/web/dist/favicon.svg +59 -0
- package/apps/web/dist/index.html +11 -2
- package/bin/wacrawl-dashboard.js +64 -15
- package/package.json +20 -4
- package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
- 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(
|
|
36
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
29
37
|
const parsed = JSON.parse(raw);
|
|
30
|
-
|
|
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(
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
88
|
-
${contactLeftJoins('contacts',
|
|
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
|
|
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
|
|
170
|
+
const kind = parseChatKind(params.kind);
|
|
124
171
|
const targetJid = typeof params.jid === 'string' && params.jid.trim() ? params.jid.trim() : undefined;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
${
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|