wacrawl-dashboard 0.1.1

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.
@@ -0,0 +1,142 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function expandPath(p) {
5
+ const t = p.trim();
6
+ if (!t)
7
+ return t;
8
+ if (t.startsWith('~/'))
9
+ return path.join(os.homedir(), t.slice(2));
10
+ return path.normalize(t);
11
+ }
12
+ function trimEnv(name) {
13
+ const raw = process.env[name];
14
+ if (!raw)
15
+ return undefined;
16
+ const t = raw.trim();
17
+ return t || undefined;
18
+ }
19
+ export function dashboardPathsFile() {
20
+ const envPath = process.env.WACRAWL_PATHS_FILE?.trim();
21
+ if (envPath) {
22
+ return envPath.startsWith('~/') ? path.join(os.homedir(), envPath.slice(2)) : path.resolve(envPath);
23
+ }
24
+ return path.join(os.homedir(), '.wacrawl', 'dashboard-paths.json');
25
+ }
26
+ export function readDashboardPathsOverride() {
27
+ try {
28
+ const raw = fs.readFileSync(dashboardPathsFile(), 'utf8');
29
+ const parsed = JSON.parse(raw);
30
+ return parsed && typeof parsed === 'object' ? parsed : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function writeDashboardPathsOverride(data) {
37
+ const filePath = dashboardPathsFile();
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
40
+ }
41
+ export function clearDashboardPathsOverride() {
42
+ try {
43
+ fs.unlinkSync(dashboardPathsFile());
44
+ }
45
+ catch {
46
+ // Missing file is fine.
47
+ }
48
+ }
49
+ export function resolvePathsFromEnv() {
50
+ const DEFAULT_DB = path.join(os.homedir(), '.wacrawl', 'wacrawl.db');
51
+ const containerRaw = trimEnv('WACRAWL_WHATSAPP_CONTAINER');
52
+ const whatsappContainer = containerRaw ? expandPath(containerRaw) : null;
53
+ const derivedChatDb = whatsappContainer ? path.join(whatsappContainer, 'ChatStorage.sqlite') : null;
54
+ const derivedContactsDb = whatsappContainer ? path.join(whatsappContainer, 'ContactsV2.sqlite') : null;
55
+ const derivedMediaRoot = whatsappContainer ? path.join(whatsappContainer, 'Message', 'Media') : null;
56
+ const explicitChatDb = trimEnv('WACRAWL_CHAT_DB');
57
+ const chatDb = explicitChatDb ? expandPath(explicitChatDb) : derivedChatDb;
58
+ const explicitContactsDb = trimEnv('WACRAWL_CONTACTS_DB');
59
+ const contactsDb = explicitContactsDb ? expandPath(explicitContactsDb) : derivedContactsDb;
60
+ const explicitPrimary = trimEnv('WACRAWL_DB');
61
+ const primaryDb = explicitPrimary ? expandPath(explicitPrimary) : chatDb ?? DEFAULT_DB;
62
+ const explicitMediaRoot = trimEnv('WACRAWL_MEDIA_ROOT');
63
+ const mediaRoot = explicitMediaRoot ? expandPath(explicitMediaRoot) : derivedMediaRoot ?? path.dirname(primaryDb);
64
+ return {
65
+ whatsappContainer,
66
+ chatDb,
67
+ contactsDb,
68
+ mediaRoot,
69
+ primaryDb,
70
+ };
71
+ }
72
+ function patchPick(patch, key) {
73
+ if (!(key in patch))
74
+ return undefined;
75
+ const v = patch[key];
76
+ if (v === undefined || v === null)
77
+ return undefined;
78
+ const t = String(v).trim();
79
+ return t ? expandPath(t) : undefined;
80
+ }
81
+ /** Layer overrides onto base (env → dashboard file → …). Later callers may merge headers the same way. */
82
+ export function mergePathPatch(base, patch) {
83
+ const whatsappContainer = patchPick(patch, 'whatsappContainer') ?? base.whatsappContainer;
84
+ const derivedChatDb = whatsappContainer ? path.join(whatsappContainer, 'ChatStorage.sqlite') : null;
85
+ const derivedContactsDb = whatsappContainer ? path.join(whatsappContainer, 'ContactsV2.sqlite') : null;
86
+ const derivedMediaRoot = whatsappContainer ? path.join(whatsappContainer, 'Message', 'Media') : null;
87
+ const chatDb = patchPick(patch, 'chatDb') ?? base.chatDb ?? derivedChatDb;
88
+ const contactsDb = patchPick(patch, 'contactsDb') ?? base.contactsDb ?? derivedContactsDb;
89
+ const DEFAULT_DB = path.join(os.homedir(), '.wacrawl', 'wacrawl.db');
90
+ const primaryDb = patchPick(patch, 'primaryDb') ??
91
+ base.primaryDb ??
92
+ chatDb ??
93
+ DEFAULT_DB;
94
+ const mediaRoot = patchPick(patch, 'mediaRoot') ??
95
+ base.mediaRoot ??
96
+ derivedMediaRoot ??
97
+ path.dirname(primaryDb);
98
+ return {
99
+ whatsappContainer,
100
+ chatDb,
101
+ contactsDb,
102
+ mediaRoot,
103
+ primaryDb,
104
+ };
105
+ }
106
+ function headerString(headers, lowerName) {
107
+ const v = headers[lowerName];
108
+ const raw = Array.isArray(v) ? v[0] : v;
109
+ if (typeof raw !== 'string' || !raw.trim())
110
+ return undefined;
111
+ return expandPath(raw.trim());
112
+ }
113
+ /** Optional per-request overrides from the UI (fetch API); `<img>` tags cannot send these—prefer POST /api/settings/paths. */
114
+ export function mergeHeaderPathOverrides(headers, base) {
115
+ const patch = {};
116
+ const c = headerString(headers, 'x-wacrawl-whatsapp-container');
117
+ const chat = headerString(headers, 'x-wacrawl-chat-db');
118
+ const contacts = headerString(headers, 'x-wacrawl-contacts-db');
119
+ const media = headerString(headers, 'x-wacrawl-media-root');
120
+ const primary = headerString(headers, 'x-wacrawl-db');
121
+ if (c !== undefined)
122
+ patch.whatsappContainer = c;
123
+ if (chat !== undefined)
124
+ patch.chatDb = chat;
125
+ if (contacts !== undefined)
126
+ patch.contactsDb = contacts;
127
+ if (media !== undefined)
128
+ patch.mediaRoot = media;
129
+ if (primary !== undefined)
130
+ patch.primaryDb = primary;
131
+ if (Object.keys(patch).length === 0)
132
+ return base;
133
+ return mergePathPatch(base, patch);
134
+ }
135
+ export function resolvePathsForRequest(headers) {
136
+ let resolved = resolvePathsFromEnv();
137
+ const stored = readDashboardPathsOverride();
138
+ if (stored) {
139
+ resolved = mergePathPatch(resolved, stored);
140
+ }
141
+ return mergeHeaderPathOverrides(headers, resolved);
142
+ }
@@ -0,0 +1,417 @@
1
+ import { Router } from 'express';
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';
6
+ import { hasMessageSearchIndex, MESSAGE_SEARCH_FTS_TABLE } from '../lib/searchIndex.js';
7
+ function asPagination(data, limit, offset, total) {
8
+ return { data, pagination: { limit, offset, total } };
9
+ }
10
+ function mapMessage(row) {
11
+ return {
12
+ id: row.id,
13
+ msgId: row.msgId,
14
+ chatJid: row.chatJid,
15
+ chatName: row.chatName ?? row.chatJid,
16
+ senderJid: row.senderJid,
17
+ senderName: row.senderName,
18
+ sentAt: unixSecondsToIso(row.ts) ?? new Date(0).toISOString(),
19
+ fromMe: row.fromMe === 1,
20
+ text: row.text,
21
+ messageType: row.messageType,
22
+ mediaType: row.mediaType,
23
+ mediaPath: row.mediaPath,
24
+ };
25
+ }
26
+ function escapeLike(value) {
27
+ return value.replace(/[\\%_]/g, (match) => `\\${match}`);
28
+ }
29
+ function buildFtsQuery(query) {
30
+ const terms = query
31
+ .toLowerCase()
32
+ .match(/[\p{L}\p{N}]+/gu)
33
+ ?.filter((term) => term.length > 0)
34
+ .slice(0, 8);
35
+ if (!terms?.length) {
36
+ return null;
37
+ }
38
+ return terms.map((term) => `"${term.replace(/"/g, '""')}"*`).join(' AND ');
39
+ }
40
+ function formatSearchResults(rows, query, limit, offset, total) {
41
+ return asPagination(rows.map((row) => {
42
+ const message = mapMessage(row);
43
+ const text = row.text ?? row.chatName ?? '';
44
+ const ftsSnippet = row.snippet?.trim();
45
+ const snippetSource = ftsSnippet || (text.toLowerCase().includes(query.toLowerCase())
46
+ ? text
47
+ : [row.chatName, row.senderName, text].filter(Boolean).join(' - '));
48
+ return {
49
+ ...message,
50
+ snippet: snippetSource.length > 180 ? `${snippetSource.slice(0, 177)}...` : snippetSource,
51
+ };
52
+ }), limit, offset, total);
53
+ }
54
+ export function getPeople(params, db = getDb()) {
55
+ const limit = parseLimit(params.limit, 50, 200);
56
+ 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(`
68
+ WITH normalized_messages AS (
69
+ SELECT
70
+ CASE WHEN from_me = 1 THEN chat_jid ELSE COALESCE(sender_jid, chat_jid) END AS jid,
71
+ ${contactDisplayNameSql('contacts')} AS contact_name,
72
+ ${cleanDisplayNameSql('CASE WHEN from_me = 1 THEN messages.chat_name ELSE messages.sender_name END')} AS message_name,
73
+ ${cleanDisplayNameSql(`
74
+ COALESCE(
75
+ chats.name,
76
+ 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
78
+ THEN messages.chat_name
79
+ ELSE NULL
80
+ END
81
+ )
82
+ `)} AS direct_chat_name,
83
+ from_me,
84
+ ts,
85
+ media_type
86
+ 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')}
89
+ ),
90
+ 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
92
+ FROM contacts
93
+ UNION ALL
94
+ SELECT jid, contact_name, message_name, direct_chat_name, from_me, ts, media_type FROM normalized_messages
95
+ )
96
+ SELECT
97
+ jid,
98
+ COALESCE(MAX(message_name), MAX(direct_chat_name), MAX(contact_name), ${cleanDisplayNameSql('jid')}, 'Unknown') AS name,
99
+ SUM(CASE WHEN ts IS NOT NULL THEN 1 ELSE 0 END) AS messageCount,
100
+ SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS mediaCount,
101
+ SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
102
+ SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS sentByThem,
103
+ MAX(ts) AS lastMessageTs
104
+ FROM all_people
105
+ WHERE jid IS NOT NULL AND jid <> ''
106
+ GROUP BY jid
107
+ ORDER BY messageCount DESC, name COLLATE NOCASE ASC
108
+ LIMIT @limit OFFSET @offset
109
+ `).all({ limit, offset });
110
+ return asPagination(rows.map((row) => ({
111
+ jid: row.jid,
112
+ name: row.name ?? row.jid,
113
+ messageCount: row.messageCount ?? 0,
114
+ mediaCount: row.mediaCount ?? 0,
115
+ sentByMe: row.sentByMe ?? 0,
116
+ sentByThem: row.sentByThem ?? 0,
117
+ lastMessageAt: unixSecondsToIso(row.lastMessageTs),
118
+ })), limit, offset, total);
119
+ }
120
+ export function getChats(params, db = getDb()) {
121
+ const limit = parseLimit(params.limit, 50, 200);
122
+ 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;
126
+ const rows = db.prepare(`
127
+ WITH chat_page AS (
128
+ SELECT
129
+ chats.jid,
130
+ chats.kind,
131
+ chats.name,
132
+ chats.last_message_at
133
+ FROM chats
134
+ ${where}
135
+ ORDER BY COALESCE(chats.last_message_at, 0) DESC, chats.name COLLATE NOCASE ASC
136
+ LIMIT @limit OFFSET @offset
137
+ ),
138
+ message_stats AS (
139
+ SELECT
140
+ chat_jid,
141
+ ${cleanDisplayNameSql('MAX(chat_name)')} AS message_name,
142
+ COUNT(rowid) AS messageCount,
143
+ SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS mediaCount,
144
+ COUNT(DISTINCT CASE WHEN from_me = 0 THEN sender_jid END) AS participantCount,
145
+ MAX(ts) AS lastMessageTs
146
+ FROM messages
147
+ WHERE chat_jid IN (SELECT jid FROM chat_page)
148
+ GROUP BY chat_jid
149
+ ),
150
+ latest AS (
151
+ SELECT messages.chat_jid, messages.text, messages.media_type AS lastMessageMediaType
152
+ FROM messages
153
+ JOIN message_stats ON message_stats.chat_jid = messages.chat_jid
154
+ WHERE messages.rowid = (
155
+ SELECT latest_message.rowid
156
+ FROM messages AS latest_message
157
+ WHERE latest_message.chat_jid = messages.chat_jid
158
+ ORDER BY latest_message.ts DESC, latest_message.rowid DESC
159
+ LIMIT 1
160
+ )
161
+ )
162
+ SELECT
163
+ chat_page.jid,
164
+ chat_page.kind,
165
+ COALESCE(${cleanDisplayNameSql('chat_page.name')}, message_stats.message_name, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('chat_page.jid')}, 'Unknown') AS name,
166
+ message_stats.messageCount,
167
+ message_stats.mediaCount,
168
+ message_stats.participantCount,
169
+ message_stats.lastMessageTs,
170
+ latest.text AS lastMessageText,
171
+ latest.lastMessageMediaType AS lastMessageMediaType
172
+ FROM chat_page
173
+ LEFT JOIN message_stats ON message_stats.chat_jid = chat_page.jid
174
+ LEFT JOIN latest ON latest.chat_jid = chat_page.jid
175
+ LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'chat_page.jid')}
176
+ ORDER BY COALESCE(message_stats.lastMessageTs, chat_page.last_message_at, 0) DESC, name COLLATE NOCASE ASC
177
+ `).all({ kind, limit, offset });
178
+ return asPagination(rows.map((row) => ({
179
+ jid: row.jid,
180
+ kind: row.kind,
181
+ name: row.name ?? row.jid,
182
+ messageCount: row.messageCount ?? 0,
183
+ mediaCount: row.mediaCount ?? 0,
184
+ participantCount: row.participantCount ?? 0,
185
+ lastMessageAt: unixSecondsToIso(row.lastMessageTs),
186
+ lastMessageText: row.lastMessageText,
187
+ lastMessageMediaType: row.lastMessageMediaType,
188
+ })), limit, offset, total);
189
+ }
190
+ export function getChatMessages(chatJid, params, db = getDb()) {
191
+ const limit = parseLimit(params.limit, 50, 200);
192
+ const offset = parseOffset(params.offset);
193
+ const total = db.prepare('SELECT COUNT(*) AS count FROM messages WHERE chat_jid = @chatJid').get({ chatJid }).count;
194
+ const rows = db.prepare(`
195
+ SELECT
196
+ messages.rowid AS id,
197
+ msg_id AS msgId,
198
+ chat_jid AS chatJid,
199
+ COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
200
+ sender_jid AS senderJid,
201
+ COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
202
+ ts,
203
+ from_me AS fromMe,
204
+ text,
205
+ message_type AS messageType,
206
+ media_type AS mediaType,
207
+ media_path AS mediaPath
208
+ FROM messages
209
+ 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')}
212
+ WHERE chat_jid = @chatJid
213
+ ORDER BY ts DESC, messages.rowid DESC
214
+ LIMIT @limit OFFSET @offset
215
+ `).all({ chatJid, limit, offset });
216
+ return asPagination(rows.map(mapMessage), limit, offset, total);
217
+ }
218
+ export function getMediaItems(params, db = getDb()) {
219
+ const limit = parseLimit(params.limit, 60, 200);
220
+ const offset = parseOffset(params.offset);
221
+ const type = typeof params.type === 'string' && params.type.trim() ? params.type.trim() : undefined;
222
+ const hasMediaPath = 'media_path IS NOT NULL AND TRIM(media_path) <> \'\'';
223
+ const where = type
224
+ ? `media_type = @type AND ${hasMediaPath}`
225
+ : `media_type IS NOT NULL AND media_type <> '' AND ${hasMediaPath}`;
226
+ const total = db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE ${where}`).get({ type }).count;
227
+ const rows = db.prepare(`
228
+ SELECT
229
+ messages.rowid AS id,
230
+ chat_jid AS chatJid,
231
+ COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('messages.chat_name')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('messages.chat_jid')}, 'Unknown') AS chatName,
232
+ sender_jid AS senderJid,
233
+ COALESCE(${cleanDisplayNameSql('messages.sender_name')}, ${contactDisplayNameSql('sender_contacts')}) AS senderName,
234
+ ts,
235
+ from_me AS fromMe,
236
+ text,
237
+ message_type AS messageType,
238
+ media_type AS mediaType,
239
+ media_path AS mediaPath,
240
+ media_size AS mediaSize
241
+ FROM messages
242
+ 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')}
245
+ WHERE ${where}
246
+ ORDER BY ts DESC, messages.rowid DESC
247
+ LIMIT @limit OFFSET @offset
248
+ `).all({ type, limit, offset });
249
+ return asPagination(rows.map((row) => ({
250
+ id: row.id,
251
+ chatJid: row.chatJid,
252
+ chatName: row.chatName ?? row.chatJid,
253
+ senderJid: row.senderJid,
254
+ senderName: row.senderName,
255
+ sentAt: unixSecondsToIso(row.ts) ?? new Date(0).toISOString(),
256
+ fromMe: row.fromMe === 1,
257
+ text: row.text,
258
+ mediaType: row.mediaType,
259
+ mediaPath: row.mediaPath,
260
+ mediaSize: row.mediaSize,
261
+ fileUrl: `/api/media/file?path=${encodeURIComponent(row.mediaPath)}`,
262
+ })), limit, offset, total);
263
+ }
264
+ export function searchMessages(params, db = getDb()) {
265
+ const query = typeof params.q === 'string' ? params.q.trim() : '';
266
+ const limit = parseLimit(params.limit, 50, 100);
267
+ const offset = parseOffset(params.offset);
268
+ if (query.length < 2) {
269
+ return asPagination([], limit, offset, 0);
270
+ }
271
+ const ftsQuery = buildFtsQuery(query);
272
+ if (ftsQuery && hasMessageSearchIndex(db)) {
273
+ try {
274
+ const total = db.prepare(`
275
+ SELECT COUNT(*) AS count
276
+ FROM ${MESSAGE_SEARCH_FTS_TABLE}
277
+ WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
278
+ `).get({ ftsQuery }).count;
279
+ const rows = db.prepare(`
280
+ WITH matched AS (
281
+ SELECT
282
+ rowid,
283
+ snippet(${MESSAGE_SEARCH_FTS_TABLE}, -1, '', '', '...', 20) AS snippet
284
+ FROM ${MESSAGE_SEARCH_FTS_TABLE}
285
+ WHERE ${MESSAGE_SEARCH_FTS_TABLE} MATCH @ftsQuery
286
+ )
287
+ 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,
300
+ matched.snippet AS snippet
301
+ FROM matched
302
+ 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')}
306
+ ORDER BY ts DESC, messages.rowid DESC
307
+ LIMIT @limit OFFSET @offset
308
+ `).all({ ftsQuery, limit, offset });
309
+ return formatSearchResults(rows, query, limit, offset, total);
310
+ }
311
+ catch {
312
+ // If an older archive has an incompatible FTS table, keep search working.
313
+ }
314
+ }
315
+ const like = `%${escapeLike(query)}%`;
316
+ const where = `
317
+ (messages.text LIKE @like ESCAPE '\\'
318
+ OR messages.chat_name LIKE @like ESCAPE '\\'
319
+ OR messages.sender_name LIKE @like ESCAPE '\\'
320
+ OR chats.name LIKE @like ESCAPE '\\'
321
+ OR ${contactDisplayNameSql('chat_contacts')} LIKE @like ESCAPE '\\'
322
+ OR ${contactDisplayNameSql('sender_contacts')} LIKE @like ESCAPE '\\')
323
+ `;
324
+ const total = db.prepare(`
325
+ SELECT COUNT(*) AS count
326
+ 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')}
330
+ WHERE ${where}
331
+ `).get({ like }).count;
332
+ 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
346
+ 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')}
350
+ WHERE ${where}
351
+ ORDER BY ts DESC, messages.rowid DESC
352
+ LIMIT @limit OFFSET @offset
353
+ `).all({ like, limit, offset });
354
+ return formatSearchResults(rows, query, limit, offset, total);
355
+ }
356
+ export const dataRouter = Router();
357
+ dataRouter.get('/people', (req, res) => {
358
+ res.json(getPeople(req.query));
359
+ });
360
+ dataRouter.get('/chats', (req, res) => {
361
+ res.json(getChats(req.query));
362
+ });
363
+ dataRouter.get('/chats/:jid/messages', (req, res) => {
364
+ res.json(getChatMessages(req.params.jid, req.query));
365
+ });
366
+ dataRouter.get('/media', (req, res) => {
367
+ res.json(getMediaItems(req.query));
368
+ });
369
+ dataRouter.get('/media/file', (req, res, next) => {
370
+ const mediaPath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
371
+ if (!mediaPath) {
372
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'path is required.' } });
373
+ return;
374
+ }
375
+ const row = getDb().prepare(`
376
+ SELECT media_path AS mediaPath
377
+ FROM messages
378
+ WHERE media_path = @mediaPath AND media_type IS NOT NULL AND media_type <> ''
379
+ LIMIT 1
380
+ `).get({ mediaPath });
381
+ if (!row) {
382
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Media file is not indexed in this archive.' } });
383
+ return;
384
+ }
385
+ let absolutePath;
386
+ try {
387
+ absolutePath = resolveArchiveMediaPath(row.mediaPath);
388
+ }
389
+ catch {
390
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid media path.' } });
391
+ return;
392
+ }
393
+ res.sendFile(absolutePath, (err) => {
394
+ if (!err)
395
+ return;
396
+ const errCode = err.code;
397
+ if (errCode === 'ENOENT') {
398
+ res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Media file was not found on disk.' } });
399
+ return;
400
+ }
401
+ if (errCode === 'EACCES' || errCode === 'EPERM') {
402
+ res.status(403).json({
403
+ error: {
404
+ code: 'PERMISSION_DENIED',
405
+ message: 'Cannot read media file — permission denied. ' +
406
+ 'On macOS, the process running the API server (Terminal, iTerm, etc.) needs ' +
407
+ 'Full Disk Access: System Settings → Privacy & Security → Full Disk Access.',
408
+ },
409
+ });
410
+ return;
411
+ }
412
+ next(err);
413
+ });
414
+ });
415
+ dataRouter.get('/search', (req, res) => {
416
+ res.json(searchMessages(req.query));
417
+ });
@@ -0,0 +1,38 @@
1
+ import { Router } from 'express';
2
+ import { closeDb } from '../db.js';
3
+ import { clearDashboardPathsOverride, mergePathPatch, readDashboardPathsOverride, resolvePathsFromEnv, resolvePathsForRequest, writeDashboardPathsOverride, } from '../pathsResolve.js';
4
+ export const settingsRouter = Router();
5
+ settingsRouter.get('/paths', (req, res) => {
6
+ const envDefaults = resolvePathsFromEnv();
7
+ const storedOverride = readDashboardPathsOverride();
8
+ const effective = resolvePathsForRequest(req.headers);
9
+ res.json({
10
+ envDefaults,
11
+ storedOverride,
12
+ effective,
13
+ pathsFile: Boolean(storedOverride && Object.keys(storedOverride).length > 0),
14
+ });
15
+ });
16
+ settingsRouter.post('/paths', (req, res) => {
17
+ const body = req.body;
18
+ if (!body || typeof body !== 'object') {
19
+ res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'JSON body required.' } });
20
+ return;
21
+ }
22
+ const patch = {
23
+ whatsappContainer: typeof body.whatsappContainer === 'string' ? body.whatsappContainer : undefined,
24
+ chatDb: typeof body.chatDb === 'string' ? body.chatDb : undefined,
25
+ contactsDb: typeof body.contactsDb === 'string' ? body.contactsDb : undefined,
26
+ mediaRoot: typeof body.mediaRoot === 'string' ? body.mediaRoot : undefined,
27
+ primaryDb: typeof body.primaryDb === 'string' ? body.primaryDb : undefined,
28
+ };
29
+ writeDashboardPathsOverride(patch);
30
+ closeDb();
31
+ const effective = mergePathPatch(resolvePathsFromEnv(), patch);
32
+ res.json({ ok: true, effective });
33
+ });
34
+ settingsRouter.delete('/paths', (_req, res) => {
35
+ clearDashboardPathsOverride();
36
+ closeDb();
37
+ res.json({ ok: true, effective: resolvePathsFromEnv() });
38
+ });