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,555 @@
|
|
|
1
|
+
import { getDb } from '../../db.js';
|
|
2
|
+
import { chatKindSql, columnExists, parseChatKind } from '../../lib/chatKind.js';
|
|
3
|
+
import { canonicalJidSql, cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../../lib/displayName.js';
|
|
4
|
+
import { parseLimit, parsePeriod, parseYear, scanCoverage, sinceTimestamp, unixSecondsToIso, yearBounds, } from '../../lib/query.js';
|
|
5
|
+
import { dateInTimezone, resolveStatsTimeZone, tsModifierSql } from '../../lib/timezone.js';
|
|
6
|
+
import { categorizeDomain, extractUrls, parseDomain } from '../../lib/urlExtract.js';
|
|
7
|
+
import { DAY_SECONDS, DAYS_PER_MONTH, EMOJI_REGEX, isNoiseToken, topCounted, WORD_TOKEN_RE, } from './shared.js';
|
|
8
|
+
/**
|
|
9
|
+
* Contacts who used to be part of the daily traffic and are not any more.
|
|
10
|
+
*/
|
|
11
|
+
export function getDormancy(params, db = getDb()) {
|
|
12
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
13
|
+
const recentDays = parseLimit(params.recentDays, 90, 3650);
|
|
14
|
+
const baselineDays = Math.max(parseLimit(params.baselineDays, 730, 36_500), recentDays + 1);
|
|
15
|
+
const minBaselineMessages = parseLimit(params.minBaselineMessages, 20, 100_000);
|
|
16
|
+
const kind = parseChatKind(params.kind, 'direct');
|
|
17
|
+
const kindFilter = `AND ${chatKindSql(kind)}`;
|
|
18
|
+
const now = Math.floor(Date.now() / 1000);
|
|
19
|
+
const recentStart = now - recentDays * DAY_SECONDS;
|
|
20
|
+
const baselineStart = now - baselineDays * DAY_SECONDS;
|
|
21
|
+
const rows = db.prepare(`
|
|
22
|
+
SELECT
|
|
23
|
+
chats.jid AS jid,
|
|
24
|
+
COALESCE(
|
|
25
|
+
${cleanDisplayNameSql('chats.name')},
|
|
26
|
+
${cleanDisplayNameSql('MAX(messages.chat_name)')},
|
|
27
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
28
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
29
|
+
'Unknown'
|
|
30
|
+
) AS name,
|
|
31
|
+
SUM(CASE WHEN messages.ts < @recentStart THEN 1 ELSE 0 END) AS baselineCount,
|
|
32
|
+
SUM(CASE WHEN messages.ts >= @recentStart THEN 1 ELSE 0 END) AS recentCount,
|
|
33
|
+
MAX(messages.ts) AS lastTs
|
|
34
|
+
FROM chats
|
|
35
|
+
JOIN messages ON messages.chat_jid = chats.jid
|
|
36
|
+
${contactLeftJoins('chat_contacts', 'chats.jid')}
|
|
37
|
+
WHERE messages.ts >= @baselineStart
|
|
38
|
+
${kindFilter}
|
|
39
|
+
GROUP BY chats.jid
|
|
40
|
+
HAVING baselineCount >= @minBaselineMessages
|
|
41
|
+
`).all({ recentStart, baselineStart, minBaselineMessages });
|
|
42
|
+
const baselineMonths = (baselineDays - recentDays) / DAYS_PER_MONTH;
|
|
43
|
+
const recentMonths = recentDays / DAYS_PER_MONTH;
|
|
44
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
45
|
+
const contacts = rows
|
|
46
|
+
.map((row) => {
|
|
47
|
+
const baselinePerMonth = row.baselineCount / baselineMonths;
|
|
48
|
+
const recentPerMonth = row.recentCount / recentMonths;
|
|
49
|
+
return {
|
|
50
|
+
jid: row.jid,
|
|
51
|
+
name: row.name ?? row.jid,
|
|
52
|
+
baselineCount: row.baselineCount,
|
|
53
|
+
baselinePerMonth: round2(baselinePerMonth),
|
|
54
|
+
recentCount: row.recentCount,
|
|
55
|
+
recentPerMonth: round2(recentPerMonth),
|
|
56
|
+
dropRatio: round2(1 - recentPerMonth / baselinePerMonth),
|
|
57
|
+
lastMessageAt: unixSecondsToIso(row.lastTs),
|
|
58
|
+
daysSinceLastMessage: Math.floor((now - row.lastTs) / DAY_SECONDS),
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
.filter((contact) => contact.dropRatio > 0)
|
|
62
|
+
.sort((a, b) => b.dropRatio - a.dropRatio || b.baselinePerMonth - a.baselinePerMonth)
|
|
63
|
+
.slice(0, limit);
|
|
64
|
+
return { baselineDays, recentDays, minBaselineMessages, contacts };
|
|
65
|
+
}
|
|
66
|
+
/** Text messages of one year scanned for words and emoji. */
|
|
67
|
+
const YEAR_IN_REVIEW_MAX_ROWS = 60_000;
|
|
68
|
+
/** Longest run of consecutive dates in an ascending list of YYYY-MM-DD strings. */
|
|
69
|
+
function longestConsecutiveRun(dates) {
|
|
70
|
+
let longest = 0;
|
|
71
|
+
let run = 0;
|
|
72
|
+
let previous = null;
|
|
73
|
+
for (const date of dates) {
|
|
74
|
+
const time = Date.parse(`${date}T00:00:00.000Z`);
|
|
75
|
+
run = previous != null && time - previous === DAY_SECONDS * 1000 ? run + 1 : 1;
|
|
76
|
+
longest = Math.max(longest, run);
|
|
77
|
+
previous = time;
|
|
78
|
+
}
|
|
79
|
+
return longest;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One calendar year assembled into a single page.
|
|
83
|
+
*/
|
|
84
|
+
export function getYearInReview(params, db = getDb()) {
|
|
85
|
+
const year = parseYear(params.year);
|
|
86
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
87
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
88
|
+
const bounds = yearBounds(year, timeZone);
|
|
89
|
+
const previousBounds = yearBounds(year - 1, timeZone);
|
|
90
|
+
const tsSql = tsModifierSql(timeZone);
|
|
91
|
+
const totalsRow = db.prepare(`
|
|
92
|
+
SELECT
|
|
93
|
+
COUNT(*) AS messages,
|
|
94
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
95
|
+
SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS media,
|
|
96
|
+
COUNT(DISTINCT chat_jid) AS chats
|
|
97
|
+
FROM messages
|
|
98
|
+
WHERE ts >= @start AND ts < @end
|
|
99
|
+
`).get(bounds);
|
|
100
|
+
const dailyRows = db.prepare(`
|
|
101
|
+
SELECT date(${tsSql}) AS date, COUNT(*) AS count
|
|
102
|
+
FROM messages
|
|
103
|
+
WHERE ts >= @start AND ts < @end
|
|
104
|
+
GROUP BY date
|
|
105
|
+
ORDER BY date ASC
|
|
106
|
+
`).all(bounds);
|
|
107
|
+
const monthlyRows = db.prepare(`
|
|
108
|
+
SELECT
|
|
109
|
+
strftime('%Y-%m', ${tsSql}) AS month,
|
|
110
|
+
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
111
|
+
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS received
|
|
112
|
+
FROM messages
|
|
113
|
+
WHERE ts >= @start AND ts < @end
|
|
114
|
+
GROUP BY month
|
|
115
|
+
ORDER BY month ASC
|
|
116
|
+
`).all(bounds);
|
|
117
|
+
const hourRows = db.prepare(`
|
|
118
|
+
SELECT CAST(strftime('%H', ${tsSql}) AS INTEGER) AS hour, COUNT(*) AS count
|
|
119
|
+
FROM messages
|
|
120
|
+
WHERE ts >= @start AND ts < @end
|
|
121
|
+
GROUP BY hour
|
|
122
|
+
ORDER BY hour ASC
|
|
123
|
+
`).all(bounds);
|
|
124
|
+
const topChats = db.prepare(`
|
|
125
|
+
SELECT
|
|
126
|
+
chats.jid AS jid,
|
|
127
|
+
chats.kind AS kind,
|
|
128
|
+
COALESCE(
|
|
129
|
+
${cleanDisplayNameSql('chats.name')},
|
|
130
|
+
${cleanDisplayNameSql('MAX(messages.chat_name)')},
|
|
131
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
132
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
133
|
+
'Unknown'
|
|
134
|
+
) AS name,
|
|
135
|
+
COUNT(*) AS messageCount
|
|
136
|
+
FROM messages
|
|
137
|
+
JOIN chats ON chats.jid = messages.chat_jid
|
|
138
|
+
${contactLeftJoins('chat_contacts', 'chats.jid')}
|
|
139
|
+
WHERE messages.ts >= @start AND messages.ts < @end
|
|
140
|
+
GROUP BY chats.jid
|
|
141
|
+
ORDER BY messageCount DESC, name COLLATE NOCASE ASC
|
|
142
|
+
LIMIT @limit
|
|
143
|
+
`).all({ ...bounds, limit })
|
|
144
|
+
.map((row) => ({ ...row, name: row.name ?? row.jid }));
|
|
145
|
+
const newContacts = db.prepare(`
|
|
146
|
+
SELECT
|
|
147
|
+
first_seen.chat_jid AS jid,
|
|
148
|
+
COALESCE(
|
|
149
|
+
${cleanDisplayNameSql('chats.name')},
|
|
150
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
151
|
+
${cleanDisplayNameSql('first_seen.chat_jid')},
|
|
152
|
+
'Unknown'
|
|
153
|
+
) AS name,
|
|
154
|
+
first_seen.firstTs AS firstTs,
|
|
155
|
+
first_seen.yearCount AS messageCount
|
|
156
|
+
FROM (
|
|
157
|
+
SELECT
|
|
158
|
+
chat_jid,
|
|
159
|
+
MIN(ts) AS firstTs,
|
|
160
|
+
SUM(CASE WHEN ts >= @start AND ts < @end THEN 1 ELSE 0 END) AS yearCount
|
|
161
|
+
FROM messages
|
|
162
|
+
GROUP BY chat_jid
|
|
163
|
+
) first_seen
|
|
164
|
+
LEFT JOIN chats ON chats.jid = first_seen.chat_jid
|
|
165
|
+
${contactLeftJoins('chat_contacts', 'first_seen.chat_jid')}
|
|
166
|
+
WHERE first_seen.firstTs >= @start AND first_seen.firstTs < @end
|
|
167
|
+
ORDER BY messageCount DESC, firstTs ASC
|
|
168
|
+
LIMIT @limit
|
|
169
|
+
`).all({ ...bounds, limit })
|
|
170
|
+
.map((row) => ({
|
|
171
|
+
jid: row.jid,
|
|
172
|
+
name: row.name ?? row.jid,
|
|
173
|
+
firstMessageAt: unixSecondsToIso(row.firstTs) ?? '',
|
|
174
|
+
messageCount: row.messageCount,
|
|
175
|
+
}));
|
|
176
|
+
const mediaBreakdown = db.prepare(`
|
|
177
|
+
SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
|
|
178
|
+
FROM messages
|
|
179
|
+
WHERE ts >= @start AND ts < @end AND media_type IS NOT NULL AND media_type <> ''
|
|
180
|
+
GROUP BY media_type
|
|
181
|
+
ORDER BY count DESC, mediaType ASC
|
|
182
|
+
`).all(bounds).map((row) => ({
|
|
183
|
+
mediaType: row.mediaType,
|
|
184
|
+
count: row.count,
|
|
185
|
+
totalBytes: row.totalBytes ?? 0,
|
|
186
|
+
}));
|
|
187
|
+
const textRows = db.prepare(`
|
|
188
|
+
SELECT text
|
|
189
|
+
FROM messages
|
|
190
|
+
WHERE ts >= @start AND ts < @end AND text IS NOT NULL AND TRIM(text) <> ''
|
|
191
|
+
ORDER BY ts DESC
|
|
192
|
+
LIMIT ${YEAR_IN_REVIEW_MAX_ROWS}
|
|
193
|
+
`).all(bounds);
|
|
194
|
+
const wordCounts = new Map();
|
|
195
|
+
const emojiCounts = new Map();
|
|
196
|
+
for (const row of textRows) {
|
|
197
|
+
const text = row.text ?? '';
|
|
198
|
+
for (const term of text.toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
199
|
+
if (isNoiseToken(term))
|
|
200
|
+
continue;
|
|
201
|
+
wordCounts.set(term, (wordCounts.get(term) ?? 0) + 1);
|
|
202
|
+
}
|
|
203
|
+
for (const emoji of text.match(EMOJI_REGEX) ?? []) {
|
|
204
|
+
emojiCounts.set(emoji, (emojiCounts.get(emoji) ?? 0) + 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const previousCount = db.prepare(`
|
|
208
|
+
SELECT COUNT(*) AS count FROM messages WHERE ts >= @start AND ts < @end
|
|
209
|
+
`).get(previousBounds).count;
|
|
210
|
+
const monthlyVolume = monthlyRows.map((row) => ({
|
|
211
|
+
month: row.month,
|
|
212
|
+
sent: row.sent ?? 0,
|
|
213
|
+
received: row.received ?? 0,
|
|
214
|
+
}));
|
|
215
|
+
const busiestDay = dailyRows.reduce((best, row) => (best === null || row.count > best.count ? row : best), null);
|
|
216
|
+
const busiestMonth = monthlyVolume.reduce((best, row) => {
|
|
217
|
+
const count = row.sent + row.received;
|
|
218
|
+
return best === null || count > best.count ? { month: row.month, count } : best;
|
|
219
|
+
}, null);
|
|
220
|
+
return {
|
|
221
|
+
year,
|
|
222
|
+
totals: {
|
|
223
|
+
messages: totalsRow.messages,
|
|
224
|
+
sent: totalsRow.sent ?? 0,
|
|
225
|
+
received: totalsRow.messages - (totalsRow.sent ?? 0),
|
|
226
|
+
media: totalsRow.media ?? 0,
|
|
227
|
+
chats: totalsRow.chats,
|
|
228
|
+
activeDays: dailyRows.length,
|
|
229
|
+
},
|
|
230
|
+
busiestDay,
|
|
231
|
+
busiestMonth,
|
|
232
|
+
longestStreakDays: longestConsecutiveRun(dailyRows.map((row) => row.date)),
|
|
233
|
+
monthlyVolume,
|
|
234
|
+
hourOfDay: hourRows.filter((row) => row.count > 0),
|
|
235
|
+
topChats,
|
|
236
|
+
newContacts,
|
|
237
|
+
topWords: topCounted(wordCounts, limit, (text, value) => ({ text, value })),
|
|
238
|
+
topEmojis: topCounted(emojiCounts, limit, (emoji, count) => ({ emoji, count })),
|
|
239
|
+
mediaBreakdown,
|
|
240
|
+
previousYear: previousCount === 0
|
|
241
|
+
? null
|
|
242
|
+
: {
|
|
243
|
+
messages: previousCount,
|
|
244
|
+
changeRatio: Math.round(((totalsRow.messages - previousCount) / previousCount) * 100) / 100,
|
|
245
|
+
},
|
|
246
|
+
scan: scanCoverage(textRows.length, YEAR_IN_REVIEW_MAX_ROWS),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Backstop on rows pulled into memory after the link/media prefilter. */
|
|
250
|
+
const LINK_INTELLIGENCE_MAX_ROWS = 100_000;
|
|
251
|
+
export function getLinkIntelligence(params, db = getDb()) {
|
|
252
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
253
|
+
const limit = parseLimit(params.limit, 15, 50);
|
|
254
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
255
|
+
const rows = db.prepare(`
|
|
256
|
+
SELECT text, ts, from_me, media_type, jid, name, contact_name
|
|
257
|
+
FROM (
|
|
258
|
+
SELECT
|
|
259
|
+
messages.rowid,
|
|
260
|
+
messages.text,
|
|
261
|
+
messages.ts,
|
|
262
|
+
messages.from_me,
|
|
263
|
+
messages.media_type,
|
|
264
|
+
${canonicalJidSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')} AS jid,
|
|
265
|
+
${cleanDisplayNameSql(`CASE WHEN messages.from_me = 1 THEN COALESCE(chats.name, messages.chat_name) ELSE messages.sender_name END`)} AS name,
|
|
266
|
+
${contactDisplayNameSql('contacts')} AS contact_name
|
|
267
|
+
FROM messages
|
|
268
|
+
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
|
|
269
|
+
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
270
|
+
WHERE messages.ts >= @since
|
|
271
|
+
AND (
|
|
272
|
+
messages.text LIKE '%http%'
|
|
273
|
+
OR (messages.media_type IS NOT NULL AND messages.media_type <> '')
|
|
274
|
+
)
|
|
275
|
+
ORDER BY messages.ts DESC, messages.rowid DESC
|
|
276
|
+
LIMIT ${LINK_INTELLIGENCE_MAX_ROWS}
|
|
277
|
+
)
|
|
278
|
+
ORDER BY ts ASC, rowid ASC
|
|
279
|
+
`).all({ since });
|
|
280
|
+
const domainCounts = new Map();
|
|
281
|
+
const contactLinks = new Map();
|
|
282
|
+
const contactMedia = new Map();
|
|
283
|
+
const timelineBuckets = new Map();
|
|
284
|
+
let totalLinks = 0;
|
|
285
|
+
for (const row of rows) {
|
|
286
|
+
const urls = extractUrls(row.text);
|
|
287
|
+
const mediaType = row.media_type?.toLowerCase() ?? '';
|
|
288
|
+
const isTimelineMedia = mediaType === 'image' || mediaType === 'video' || mediaType === 'audio';
|
|
289
|
+
if (urls.length === 0 && !mediaType)
|
|
290
|
+
continue;
|
|
291
|
+
const jid = row.jid ?? 'unknown';
|
|
292
|
+
const isPerson = !jid.endsWith('@g.us') && !jid.endsWith('@status') && !jid.endsWith('@broadcast');
|
|
293
|
+
const displayName = row.name ?? row.contact_name ?? jid;
|
|
294
|
+
if (urls.length > 0) {
|
|
295
|
+
totalLinks += urls.length;
|
|
296
|
+
if (isPerson) {
|
|
297
|
+
if (!contactLinks.has(jid)) {
|
|
298
|
+
contactLinks.set(jid, { name: displayName, sent: 0, received: 0 });
|
|
299
|
+
}
|
|
300
|
+
const entry = contactLinks.get(jid);
|
|
301
|
+
if (row.from_me) {
|
|
302
|
+
entry.sent += urls.length;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
entry.received += urls.length;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const url of urls) {
|
|
309
|
+
const domain = parseDomain(url);
|
|
310
|
+
if (domain) {
|
|
311
|
+
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (mediaType && isPerson) {
|
|
316
|
+
if (!contactMedia.has(jid)) {
|
|
317
|
+
contactMedia.set(jid, { name: displayName, mediaSent: 0, mediaReceived: 0 });
|
|
318
|
+
}
|
|
319
|
+
const entry = contactMedia.get(jid);
|
|
320
|
+
if (row.from_me)
|
|
321
|
+
entry.mediaSent++;
|
|
322
|
+
else
|
|
323
|
+
entry.mediaReceived++;
|
|
324
|
+
}
|
|
325
|
+
if (urls.length > 0 || isTimelineMedia) {
|
|
326
|
+
const date = dateInTimezone(row.ts, timeZone);
|
|
327
|
+
let bucket = timelineBuckets.get(date);
|
|
328
|
+
if (!bucket) {
|
|
329
|
+
bucket = { images: 0, videos: 0, audio: 0, links: 0 };
|
|
330
|
+
timelineBuckets.set(date, bucket);
|
|
331
|
+
}
|
|
332
|
+
if (mediaType === 'image')
|
|
333
|
+
bucket.images++;
|
|
334
|
+
else if (mediaType === 'video')
|
|
335
|
+
bucket.videos++;
|
|
336
|
+
else if (mediaType === 'audio')
|
|
337
|
+
bucket.audio++;
|
|
338
|
+
bucket.links += urls.length;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const domainLeaderboard = [...domainCounts.entries()]
|
|
342
|
+
.sort((a, b) => b[1] - a[1])
|
|
343
|
+
.slice(0, limit)
|
|
344
|
+
.map(([domain, count]) => ({ domain, count, category: categorizeDomain(domain) }));
|
|
345
|
+
const uniqueDomains = domainCounts.size;
|
|
346
|
+
const catCounts = new Map();
|
|
347
|
+
for (const [domain, count] of domainCounts) {
|
|
348
|
+
const cat = categorizeDomain(domain);
|
|
349
|
+
if (!catCounts.has(cat)) {
|
|
350
|
+
catCounts.set(cat, { count: 0, domains: new Map() });
|
|
351
|
+
}
|
|
352
|
+
const catEntry = catCounts.get(cat);
|
|
353
|
+
catEntry.count += count;
|
|
354
|
+
catEntry.domains.set(domain, (catEntry.domains.get(domain) ?? 0) + count);
|
|
355
|
+
}
|
|
356
|
+
const linkCategories = [...catCounts.entries()]
|
|
357
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
358
|
+
.map(([category, { count, domains }]) => {
|
|
359
|
+
const topDomain = [...domains.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
360
|
+
return { category: category, count, topDomain };
|
|
361
|
+
});
|
|
362
|
+
const mediaTimeline = [...timelineBuckets.entries()]
|
|
363
|
+
.filter(([, b]) => b.images + b.videos + b.audio + b.links > 0)
|
|
364
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
365
|
+
.map(([date, b]) => ({ date, ...b }));
|
|
366
|
+
const sharingAsymmetry = [...new Set([...contactMedia.keys(), ...contactLinks.keys()])]
|
|
367
|
+
.map((jid) => {
|
|
368
|
+
const media = contactMedia.get(jid) ?? { name: jid, mediaSent: 0, mediaReceived: 0 };
|
|
369
|
+
const links = contactLinks.get(jid) ?? { name: jid, sent: 0, received: 0 };
|
|
370
|
+
const name = media.name !== jid ? media.name : links.name;
|
|
371
|
+
return {
|
|
372
|
+
jid,
|
|
373
|
+
name,
|
|
374
|
+
mediaSent: media.mediaSent,
|
|
375
|
+
mediaReceived: media.mediaReceived,
|
|
376
|
+
linksSent: links.sent,
|
|
377
|
+
linksReceived: links.received,
|
|
378
|
+
};
|
|
379
|
+
})
|
|
380
|
+
.filter((s) => s.mediaSent + s.mediaReceived + s.linksSent + s.linksReceived > 0)
|
|
381
|
+
.sort((a, b) => (b.mediaSent + b.mediaReceived + b.linksSent + b.linksReceived) - (a.mediaSent + a.mediaReceived + a.linksSent + a.linksReceived))
|
|
382
|
+
.slice(0, limit);
|
|
383
|
+
const firstSharedRows = db.prepare(`
|
|
384
|
+
SELECT
|
|
385
|
+
m.chat_jid,
|
|
386
|
+
${cleanDisplayNameSql('COALESCE(chats.name, first_msg.chat_name)')} AS chat_name,
|
|
387
|
+
${contactDisplayNameSql('fcontacts')} AS contact_name,
|
|
388
|
+
first_msg.text AS first_text,
|
|
389
|
+
first_msg.ts AS first_ts,
|
|
390
|
+
first_media.media_type AS first_media_type,
|
|
391
|
+
first_media.ts AS first_media_ts
|
|
392
|
+
FROM (
|
|
393
|
+
SELECT chat_jid, MIN(rowid) AS first_rowid
|
|
394
|
+
FROM messages
|
|
395
|
+
WHERE ts >= @since
|
|
396
|
+
GROUP BY chat_jid
|
|
397
|
+
) m
|
|
398
|
+
LEFT JOIN messages first_msg ON first_msg.rowid = m.first_rowid
|
|
399
|
+
LEFT JOIN chats ON chats.jid = m.chat_jid
|
|
400
|
+
${contactLeftJoins('fcontacts', 'm.chat_jid')}
|
|
401
|
+
LEFT JOIN (
|
|
402
|
+
SELECT chat_jid, MIN(rowid) AS media_rowid
|
|
403
|
+
FROM messages
|
|
404
|
+
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
405
|
+
GROUP BY chat_jid
|
|
406
|
+
) fm ON fm.chat_jid = m.chat_jid
|
|
407
|
+
LEFT JOIN messages first_media ON first_media.rowid = fm.media_rowid
|
|
408
|
+
ORDER BY first_msg.ts ASC
|
|
409
|
+
LIMIT @limit
|
|
410
|
+
`).all({ since, limit });
|
|
411
|
+
const firstShared = firstSharedRows.map((row) => ({
|
|
412
|
+
jid: row.chat_jid,
|
|
413
|
+
name: row.chat_name ?? row.contact_name ?? row.chat_jid,
|
|
414
|
+
firstMessageText: row.first_text,
|
|
415
|
+
firstMessageDate: unixSecondsToIso(row.first_ts) ?? new Date(0).toISOString(),
|
|
416
|
+
firstMediaType: row.first_media_type,
|
|
417
|
+
firstMediaDate: row.first_media_ts ? unixSecondsToIso(row.first_media_ts) : null,
|
|
418
|
+
}));
|
|
419
|
+
return {
|
|
420
|
+
domainLeaderboard,
|
|
421
|
+
mediaTimeline,
|
|
422
|
+
sharingAsymmetry,
|
|
423
|
+
linkCategories,
|
|
424
|
+
firstShared,
|
|
425
|
+
totalLinks,
|
|
426
|
+
uniqueDomains,
|
|
427
|
+
scan: scanCoverage(rows.length, LINK_INTELLIGENCE_MAX_ROWS),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** Messages scanned for distinctive words; the newest ones, when the archive is larger. */
|
|
431
|
+
const DISTINCTIVE_WORDS_MAX_ROWS = 60_000;
|
|
432
|
+
/** A word must turn up this often on its own side before it can represent it. */
|
|
433
|
+
const DISTINCTIVE_MIN_SIDE_COUNT = 3;
|
|
434
|
+
/** |z| above this is the conventional 95% line; below it the lean is noise. */
|
|
435
|
+
const DISTINCTIVE_MIN_Z = 1.96;
|
|
436
|
+
/**
|
|
437
|
+
* Words that are characteristically yours, and characteristically everyone else's.
|
|
438
|
+
*/
|
|
439
|
+
export function getDistinctiveWords(params, db = getDb()) {
|
|
440
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
441
|
+
const limit = parseLimit(params.limit, 12, 50);
|
|
442
|
+
const rows = db.prepare(`
|
|
443
|
+
SELECT text, from_me
|
|
444
|
+
FROM messages
|
|
445
|
+
WHERE ts >= @since AND text IS NOT NULL AND TRIM(text) <> ''
|
|
446
|
+
ORDER BY ts DESC
|
|
447
|
+
LIMIT ${DISTINCTIVE_WORDS_MAX_ROWS}
|
|
448
|
+
`).all({ since });
|
|
449
|
+
const mineCounts = new Map();
|
|
450
|
+
const theirCounts = new Map();
|
|
451
|
+
let mineTotal = 0;
|
|
452
|
+
let theirTotal = 0;
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
const counts = row.from_me ? mineCounts : theirCounts;
|
|
455
|
+
for (const term of row.text.toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
456
|
+
if (isNoiseToken(term))
|
|
457
|
+
continue;
|
|
458
|
+
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
459
|
+
if (row.from_me)
|
|
460
|
+
mineTotal += 1;
|
|
461
|
+
else
|
|
462
|
+
theirTotal += 1;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const scan = scanCoverage(rows.length, DISTINCTIVE_WORDS_MAX_ROWS);
|
|
466
|
+
if (mineTotal === 0 || theirTotal === 0)
|
|
467
|
+
return { mine: [], theirs: [], scan };
|
|
468
|
+
const priorTotal = mineTotal + theirTotal;
|
|
469
|
+
const scored = [];
|
|
470
|
+
for (const text of new Set([...mineCounts.keys(), ...theirCounts.keys()])) {
|
|
471
|
+
const mine = mineCounts.get(text) ?? 0;
|
|
472
|
+
const theirs = theirCounts.get(text) ?? 0;
|
|
473
|
+
const prior = mine + theirs;
|
|
474
|
+
const delta = Math.log((mine + prior) / (mineTotal + priorTotal - mine - prior))
|
|
475
|
+
- Math.log((theirs + prior) / (theirTotal + priorTotal - theirs - prior));
|
|
476
|
+
const variance = 1 / (mine + prior) + 1 / (theirs + prior);
|
|
477
|
+
scored.push({ text, mine, theirs, score: Math.round((delta / Math.sqrt(variance)) * 100) / 100 });
|
|
478
|
+
}
|
|
479
|
+
const byText = (a, b) => a.text.localeCompare(b.text);
|
|
480
|
+
return {
|
|
481
|
+
mine: scored
|
|
482
|
+
.filter((term) => term.score >= DISTINCTIVE_MIN_Z && term.mine >= DISTINCTIVE_MIN_SIDE_COUNT)
|
|
483
|
+
.sort((a, b) => b.score - a.score || byText(a, b))
|
|
484
|
+
.slice(0, limit),
|
|
485
|
+
theirs: scored
|
|
486
|
+
.filter((term) => term.score <= -DISTINCTIVE_MIN_Z && term.theirs >= DISTINCTIVE_MIN_SIDE_COUNT)
|
|
487
|
+
.sort((a, b) => a.score - b.score || byText(a, b))
|
|
488
|
+
.slice(0, limit),
|
|
489
|
+
scan,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Chats you archived that did not stay archived in spirit.
|
|
494
|
+
*/
|
|
495
|
+
export function getArchivedChats(params, db = getDb()) {
|
|
496
|
+
const windowDays = parseLimit(params.windowDays, 30, 3650);
|
|
497
|
+
const limit = parseLimit(params.limit, 8, 50);
|
|
498
|
+
if (!columnExists(db, 'chats', 'archived')) {
|
|
499
|
+
return {
|
|
500
|
+
available: false,
|
|
501
|
+
windowDays,
|
|
502
|
+
totalChats: 0,
|
|
503
|
+
archivedChats: 0,
|
|
504
|
+
archivedDirect: 0,
|
|
505
|
+
archivedGroups: 0,
|
|
506
|
+
stillActiveCount: 0,
|
|
507
|
+
stillActive: [],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const totals = db.prepare(`
|
|
511
|
+
SELECT
|
|
512
|
+
COUNT(*) AS totalChats,
|
|
513
|
+
COALESCE(SUM(archived <> 0), 0) AS archivedChats,
|
|
514
|
+
COALESCE(SUM(archived <> 0 AND ${chatKindSql('direct', 'kind')}), 0) AS archivedDirect,
|
|
515
|
+
COALESCE(SUM(archived <> 0 AND ${chatKindSql('group', 'kind')}), 0) AS archivedGroups
|
|
516
|
+
FROM chats
|
|
517
|
+
WHERE ${chatKindSql('all', 'kind')}
|
|
518
|
+
`).get();
|
|
519
|
+
const since = Math.floor(Date.now() / 1000) - windowDays * DAY_SECONDS;
|
|
520
|
+
const rows = db.prepare(`
|
|
521
|
+
SELECT
|
|
522
|
+
chats.jid AS jid,
|
|
523
|
+
chats.kind AS kind,
|
|
524
|
+
COALESCE(
|
|
525
|
+
${cleanDisplayNameSql('chats.name')},
|
|
526
|
+
${cleanDisplayNameSql('MAX(messages.chat_name)')},
|
|
527
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
528
|
+
${cleanDisplayNameSql('chats.jid')},
|
|
529
|
+
'Unknown'
|
|
530
|
+
) AS name,
|
|
531
|
+
COUNT(messages.rowid) AS recentMessages,
|
|
532
|
+
COALESCE(SUM(messages.from_me), 0) AS recentFromMe,
|
|
533
|
+
MAX(messages.ts) AS lastTs
|
|
534
|
+
FROM chats
|
|
535
|
+
JOIN messages ON messages.chat_jid = chats.jid AND messages.ts >= @since
|
|
536
|
+
${contactLeftJoins('chat_contacts', 'chats.jid')}
|
|
537
|
+
WHERE chats.archived <> 0 AND ${chatKindSql('all')}
|
|
538
|
+
GROUP BY chats.jid
|
|
539
|
+
ORDER BY recentMessages DESC, lastTs DESC
|
|
540
|
+
`).all({ since });
|
|
541
|
+
return {
|
|
542
|
+
available: true,
|
|
543
|
+
windowDays,
|
|
544
|
+
...totals,
|
|
545
|
+
stillActiveCount: rows.length,
|
|
546
|
+
stillActive: rows.slice(0, limit).map((row) => ({
|
|
547
|
+
jid: row.jid,
|
|
548
|
+
name: row.name ?? row.jid,
|
|
549
|
+
kind: row.kind === 'group' ? 'group' : 'direct',
|
|
550
|
+
recentMessages: row.recentMessages,
|
|
551
|
+
recentFromMe: row.recentFromMe,
|
|
552
|
+
lastMessageAt: unixSecondsToIso(row.lastTs),
|
|
553
|
+
})),
|
|
554
|
+
};
|
|
555
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../../lib/displayName.js';
|
|
2
|
+
import { percentile } from '../../lib/percentile.js';
|
|
3
|
+
import { USEFUL_WORD_STOP_SET } from '../../lib/wordCloudUsefulStopWords.js';
|
|
4
|
+
export const DAY_SECONDS = 86_400;
|
|
5
|
+
export const DAYS_PER_MONTH = 30;
|
|
6
|
+
export const SESSION_GAP_SECONDS = 4 * 3600;
|
|
7
|
+
export const GHOST_WINDOW_SECONDS = 24 * 3600;
|
|
8
|
+
/**
|
|
9
|
+
* Runs of three or more letters or digits, in any script.
|
|
10
|
+
*/
|
|
11
|
+
export const WORD_TOKEN_RE = /[\p{L}\p{N}]{3,}/gu;
|
|
12
|
+
const ALL_DIGITS_RE = /^\p{N}+$/u;
|
|
13
|
+
/**
|
|
14
|
+
* Long letter-and-digit runs are media keys and URL slugs, not words.
|
|
15
|
+
*/
|
|
16
|
+
const OPAQUE_TOKEN_RE = /^(?=.{12,})(?=.*\p{L})(?=.*\p{N})[\p{L}\p{N}]+$/u;
|
|
17
|
+
export const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
|
|
18
|
+
/** True for tokens that carry no meaning in a word cloud. */
|
|
19
|
+
export function isNoiseToken(term) {
|
|
20
|
+
return ALL_DIGITS_RE.test(term) || OPAQUE_TOKEN_RE.test(term) || USEFUL_WORD_STOP_SET.has(term);
|
|
21
|
+
}
|
|
22
|
+
export function summarizeGaps(gaps) {
|
|
23
|
+
const sorted = [...gaps].sort((a, b) => a - b);
|
|
24
|
+
return { median: Math.round(percentile(sorted, 50)), p90: Math.round(percentile(sorted, 90)) };
|
|
25
|
+
}
|
|
26
|
+
export function topCounted(counts, limit, build) {
|
|
27
|
+
return [...counts.entries()]
|
|
28
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
29
|
+
.slice(0, limit)
|
|
30
|
+
.map(([key, count]) => build(key, count));
|
|
31
|
+
}
|
|
32
|
+
export function resolveChatNames(jids, db) {
|
|
33
|
+
const map = new Map();
|
|
34
|
+
if (jids.length === 0)
|
|
35
|
+
return map;
|
|
36
|
+
const stmt = db.prepare(`
|
|
37
|
+
SELECT
|
|
38
|
+
c.jid,
|
|
39
|
+
COALESCE(
|
|
40
|
+
${cleanDisplayNameSql('c.name')},
|
|
41
|
+
${contactDisplayNameSql('chat_contacts')},
|
|
42
|
+
${cleanDisplayNameSql('c.jid')},
|
|
43
|
+
'Unknown'
|
|
44
|
+
) AS name
|
|
45
|
+
FROM chats c
|
|
46
|
+
${contactLeftJoins('chat_contacts', 'c.jid')}
|
|
47
|
+
WHERE c.jid = @jid
|
|
48
|
+
`);
|
|
49
|
+
for (const jid of jids) {
|
|
50
|
+
const row = stmt.get({ jid });
|
|
51
|
+
map.set(jid, row?.name ?? jid);
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
/** Not-found is a real answer here, so the route can 404 rather than invent an empty profile. */
|
|
56
|
+
export class ChatNotFoundError extends Error {
|
|
57
|
+
jid;
|
|
58
|
+
code = 'CHAT_NOT_FOUND';
|
|
59
|
+
constructor(jid) {
|
|
60
|
+
super(`No chat found for ${jid}.`);
|
|
61
|
+
this.jid = jid;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Asking for the group report on a one-to-one chat is a routing mistake, not a
|
|
66
|
+
* missing chat, and 404 would send the UI looking for a chat that does exist.
|
|
67
|
+
*/
|
|
68
|
+
export class NotAGroupError extends Error {
|
|
69
|
+
jid;
|
|
70
|
+
code = 'NOT_A_GROUP';
|
|
71
|
+
constructor(jid) {
|
|
72
|
+
super(`${jid} is not a group chat.`);
|
|
73
|
+
this.jid = jid;
|
|
74
|
+
}
|
|
75
|
+
}
|