wacrawl-dashboard 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +220 -105
  2. package/apps/api/dist/demo/demoArchive.js +371 -0
  3. package/apps/api/dist/demo/demoCast.js +138 -0
  4. package/apps/api/dist/demo/demoMedia.js +108 -0
  5. package/apps/api/dist/index.js +109 -29
  6. package/apps/api/dist/lib/archiveSchema.js +57 -0
  7. package/apps/api/dist/lib/archiveSync.js +151 -0
  8. package/apps/api/dist/lib/chatKind.js +41 -0
  9. package/apps/api/dist/lib/displayName.js +20 -0
  10. package/apps/api/dist/lib/mediaFsPath.js +27 -0
  11. package/apps/api/dist/lib/percentile.js +23 -0
  12. package/apps/api/dist/lib/query.js +33 -7
  13. package/apps/api/dist/lib/rollupDb.js +253 -0
  14. package/apps/api/dist/lib/searchFilters.js +141 -0
  15. package/apps/api/dist/lib/selfIdentity.js +42 -0
  16. package/apps/api/dist/lib/statsCache.js +83 -0
  17. package/apps/api/dist/lib/timezone.js +102 -0
  18. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  19. package/apps/api/dist/pathsResolve.js +17 -3
  20. package/apps/api/dist/routes/data.js +141 -77
  21. package/apps/api/dist/routes/settings.js +3 -0
  22. package/apps/api/dist/routes/stats/profiles.js +522 -0
  23. package/apps/api/dist/routes/stats/reports.js +555 -0
  24. package/apps/api/dist/routes/stats/shared.js +75 -0
  25. package/apps/api/dist/routes/stats.js +273 -323
  26. package/apps/api/dist/routes/sync.js +60 -0
  27. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  28. package/apps/api/package.json +31 -0
  29. package/apps/web/dist/apple-touch-icon.png +0 -0
  30. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  31. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  32. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  33. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  34. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  35. package/apps/web/dist/favicon-16x16.png +0 -0
  36. package/apps/web/dist/favicon-32x32.png +0 -0
  37. package/apps/web/dist/favicon-512x512.png +0 -0
  38. package/apps/web/dist/favicon.ico +0 -0
  39. package/apps/web/dist/favicon.svg +59 -0
  40. package/apps/web/dist/index.html +11 -2
  41. package/bin/wacrawl-dashboard.js +64 -15
  42. package/package.json +20 -4
  43. package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
  44. package/apps/web/dist/assets/index-DJiLhgJZ.css +0 -1
@@ -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
+ }