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.
- package/README.md +133 -0
- package/apps/api/dist/db.js +29 -0
- package/apps/api/dist/index.js +115 -0
- package/apps/api/dist/lib/displayName.js +28 -0
- package/apps/api/dist/lib/mediaFsPath.js +80 -0
- package/apps/api/dist/lib/query.js +71 -0
- package/apps/api/dist/lib/searchIndex.js +57 -0
- package/apps/api/dist/lib/timezone.js +38 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +118 -0
- package/apps/api/dist/pathsResolve.js +142 -0
- package/apps/api/dist/routes/data.js +417 -0
- package/apps/api/dist/routes/settings.js +38 -0
- package/apps/api/dist/routes/stats.js +388 -0
- package/apps/api/dist/runtimePaths.js +10 -0
- package/apps/api/dist/scripts/optimizeDb.js +14 -0
- package/apps/api/dist/types.js +1 -0
- package/apps/web/dist/assets/index-1fGu93mF.css +1 -0
- package/apps/web/dist/assets/index-Dfsoi6vG.js +43 -0
- package/apps/web/dist/index.html +13 -0
- package/bin/wacrawl-dashboard.js +41 -0
- package/package.json +56 -0
|
@@ -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
|
+
});
|