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.
- package/README.md +220 -89
- 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 +37 -3
- 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/searchIndex.js +3 -3
- 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 +120 -5
- package/apps/api/dist/lib/urlExtract.js +111 -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 +168 -78
- 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 +486 -97
- 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 +25 -4
- package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
- 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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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,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 =
|
|
88
|
-
|
|
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
|
|
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
|
|
124
|
-
const
|
|
125
|
-
const
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|