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.
Files changed (46) hide show
  1. package/README.md +220 -89
  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 +37 -3
  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/searchIndex.js +3 -3
  16. package/apps/api/dist/lib/selfIdentity.js +42 -0
  17. package/apps/api/dist/lib/statsCache.js +83 -0
  18. package/apps/api/dist/lib/timezone.js +120 -5
  19. package/apps/api/dist/lib/urlExtract.js +111 -0
  20. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  21. package/apps/api/dist/pathsResolve.js +17 -3
  22. package/apps/api/dist/routes/data.js +168 -78
  23. package/apps/api/dist/routes/settings.js +3 -0
  24. package/apps/api/dist/routes/stats/profiles.js +522 -0
  25. package/apps/api/dist/routes/stats/reports.js +555 -0
  26. package/apps/api/dist/routes/stats/shared.js +75 -0
  27. package/apps/api/dist/routes/stats.js +486 -97
  28. package/apps/api/dist/routes/sync.js +60 -0
  29. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  30. package/apps/api/package.json +31 -0
  31. package/apps/web/dist/apple-touch-icon.png +0 -0
  32. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  33. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  34. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  35. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  36. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  37. package/apps/web/dist/favicon-16x16.png +0 -0
  38. package/apps/web/dist/favicon-32x32.png +0 -0
  39. package/apps/web/dist/favicon-512x512.png +0 -0
  40. package/apps/web/dist/favicon.ico +0 -0
  41. package/apps/web/dist/favicon.svg +59 -0
  42. package/apps/web/dist/index.html +11 -2
  43. package/bin/wacrawl-dashboard.js +64 -15
  44. package/package.json +25 -4
  45. package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
  46. package/apps/web/dist/assets/index-Dfsoi6vG.js +0 -43
@@ -0,0 +1,371 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { ARCHIVE_SCHEMA_SQL } from '../lib/archiveSchema.js';
5
+ import { createSearchIndexes } from '../lib/searchIndex.js';
6
+ import { CAPTIONS, DIRECT_PERSONAS, EMOJIS_MINE, EMOJIS_THEIRS, EXTRA_CONTACTS, GROUP_PERSONAS, LINK_INTROS, LINK_TEMPLATES, MY_TEMPLATES, SILENT_GROUP, THEIR_TEMPLATES, TOPICS, } from './demoCast.js';
7
+ import { demoMediaPath, writeDemoMedia } from './demoMedia.js';
8
+ export const DEFAULT_DEMO_SEED = 1815;
9
+ const DAY_SECONDS = 86_400;
10
+ const MONTH_SECONDS = 30 * DAY_SECONDS;
11
+ const SELF_JID = '15550000000@s.whatsapp.net';
12
+ /** Messaging by hour of day: quiet nights, a lunch bump, a long evening. */
13
+ const HOUR_WEIGHTS = [2, 1, 1, 0, 0, 0, 1, 3, 6, 7, 7, 7, 9, 8, 6, 6, 6, 7, 9, 12, 14, 14, 10, 5];
14
+ const NIGHT_OWL_WEIGHTS = HOUR_WEIGHTS.map((weight, hour) => (hour < 5 ? weight + 8 : weight));
15
+ const WEEKDAY_FACTOR = [1.15, 0.85, 0.9, 0.95, 1, 1.1, 1.35];
16
+ const MEDIA_MIX = [
17
+ // type, weight, smallest bytes, largest bytes
18
+ ['image', 55, 80_000, 2_400_000],
19
+ ['audio', 20, 40_000, 900_000],
20
+ ['video', 10, 2_000_000, 40_000_000],
21
+ ['sticker', 10, 12_000, 60_000],
22
+ ['document', 5, 50_000, 6_000_000],
23
+ ];
24
+ const RAW_TYPES = { image: 1, video: 2, audio: 3, document: 8, sticker: 15 };
25
+ /** Small, seedable and the same on every platform, unlike Math.random. */
26
+ function mulberry32(seed) {
27
+ let state = seed >>> 0;
28
+ return () => {
29
+ state = (state + 0x6d2b79f5) >>> 0;
30
+ let t = state;
31
+ t = Math.imul(t ^ (t >>> 15), t | 1);
32
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
33
+ return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
34
+ };
35
+ }
36
+ function pick(rng, items) {
37
+ return items[Math.floor(rng() * items.length)];
38
+ }
39
+ function weightedIndex(rng, weights) {
40
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
41
+ let roll = rng() * total;
42
+ for (let i = 0; i < weights.length; i += 1) {
43
+ roll -= weights[i];
44
+ if (roll < 0)
45
+ return i;
46
+ }
47
+ return weights.length - 1;
48
+ }
49
+ /** Knuth's method; the rates here are all small. */
50
+ function poisson(rng, rate) {
51
+ const limit = Math.exp(-rate);
52
+ let count = 0;
53
+ let product = rng();
54
+ while (product > limit) {
55
+ count += 1;
56
+ product *= rng();
57
+ }
58
+ return count;
59
+ }
60
+ /** Reply gaps are long-tailed: mostly near the median, occasionally hours later. */
61
+ function replyGapSeconds(rng, medianMinutes) {
62
+ const gaussian = Math.sqrt(-2 * Math.log(1 - rng())) * Math.cos(2 * Math.PI * rng());
63
+ const lateFactor = rng() < 0.06 ? 12 : 1;
64
+ return Math.max(5, Math.round(medianMinutes * 60 * Math.exp(0.9 * gaussian) * lateFactor));
65
+ }
66
+ function activityFactor(shape, monthsAgo, startMonthsAgo, stoppedMonthsAgo = 0) {
67
+ const age = 1 - monthsAgo / startMonthsAgo;
68
+ switch (shape) {
69
+ case 'steady': return 1;
70
+ case 'growing': return 0.25 + 1.25 * age;
71
+ case 'fading': return Math.max(0.08, 1.4 - 1.35 * age);
72
+ case 'arc': return 0.15 + Math.sin(Math.PI * age);
73
+ case 'stopped': return monthsAgo < stoppedMonthsAgo ? 0 : 1;
74
+ }
75
+ }
76
+ function phoneJid(index) {
77
+ return `1555000${String(1000 + index)}@s.whatsapp.net`;
78
+ }
79
+ function buildPeople() {
80
+ const people = new Map();
81
+ let index = 1;
82
+ for (const persona of DIRECT_PERSONAS) {
83
+ people.set(persona.name, {
84
+ name: persona.name,
85
+ jid: phoneJid(index),
86
+ lid: persona.hasLid ? `9000000000${String(index).padStart(3, '0')}@lid` : null,
87
+ });
88
+ index += 1;
89
+ }
90
+ for (const name of EXTRA_CONTACTS) {
91
+ people.set(name, { name, jid: phoneJid(index), lid: null });
92
+ index += 1;
93
+ }
94
+ return people;
95
+ }
96
+ function composeText(rng, plan, fromMe) {
97
+ const words = TOPICS[pick(rng, plan.topics)];
98
+ const template = pick(rng, fromMe ? MY_TEMPLATES : THEIR_TEMPLATES);
99
+ const text = template.replaceAll('{a}', pick(rng, words)).replaceAll('{b}', pick(rng, words));
100
+ if (rng() < 0.16)
101
+ return `${text} ${pick(rng, fromMe ? EMOJIS_MINE : EMOJIS_THEIRS)}`;
102
+ return text;
103
+ }
104
+ function composeLink(rng) {
105
+ const id = Math.floor(rng() * 1_000_000_000).toString(36);
106
+ return `${pick(rng, LINK_INTROS)} ${pick(rng, LINK_TEMPLATES).replace('{id}', id)}`;
107
+ }
108
+ function planChats(people) {
109
+ const person = (name) => people.get(name);
110
+ const direct = DIRECT_PERSONAS.map((persona) => ({
111
+ jid: person(persona.name).jid,
112
+ kind: 'dm',
113
+ name: persona.name,
114
+ archived: Boolean(persona.archived),
115
+ sessions: persona.sessions,
116
+ shape: persona.shape,
117
+ startMonthsAgo: persona.startMonthsAgo,
118
+ stoppedMonthsAgo: persona.stoppedMonthsAgo,
119
+ turns: persona.turns,
120
+ topics: persona.topics,
121
+ hourWeights: persona.nightOwl ? NIGHT_OWL_WEIGHTS : HOUR_WEIGHTS,
122
+ mine: persona.initiates,
123
+ others: [person(persona.name)],
124
+ otherWeights: [1],
125
+ theirReply: persona.reply,
126
+ myReply: persona.myReply,
127
+ ghost: persona.ghost ?? 0,
128
+ }));
129
+ // Everybody draws from one fixed ordering, shifted per group, so that the same
130
+ // few people turn up across groups the way friends do.
131
+ const everyone = [...people.values()];
132
+ const groups = GROUP_PERSONAS.map((persona, index) => {
133
+ const offset = (index * 7) % everyone.length;
134
+ const roster = Array.from({ length: persona.members }, (_, i) => everyone[(offset + i) % everyone.length]);
135
+ return {
136
+ jid: groupJid(index),
137
+ kind: 'group',
138
+ name: persona.name,
139
+ archived: Boolean(persona.archived),
140
+ sessions: persona.sessions,
141
+ shape: persona.shape,
142
+ startMonthsAgo: persona.startMonthsAgo,
143
+ stoppedMonthsAgo: persona.stoppedMonthsAgo,
144
+ turns: persona.turns,
145
+ topics: persona.topics,
146
+ hourWeights: HOUR_WEIGHTS,
147
+ mine: persona.myShare,
148
+ others: roster.slice(0, persona.speakers),
149
+ // Roughly Zipfian: in most groups a handful of people do the talking.
150
+ otherWeights: roster.slice(0, persona.speakers).map((_, i) => 1 / (i + 1)),
151
+ theirReply: 8,
152
+ myReply: 12,
153
+ ghost: 0,
154
+ };
155
+ });
156
+ return [...direct, ...groups];
157
+ }
158
+ function groupJid(index) {
159
+ return `120363000000${String(100 + index)}@g.us`;
160
+ }
161
+ /** Pick the sender. In a direct chat turns alternate; a group just has a next speaker. */
162
+ function nextMine(rng, plan, previousMine) {
163
+ if (plan.kind === 'dm')
164
+ return rng() < 0.28 ? previousMine : !previousMine;
165
+ return rng() < plan.mine;
166
+ }
167
+ function planMessage(rng, plan, ts, fromMe, text) {
168
+ const speaker = fromMe ? null : plan.others[weightedIndex(rng, plan.otherWeights)];
169
+ // Real archives record a person by `@lid` in some group messages and leave the
170
+ // name for the contacts join to fill in; both spellings have to merge.
171
+ const useLid = plan.kind === 'group' && speaker?.lid != null && rng() < 0.35;
172
+ const message = {
173
+ chatJid: plan.jid,
174
+ chatName: plan.name,
175
+ // My own JID is never written down: a group records the group, a direct chat nothing.
176
+ senderJid: speaker ? (useLid ? speaker.lid : speaker.jid) : plan.kind === 'group' ? plan.jid : null,
177
+ senderName: speaker && !useLid ? speaker.name : null,
178
+ ts,
179
+ fromMe,
180
+ text: text ?? null,
181
+ mediaType: null,
182
+ mediaPath: null,
183
+ mediaSize: null,
184
+ };
185
+ if (text !== undefined)
186
+ return message;
187
+ const roll = rng();
188
+ if (roll < 0.08) {
189
+ const [type, , smallest, largest] = MEDIA_MIX[weightedIndex(rng, MEDIA_MIX.map((entry) => entry[1]))];
190
+ message.mediaType = type;
191
+ message.mediaPath = demoMediaPath(type, rng);
192
+ message.mediaSize = Math.round(smallest + rng() * (largest - smallest));
193
+ if (type === 'image' && rng() < 0.3)
194
+ message.text = pick(rng, CAPTIONS);
195
+ }
196
+ else if (roll < 0.11) {
197
+ message.text = composeLink(rng);
198
+ }
199
+ else {
200
+ message.text = composeText(rng, plan, fromMe);
201
+ }
202
+ return message;
203
+ }
204
+ function planSession(rng, plan, startTs, nowTs, out) {
205
+ let fromMe = rng() < plan.mine;
206
+ if (fromMe && rng() < plan.ghost) {
207
+ out.push(planMessage(rng, plan, startTs, true));
208
+ return;
209
+ }
210
+ const turns = 1 + Math.floor(-Math.log(1 - rng()) * (plan.turns - 1));
211
+ let ts = startTs;
212
+ for (let turn = 0; turn < turns && ts <= nowTs; turn += 1) {
213
+ out.push(planMessage(rng, plan, ts, fromMe));
214
+ const nextIsMine = nextMine(rng, plan, fromMe);
215
+ ts += nextIsMine === fromMe
216
+ ? Math.round(8 + rng() * 150)
217
+ : replyGapSeconds(rng, nextIsMine ? plan.myReply : plan.theirReply);
218
+ fromMe = nextIsMine;
219
+ }
220
+ }
221
+ function planHistory(rng, plans, nowTs) {
222
+ const out = [];
223
+ const today = Math.floor(nowTs / DAY_SECONDS) * DAY_SECONDS;
224
+ const longestHistory = Math.max(...plans.map((plan) => plan.startMonthsAgo));
225
+ for (const plan of plans) {
226
+ const startDay = today - Math.round(plan.startMonthsAgo * 30) * DAY_SECONDS;
227
+ for (let day = startDay; day <= today; day += DAY_SECONDS) {
228
+ const monthsAgo = (today - day) / MONTH_SECONDS;
229
+ const date = new Date(day * 1000);
230
+ // Everyone gets busier year on year, and December is December.
231
+ const trend = 0.7 + 0.5 * (1 - monthsAgo / longestHistory);
232
+ const season = date.getUTCMonth() === 11 ? 1.5 : date.getUTCMonth() === 7 ? 0.75 : 1;
233
+ const rate = (plan.sessions / 30)
234
+ * activityFactor(plan.shape, monthsAgo, plan.startMonthsAgo, plan.stoppedMonthsAgo)
235
+ * WEEKDAY_FACTOR[date.getUTCDay()]
236
+ * trend
237
+ * season;
238
+ for (let session = poisson(rng, rate); session > 0; session -= 1) {
239
+ const hour = weightedIndex(rng, plan.hourWeights);
240
+ const startTs = day + hour * 3600 + Math.floor(rng() * 3600);
241
+ if (startTs <= nowTs)
242
+ planSession(rng, plan, startTs, nowTs, out);
243
+ }
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+ /**
249
+ * The fixed points the smoke tests and the current-streak card rely on. Random
250
+ * history cannot promise that a term exists, or that today has any messages.
251
+ */
252
+ function planMilestones(rng, plans, nowTs) {
253
+ const planFor = (name) => plans.find((plan) => plan.name === name);
254
+ const ada = planFor('Ada Lovelace');
255
+ const family = planFor('Family Dinner Planning');
256
+ const bookClub = planFor('Book Club');
257
+ const grace = planFor('Grace Hopper');
258
+ const daysAgo = (days, hour = 19) => Math.floor(nowTs / DAY_SECONDS) * DAY_SECONDS - days * DAY_SECONDS + hour * 3600;
259
+ const out = [
260
+ planMessage(rng, ada, daysAgo(12), true, 'The lighthouse trip is booked for the long weekend'),
261
+ planMessage(rng, ada, daysAgo(12) + 240, false, 'Perfect, I will bring the old lighthouse logbook'),
262
+ planMessage(rng, bookClub, daysAgo(30), false, 'Next month we read To the Lighthouse, paperback copies at the library'),
263
+ // A message in the last few hours keeps Day and the current streak populated.
264
+ planMessage(rng, ada, nowTs - 2 * 3600, false, 'Are we still on for tonight?'),
265
+ planMessage(rng, ada, nowTs - 2 * 3600 + 120, true, 'Definitely, see you at seven'),
266
+ planMessage(rng, family, nowTs - 5 * 3600, false, 'Who is bringing dessert on Sunday?'),
267
+ planMessage(rng, grace, nowTs - 9 * 3600, false, 'The deploy went out, thanks for the review'),
268
+ ];
269
+ for (let day = 1; day <= 60; day += 1) {
270
+ const hour = 8 + Math.floor(rng() * 14);
271
+ out.push(planMessage(rng, ada, daysAgo(day, hour), rng() < 0.5, 'Morning! How did it go?'));
272
+ }
273
+ return out;
274
+ }
275
+ function insertPeople(db, people) {
276
+ const insertContact = db.prepare(`
277
+ INSERT INTO contacts (jid, phone, full_name, first_name, last_name, business_name, lid)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?)
279
+ `);
280
+ for (const { jid, lid, name } of people.values()) {
281
+ const [first, ...rest] = name.split(' ');
282
+ insertContact.run(jid, jid.split('@')[0], name, first, rest.join(' ') || null, null, lid);
283
+ }
284
+ // The other shapes an address book holds: a business, and a bare number.
285
+ insertContact.run('15550001900@s.whatsapp.net', '15550001900', null, null, null, 'Analytical Engine Repairs', null);
286
+ insertContact.run('15550001901@s.whatsapp.net', '15550001901', null, null, null, null, null);
287
+ }
288
+ function insertGroups(db, people, nowTs) {
289
+ const insertGroup = db.prepare('INSERT INTO groups (jid, name, owner_jid, created_at) VALUES (?, ?, ?, ?)');
290
+ const insertMember = db.prepare(`
291
+ INSERT INTO group_participants (group_jid, user_jid, contact_name, first_name, is_admin, is_active)
292
+ VALUES (?, ?, ?, ?, ?, ?)
293
+ `);
294
+ const everyone = [...people.values()];
295
+ const seedRoster = (jid, members) => {
296
+ // I am on every roster, and on no other record: see lib/selfIdentity.ts.
297
+ insertMember.run(jid, SELF_JID, null, null, 0, 1);
298
+ members.forEach((member, i) => {
299
+ const user = member.lid && i % 2 === 1 ? member.lid : member.jid;
300
+ insertMember.run(jid, user, member.name, member.name.split(' ')[0], i === 0 ? 1 : 0, 1);
301
+ });
302
+ };
303
+ GROUP_PERSONAS.forEach((persona, index) => {
304
+ const offset = (index * 7) % everyone.length;
305
+ const roster = Array.from({ length: persona.members }, (_, i) => everyone[(offset + i) % everyone.length]);
306
+ const founded = nowTs - Math.round(persona.foundedYearsAgo * 365.25) * DAY_SECONDS;
307
+ insertGroup.run(groupJid(index), persona.name, roster[0].jid, founded);
308
+ seedRoster(groupJid(index), roster);
309
+ });
310
+ const silentJid = groupJid(GROUP_PERSONAS.length);
311
+ const silentRoster = everyone.slice(0, SILENT_GROUP.members);
312
+ insertGroup.run(silentJid, SILENT_GROUP.name, silentRoster[0].jid, nowTs - SILENT_GROUP.foundedYearsAgo * 365 * DAY_SECONDS);
313
+ seedRoster(silentJid, silentRoster);
314
+ }
315
+ export function generateDemoArchive(options) {
316
+ const nowTs = Math.floor((options.now ?? new Date()).getTime() / 1000);
317
+ const rng = mulberry32(options.seed ?? DEFAULT_DEMO_SEED);
318
+ const dbPath = path.join(options.dir, 'wacrawl-demo.db');
319
+ const mediaRoot = path.join(options.dir, 'media');
320
+ fs.mkdirSync(options.dir, { recursive: true });
321
+ writeDemoMedia(mediaRoot, rng);
322
+ const people = buildPeople();
323
+ const plans = planChats(people);
324
+ const messages = [...planHistory(rng, plans, nowTs), ...planMilestones(rng, plans, nowTs)]
325
+ .filter((message) => message.ts <= nowTs)
326
+ .sort((a, b) => a.ts - b.ts);
327
+ const db = new Database(dbPath);
328
+ try {
329
+ db.pragma('synchronous = OFF');
330
+ db.exec(ARCHIVE_SCHEMA_SQL);
331
+ db.exec(`
332
+ ALTER TABLE chats ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
333
+ CREATE TABLE sync_state (key TEXT PRIMARY KEY, value TEXT);
334
+ `);
335
+ db.transaction(() => {
336
+ insertPeople(db, people);
337
+ insertGroups(db, people, nowTs);
338
+ const insertMessage = db.prepare(`
339
+ INSERT INTO messages (
340
+ source_pk, chat_jid, chat_name, msg_id, sender_jid, sender_name, ts,
341
+ from_me, text, raw_type, message_type, media_type, media_path, media_size
342
+ )
343
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
344
+ `);
345
+ const lastByChat = new Map();
346
+ messages.forEach((message, index) => {
347
+ insertMessage.run(index + 1, message.chatJid, message.chatName, `DEMO${(index + 1).toString(16).padStart(12, '0').toUpperCase()}`, message.senderJid, message.senderName, message.ts, message.fromMe ? 1 : 0, message.text, message.mediaType ? RAW_TYPES[message.mediaType] : 0, message.mediaType ?? 'text', message.mediaType, message.mediaPath, message.mediaSize);
348
+ lastByChat.set(message.chatJid, message.ts);
349
+ });
350
+ const insertChat = db.prepare('INSERT INTO chats (jid, kind, name, last_message_at, archived) VALUES (?, ?, ?, ?, ?)');
351
+ for (const plan of plans) {
352
+ insertChat.run(plan.jid, plan.kind, plan.name, lastByChat.get(plan.jid) ?? null, plan.archived ? 1 : 0);
353
+ }
354
+ insertChat.run(groupJid(GROUP_PERSONAS.length), 'group', SILENT_GROUP.name, null, 0);
355
+ const insertState = db.prepare('INSERT INTO sync_state (key, value) VALUES (?, ?)');
356
+ insertState.run('last_import_at', new Date((nowTs - 3 * 3600) * 1000).toISOString());
357
+ insertState.run('source_path', 'demo');
358
+ })();
359
+ createSearchIndexes(db);
360
+ return {
361
+ dbPath,
362
+ mediaRoot,
363
+ messages: messages.length,
364
+ chats: plans.length + 1,
365
+ contacts: db.prepare('SELECT COUNT(*) AS count FROM contacts').get().count,
366
+ };
367
+ }
368
+ finally {
369
+ db.close();
370
+ }
371
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Who is in the demo archive and how they behave. All of it is invented.
3
+ *
4
+ * The names are long-dead scientists on purpose: nobody can mistake them for
5
+ * somebody's real contacts. Phone numbers sit in the reserved 555-0xxx range and
6
+ * LIDs in a made-up 900… block. Do not "improve" this with real data, ever.
7
+ */
8
+ export const SELF_NAME = 'Charles Babbage';
9
+ export const TOPICS = {
10
+ climbing: ['crag', 'belay', 'harness', 'chalk', 'overhang', 'bouldering'],
11
+ books: ['novel', 'chapter', 'library', 'paperback', 'author', 'bookmark'],
12
+ cooking: ['sourdough', 'risotto', 'paprika', 'oven', 'recipe', 'lentils'],
13
+ code: ['compiler', 'deploy', 'refactor', 'debugger', 'kernel', 'pipeline'],
14
+ maths: ['theorem', 'proof', 'algebra', 'calculus', 'matrix', 'integral'],
15
+ space: ['orbit', 'telescope', 'comet', 'rocket', 'eclipse', 'satellite'],
16
+ travel: ['airport', 'itinerary', 'passport', 'ferry', 'hostel', 'luggage'],
17
+ family: ['grandma', 'birthday', 'cousins', 'picnic', 'dinner', 'photos'],
18
+ garden: ['tomatoes', 'compost', 'seedlings', 'greenhouse', 'roses', 'allotment'],
19
+ football: ['match', 'referee', 'penalty', 'stadium', 'midfielder', 'kickoff'],
20
+ music: ['concert', 'playlist', 'cello', 'rehearsal', 'vinyl', 'soundcheck'],
21
+ work: ['meeting', 'deadline', 'roadmap', 'standup', 'invoice', 'quarterly'],
22
+ health: ['marathon', 'physio', 'yoga', 'sleep', 'running', 'stretching'],
23
+ home: ['plumber', 'landlord', 'paint', 'neighbours', 'boiler', 'skylight'],
24
+ };
25
+ export const DIRECT_PERSONAS = [
26
+ { name: 'Ada Lovelace', sessions: 18, shape: 'steady', startMonthsAgo: 44, turns: 8, initiates: 0.55, reply: 6, myReply: 9, topics: ['maths', 'music', 'code'], hasLid: true },
27
+ { name: 'Grace Hopper', sessions: 14, shape: 'steady', startMonthsAgo: 44, turns: 8, initiates: 0.4, reply: 15, myReply: 8, topics: ['code', 'work'], hasLid: true },
28
+ { name: 'Katherine Johnson', sessions: 16, shape: 'growing', startMonthsAgo: 30, turns: 7, initiates: 0.35, reply: 12, myReply: 25, topics: ['space', 'maths'] },
29
+ { name: 'Marie Curie', sessions: 12, shape: 'steady', startMonthsAgo: 44, turns: 6, initiates: 0.7, reply: 95, myReply: 12, topics: ['garden', 'family'], ghost: 0.25 },
30
+ { name: 'Alan Turing', sessions: 14, shape: 'fading', startMonthsAgo: 44, turns: 7, initiates: 0.6, reply: 35, myReply: 20, topics: ['maths', 'health', 'code'], nightOwl: true },
31
+ { name: 'Rosalind Franklin', sessions: 10, shape: 'stopped', startMonthsAgo: 40, stoppedMonthsAgo: 6, turns: 6, initiates: 0.5, reply: 40, myReply: 30, topics: ['travel', 'books'], archived: true },
32
+ { name: 'Nikola Tesla', sessions: 14, shape: 'steady', startMonthsAgo: 38, turns: 5, initiates: 0.2, reply: 4, myReply: 180, topics: ['space', 'home'], nightOwl: true, hasLid: true },
33
+ { name: 'Emmy Noether', sessions: 9, shape: 'growing', startMonthsAgo: 14, turns: 8, initiates: 0.45, reply: 20, myReply: 15, topics: ['maths', 'books'] },
34
+ { name: 'Hedy Lamarr', sessions: 7, shape: 'steady', startMonthsAgo: 42, turns: 5, initiates: 0.5, reply: 60, myReply: 45, topics: ['music', 'travel'], archived: true },
35
+ { name: 'Charles Darwin', sessions: 8, shape: 'fading', startMonthsAgo: 44, turns: 5, initiates: 0.75, reply: 400, myReply: 30, topics: ['garden', 'travel'], ghost: 0.45 },
36
+ { name: 'Johannes Kepler', sessions: 6, shape: 'stopped', startMonthsAgo: 36, stoppedMonthsAgo: 9, turns: 5, initiates: 0.5, reply: 50, myReply: 40, topics: ['space'] },
37
+ { name: 'Mary Somerville', sessions: 6, shape: 'steady', startMonthsAgo: 28, turns: 4, initiates: 0.3, reply: 25, myReply: 70, topics: ['books', 'space'] },
38
+ { name: 'Michael Faraday', sessions: 5, shape: 'arc', startMonthsAgo: 34, turns: 5, initiates: 0.5, reply: 30, myReply: 30, topics: ['home', 'work'] },
39
+ { name: 'Caroline Herschel', sessions: 5, shape: 'steady', startMonthsAgo: 8, turns: 5, initiates: 0.4, reply: 18, myReply: 22, topics: ['space', 'cooking'] },
40
+ { name: 'Louis Pasteur', sessions: 4, shape: 'fading', startMonthsAgo: 40, turns: 4, initiates: 0.8, reply: 900, myReply: 50, topics: ['cooking', 'health'], ghost: 0.6 },
41
+ { name: 'Sofia Kovalevskaya', sessions: 6, shape: 'steady', startMonthsAgo: 5, turns: 6, initiates: 0.5, reply: 14, myReply: 16, topics: ['maths', 'travel'] },
42
+ { name: 'Blaise Pascal', sessions: 3, shape: 'stopped', startMonthsAgo: 30, stoppedMonthsAgo: 5, turns: 4, initiates: 0.6, reply: 90, myReply: 60, topics: ['maths', 'football'] },
43
+ { name: 'Lise Meitner', sessions: 4, shape: 'steady', startMonthsAgo: 3, turns: 5, initiates: 0.6, reply: 22, myReply: 26, topics: ['travel', 'music'] },
44
+ { name: 'Florence Nightingale', sessions: 4, shape: 'steady', startMonthsAgo: 24, turns: 4, initiates: 0.25, reply: 35, myReply: 110, topics: ['health', 'family'] },
45
+ { name: 'Isaac Newton', sessions: 3, shape: 'steady', startMonthsAgo: 44, turns: 3, initiates: 0.85, reply: 1500, myReply: 60, topics: ['maths', 'garden'], ghost: 0.7, archived: true },
46
+ { name: 'Joan Clarke', sessions: 3, shape: 'growing', startMonthsAgo: 12, turns: 5, initiates: 0.5, reply: 28, myReply: 28, topics: ['books', 'code'], nightOwl: true },
47
+ { name: 'Dorothy Vaughan', sessions: 3, shape: 'steady', startMonthsAgo: 18, turns: 4, initiates: 0.4, reply: 40, myReply: 35, topics: ['work', 'cooking'] },
48
+ ];
49
+ /** In group rosters and the address book, with no direct chat of their own. */
50
+ export const EXTRA_CONTACTS = [
51
+ 'Annie Easley', 'Leonhard Euler', 'Carl Gauss', 'Srinivasa Ramanujan', 'Galileo Galilei',
52
+ 'Mary Anning', 'Gregor Mendel', 'Dmitri Mendeleev', 'Maria Agnesi', 'Antoine Lavoisier',
53
+ 'Tycho Brahe', 'Robert Hooke', 'James Maxwell', 'Barbara McClintock', 'Henrietta Leavitt',
54
+ 'Chien-Shiung Wu', 'Niels Bohr', 'Paul Dirac', 'Pierre Fermat', 'Sophie Germain',
55
+ ];
56
+ export const GROUP_PERSONAS = [
57
+ { name: 'Family Dinner Planning', sessions: 16, shape: 'steady', startMonthsAgo: 44, turns: 9, myShare: 0.3, members: 7, speakers: 6, topics: ['family', 'cooking'], foundedYearsAgo: 7 },
58
+ { name: 'Climbing Crew', sessions: 12, shape: 'growing', startMonthsAgo: 36, turns: 9, myShare: 0.2, members: 14, speakers: 9, topics: ['climbing', 'travel', 'health'], foundedYearsAgo: 3 },
59
+ { name: 'Book Club', sessions: 8, shape: 'steady', startMonthsAgo: 44, turns: 8, myShare: 0.18, members: 11, speakers: 7, topics: ['books'], foundedYearsAgo: 5 },
60
+ { name: 'Neighbourhood Watch', sessions: 10, shape: 'steady', startMonthsAgo: 40, turns: 7, myShare: 0.03, members: 34, speakers: 9, topics: ['home', 'garden'], foundedYearsAgo: 4 },
61
+ { name: 'Friday Football', sessions: 9, shape: 'steady', startMonthsAgo: 34, turns: 8, myShare: 0.12, members: 18, speakers: 12, topics: ['football'], foundedYearsAgo: 3 },
62
+ { name: 'Office Coffee Run', sessions: 16, shape: 'steady', startMonthsAgo: 26, turns: 5, myShare: 0.25, members: 9, speakers: 8, topics: ['work', 'cooking'], foundedYearsAgo: 2, archived: true },
63
+ { name: 'Allotment Society', sessions: 6, shape: 'steady', startMonthsAgo: 32, turns: 5, myShare: 0.08, members: 21, speakers: 6, topics: ['garden'], foundedYearsAgo: 6, archived: true },
64
+ { name: 'Difference Engine Alumni', sessions: 10, shape: 'fading', startMonthsAgo: 44, turns: 6, myShare: 0.15, members: 25, speakers: 10, topics: ['code', 'work'], foundedYearsAgo: 8 },
65
+ { name: 'Eclipse Road Trip', sessions: 18, shape: 'stopped', startMonthsAgo: 26, stoppedMonthsAgo: 13, turns: 10, myShare: 0.28, members: 6, speakers: 6, topics: ['travel', 'space'], foundedYearsAgo: 2 },
66
+ { name: 'Wedding Planning', sessions: 14, shape: 'stopped', startMonthsAgo: 34, stoppedMonthsAgo: 20, turns: 9, myShare: 0.25, members: 8, speakers: 7, topics: ['family', 'music'], foundedYearsAgo: 3 },
67
+ { name: 'Startup Ideas', sessions: 12, shape: 'stopped', startMonthsAgo: 42, stoppedMonthsAgo: 24, turns: 8, myShare: 0.35, members: 5, speakers: 5, topics: ['code', 'work'], foundedYearsAgo: 4 },
68
+ { name: 'Old Flatmates', sessions: 5, shape: 'stopped', startMonthsAgo: 44, stoppedMonthsAgo: 8, turns: 5, myShare: 0.25, members: 4, speakers: 4, topics: ['home', 'music'], foundedYearsAgo: 8 },
69
+ { name: 'Concert Tickets', sessions: 8, shape: 'stopped', startMonthsAgo: 18, stoppedMonthsAgo: 11, turns: 6, myShare: 0.2, members: 10, speakers: 6, topics: ['music'], foundedYearsAgo: 1, archived: true },
70
+ ];
71
+ /** On the roster with nothing ever posted, which the graveyard reports separately. */
72
+ export const SILENT_GROUP = { name: 'School Reunion', members: 12, foundedYearsAgo: 2 };
73
+ /** Words only I lean on, so the Distinctive Words card has something to find. */
74
+ export const MY_TEMPLATES = [
75
+ 'honestly the {a} sounds great',
76
+ 'basically done with the {a}, will send it over',
77
+ 'definitely up for the {a} tomorrow',
78
+ 'honestly not sure about the {a} yet',
79
+ 'let me check the {a} and get back to you',
80
+ 'running late, the {a} took forever',
81
+ 'basically the {a} is sorted, what about the {b}?',
82
+ 'can you remind me about the {a} tomorrow?',
83
+ 'I reckon the {a} needs another look',
84
+ 'definitely agree on the {a}',
85
+ 'on my way, bringing the {a}',
86
+ 'sorry, just saw this. {a} works for me',
87
+ 'sounds good',
88
+ 'tomorrow works',
89
+ 'on my way',
90
+ 'will do, thanks',
91
+ ];
92
+ export const THEIR_TEMPLATES = [
93
+ 'hey charles, did you see the {a}?',
94
+ 'thanks charles! the {a} was brilliant',
95
+ 'any news on the {a}?',
96
+ 'the {a} was amazing yesterday',
97
+ 'what do you think about the {a} and the {b}?',
98
+ 'charles are you coming to the {a} this weekend?',
99
+ 'just finished the {a}, wow',
100
+ 'can we talk about the {a} later?',
101
+ 'lovely, the {b} turned out really well',
102
+ 'wonderful news about the {a}',
103
+ 'so the {a} got moved to next week',
104
+ 'haha that {a} story is great',
105
+ 'thanks!',
106
+ 'lovely',
107
+ 'see you soon',
108
+ 'wonderful',
109
+ ];
110
+ export const EMOJIS_MINE = ['😂', '🙏', '👍', '😅', '🤔'];
111
+ export const EMOJIS_THEIRS = ['😂', '❤️', '🔥', '🎉', '😊', '🙌', '😍', '👏', '🥳', '😮'];
112
+ export const CAPTIONS = ['look at this', 'from today', 'the view from here', 'this one is for you', 'finally got a decent shot'];
113
+ /** Every category in the link report gets at least one domain. */
114
+ export const LINK_TEMPLATES = [
115
+ 'https://www.youtube.com/watch?v={id}',
116
+ 'https://www.youtube.com/watch?v={id}',
117
+ 'https://vimeo.com/{id}',
118
+ 'https://www.reddit.com/r/space/comments/{id}',
119
+ 'https://x.com/example/status/{id}',
120
+ 'https://www.linkedin.com/posts/example-{id}',
121
+ 'https://www.nytimes.com/2026/example-{id}.html',
122
+ 'https://www.bbc.com/news/example-{id}',
123
+ 'https://www.theguardian.com/science/example-{id}',
124
+ 'https://www.amazon.com/dp/{id}',
125
+ 'https://www.etsy.com/listing/{id}',
126
+ 'https://open.spotify.com/track/{id}',
127
+ 'https://open.spotify.com/track/{id}',
128
+ 'https://bandcamp.com/track/{id}',
129
+ 'https://github.com/example/{id}',
130
+ 'https://github.com/example/{id}',
131
+ 'https://stackoverflow.com/questions/{id}',
132
+ 'https://en.wikipedia.org/wiki/Analytical_Engine',
133
+ 'https://en.wikipedia.org/wiki/Comet',
134
+ 'https://docs.google.com/document/d/{id}',
135
+ 'https://example.com/notes/{id}',
136
+ 'https://example.org/events/{id}',
137
+ ];
138
+ export const LINK_INTROS = ['check this out', 'you will like this', 'this reminded me of you', 'read this', 'worth a look', 'saved this for you'];
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const IMAGE_COUNT = 24;
4
+ const STICKER_COUNT = 6;
5
+ const DOCUMENT_COUNT = 3;
6
+ /**
7
+ * One second of a solid colour, VP8, small enough to live in the source:
8
+ * ffmpeg -f lavfi -i "color=c=0x0ea5a4:s=64x64:d=1:r=4" -map_metadata -1 -fflags +bitexact
9
+ * -pix_fmt yuv420p -c:v libvpx -b:v 8k -g 4 -auto-alt-ref 0 -an -f webm clip.webm
10
+ * WebM rather than MP4 because stock Chromium, which runs the smoke tests, has no H.264.
11
+ */
12
+ const CLIP_WEBM_BASE64 = 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAIjEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZU'
13
+ + 'rmtTrIHGTbuMU6uEElTDZ1OsggEiTbuMU6uEHFO7a1OsggIN7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
14
+ + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmoCrXsYMPQkBNgIRM'
15
+ + 'YXZmV0GETGF2ZkSJiECPQAAAAAAAFlSua9euAQAAAAAAAE7XgQFzxYgAAAAAAAAAAZyBACK1nIN1bmSIgQCGhVZfVlA4g4EBI+OD'
16
+ + 'hA7msoDgkLCBQLqBQJqBAlWwhFW5gQFV7oEA7AEAAAAAAAACAAASVMNn0HNzzWPAi2PFiAAAAAAAAAABZ8iYRaOHRU5DT0RFUkSH'
17
+ + 'i0xhdmMgbGlidnB4Z8ihRaOIRFVSQVRJT05Eh5MwMDowMDowMS4wMDAwMDAwMDAAH0O2dUCQ54EAo8OBAACAkAMAnQEqQABAAABH'
18
+ + 'CIWFiIWEiAICAnWqA/gCBuhKMBVkQe7MTgD+7L2P/UGfIM+QZ+ql//A/f4H7/A/f/gTwo5aBAPoA0QEAARAQABgAGFgv9AAIjoAA'
19
+ + 'o5aBAfQA0QEAARAQABgAGFgv9AAIjoAAo5aBAu4A0QEAARAQABgAGFgv9AAIjoAAHFO7a5G7j7OBALeK94EB8YIBd/CBAw==';
20
+ const SKIES = [
21
+ ['#fde68a', '#fb7185', '#7c2d12'],
22
+ ['#bae6fd', '#38bdf8', '#14532d'],
23
+ ['#c4b5fd', '#f0abfc', '#312e81'],
24
+ ['#99f6e4', '#2dd4bf', '#134e4a'],
25
+ ['#fed7aa', '#f97316', '#451a03'],
26
+ ['#e2e8f0', '#94a3b8', '#1e293b'],
27
+ ];
28
+ /** A little landscape: nothing in it can be anybody's real photo. */
29
+ function landscapeSvg(rng, index) {
30
+ const [top, bottom, ground] = SKIES[index % SKIES.length];
31
+ const hill = (base) => {
32
+ const peak = Math.round(base - 40 - rng() * 90);
33
+ const x = Math.round(80 + rng() * 240);
34
+ return `M0 ${base} Q${x} ${peak} 400 ${Math.round(base - rng() * 50)} L400 400 L0 400 Z`;
35
+ };
36
+ const sunX = Math.round(60 + rng() * 280);
37
+ const sunY = Math.round(60 + rng() * 110);
38
+ const sunRadius = Math.round(18 + rng() * 26);
39
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
40
+ <defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${top}"/><stop offset="1" stop-color="${bottom}"/></linearGradient></defs>
41
+ <rect width="400" height="400" fill="url(#sky)"/>
42
+ <circle cx="${sunX}" cy="${sunY}" r="${sunRadius}" fill="#ffffff" opacity="0.8"/>
43
+ <path d="${hill(300)}" fill="${ground}" opacity="0.55"/>
44
+ <path d="${hill(350)}" fill="${ground}"/>
45
+ </svg>
46
+ `;
47
+ }
48
+ function stickerSvg(index) {
49
+ const [, fill, shade] = SKIES[index % SKIES.length];
50
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
51
+ <circle cx="100" cy="100" r="90" fill="${fill}"/>
52
+ <circle cx="70" cy="82" r="11" fill="${shade}"/>
53
+ <circle cx="130" cy="82" r="11" fill="${shade}"/>
54
+ <path d="M60 125 Q100 165 140 125" stroke="${shade}" stroke-width="9" fill="none" stroke-linecap="round"/>
55
+ </svg>
56
+ `;
57
+ }
58
+ /** Half a second of a soft tone, as 8-bit mono PCM. */
59
+ function toneWav() {
60
+ const rate = 8000;
61
+ const samples = rate / 2;
62
+ const header = Buffer.alloc(44);
63
+ header.write('RIFF', 0, 'ascii');
64
+ header.writeUInt32LE(36 + samples, 4);
65
+ header.write('WAVEfmt ', 8, 'ascii');
66
+ header.writeUInt32LE(16, 16);
67
+ header.writeUInt16LE(1, 20);
68
+ header.writeUInt16LE(1, 22);
69
+ header.writeUInt32LE(rate, 24);
70
+ header.writeUInt32LE(rate, 28);
71
+ header.writeUInt16LE(1, 32);
72
+ header.writeUInt16LE(8, 34);
73
+ header.write('data', 36, 'ascii');
74
+ header.writeUInt32LE(samples, 40);
75
+ const body = Buffer.alloc(samples);
76
+ for (let i = 0; i < samples; i += 1) {
77
+ const fade = 1 - i / samples;
78
+ body[i] = Math.round(128 + 60 * fade * Math.sin((2 * Math.PI * 440 * i) / rate));
79
+ }
80
+ return Buffer.concat([header, body]);
81
+ }
82
+ const pad = (value) => String(value).padStart(2, '0');
83
+ /** Paths are relative to the media root, the way a WaCrawl archive stores them. */
84
+ export function demoMediaPath(type, rng) {
85
+ switch (type) {
86
+ case 'image': return `images/photo-${pad(Math.floor(rng() * IMAGE_COUNT))}.svg`;
87
+ case 'sticker': return `stickers/sticker-${pad(Math.floor(rng() * STICKER_COUNT))}.svg`;
88
+ case 'video': return 'videos/clip.webm';
89
+ case 'audio': return 'audio/voice-note.wav';
90
+ case 'document': return `documents/notes-${pad(Math.floor(rng() * DOCUMENT_COUNT))}.txt`;
91
+ }
92
+ }
93
+ /** Real, tiny files behind every media row, so the media grid has something to show. */
94
+ export function writeDemoMedia(mediaRoot, rng) {
95
+ const write = (relative, contents) => {
96
+ const target = path.join(mediaRoot, relative);
97
+ fs.mkdirSync(path.dirname(target), { recursive: true });
98
+ fs.writeFileSync(target, contents);
99
+ };
100
+ for (let i = 0; i < IMAGE_COUNT; i += 1)
101
+ write(`images/photo-${pad(i)}.svg`, landscapeSvg(rng, i));
102
+ for (let i = 0; i < STICKER_COUNT; i += 1)
103
+ write(`stickers/sticker-${pad(i)}.svg`, stickerSvg(i));
104
+ for (let i = 0; i < DOCUMENT_COUNT; i += 1)
105
+ write(`documents/notes-${pad(i)}.txt`, `Demo attachment ${i + 1}. Synthetic, like everything else here.\n`);
106
+ write('videos/clip.webm', Buffer.from(CLIP_WEBM_BASE64, 'base64'));
107
+ write('audio/voice-note.wav', toneWav());
108
+ }