josefandersson / teams-export.mjs

Last active 2 weeks ago

Like 0

Export of Teams messages for a specific chat to the Frenfoil generic chat import format so that they can be imported into the Frenfoil history.

josefandersson revised this gist 2 weeks ago · f639d5d

1 file changed, 30 insertions, 14 deletions

teams-export.mjs
@@ -5,10 +5,11 @@
5 5 // while it starts up, then fetches the chat and its images directly.
6 6 //
7 7 // Run again into a folder that holds an export to extend it: the chat is picked from its chats.json, only
8 - // the last week before its newest message onward is fetched (--full for everything), and what Teams no
9 - // longer has is kept.
8 + // the last --backtrack-days (default 7) before its newest message onward are fetched (--full for everything), and what Teams no
9 + // longer has is kept. The new messages and their photos also go to a folder beside it,
10 + // <outDir>-new-<UTC timestamp>, as an export of their own.
10 11 //
11 - // node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222]
12 + // node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--backtrack-days <n>] [--full] [--yes] [--no-restart] [--port 9222]
12 13
13 14 import { spawn } from 'node:child_process';
14 15 import { createInterface } from 'node:readline/promises';
@@ -20,17 +21,20 @@ const flag = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? (arg
20 21 const opt = name => { const i = args.indexOf(`--${name}`); return i >= 0 ? args.splice(i, 2)[1] : undefined; };
21 22 const port = opt('port') ?? '9222';
22 23 const sinceArg = opt('since');
24 + const backtrackArg = opt('backtrack-days');
23 25 const withName = opt('with');
24 26 const yes = flag('yes');
25 27 const full = flag('full');
26 28 const noRestart = flag('no-restart');
27 29 const outDir = args[0];
28 30 if (!outDir) {
29 - console.log('usage: node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--full] [--yes] [--no-restart] [--port 9222]');
31 + console.log('usage: node teams-export.mjs <outDir> [--with <name>] [--since YYYY-MM-DD] [--backtrack-days <n>] [--full] [--yes] [--no-restart] [--port 9222]');
30 32 process.exit(1);
31 33 }
32 34 let since = sinceArg ? Date.parse(sinceArg) : 0;
33 35 if (isNaN(since)) throw new Error(`Invalid --since date: ${sinceArg}`);
36 + const backtrackDays = +(backtrackArg ?? 7);
37 + if (!(backtrackDays >= 0)) throw new Error(`Invalid --backtrack-days: ${backtrackArg}`);
34 38
35 39 const FORMAT = 'frenfoil-chat-import';
36 40 // Frenfoil recognises a re-imported message by source, chat id and message id, so this never changes.
@@ -42,11 +46,10 @@ const previousChat = previous?.chats?.[0];
42 46 if (previous && (previous.format !== FORMAT || previous.source !== SOURCE || !Array.isArray(previousChat?.messages)))
43 47 throw new Error(`${outPath} is not an export this tool wrote.`);
44 48
45 - // Edits and reactions land on recent messages, so an extension re-reads the week before its newest one.
46 - const OVERLAP_DAYS = 7;
49 + // Edits and reactions land on recent messages, so an extension re-reads the days before its newest one.
47 50 if (previous && !sinceArg && !full) {
48 51 const newest = previousChat.messages.reduce((n, m) => Math.max(n, +m.time || 0), 0);
49 - since = Math.max(0, newest - OVERLAP_DAYS * 86_400_000);
52 + since = Math.max(0, newest - backtrackDays * 86_400_000);
50 53 }
51 54
52 55 const PARALLEL = 4;
@@ -463,28 +466,41 @@ const messages = prepared.map(p => {
463 466 // download that failed now.
464 467 const pathed = m => (m.files ?? []).filter(f => f.path).length;
465 468 const merged = new Map((previousChat?.messages ?? []).map(m => [String(m.id), m]));
466 - let added = 0;
469 + const added = [];
467 470 let updated = 0;
468 471 for (const m of messages) {
469 472 const old = merged.get(m.id);
470 473 if (old && pathed(old) > pathed(m)) continue;
471 - if (!old) added++;
474 + if (!old) added.push(m);
472 475 else if (JSON.stringify(old) !== JSON.stringify(m)) updated++;
473 476 merged.set(m.id, m);
474 477 }
475 478 const all = [...merged.values()].sort((a, b) => a.time - b.time || Number(a.id) - Number(b.id));
476 479
477 - if (previous) fs.copyFileSync(outPath, `${outPath}.bak`);
478 - fs.writeFileSync(`${outPath}.tmp`, JSON.stringify({
480 + const exportJson = msgs => JSON.stringify({
479 481 format: FORMAT,
480 482 version: 1,
481 483 source: SOURCE,
482 - chats: [{ id: chat.id, kind: 'direct', name: peerDisplay, messages: all }],
483 - }, null, 1));
484 + chats: [{ id: chat.id, kind: 'direct', name: peerDisplay, messages: msgs }],
485 + }, null, 1);
486 +
487 + if (previous) fs.copyFileSync(outPath, `${outPath}.bak`);
488 + fs.writeFileSync(`${outPath}.tmp`, exportJson(all));
484 489 fs.renameSync(`${outPath}.tmp`, outPath);
485 490
486 491 const failed = unique.length - saved.size;
487 - console.log(`Done: ${added} new, ${updated} updated, ${all.length} in total; ${saved.size}/${unique.length} images → ${path.resolve(outPath)}`);
492 + console.log(`Done: ${added.length} new, ${updated} updated, ${all.length} in total; ${saved.size}/${unique.length} images → ${path.resolve(outPath)}`);
493 +
494 + // An extension also gets a folder beside the export with only its new messages and their photos.
495 + if (previous && added.length) {
496 + const stamp = new Date().toISOString().slice(0, 16).replace('T', '_').replace(':', '');
497 + const deltaDir = `${path.resolve(outDir)}-new-${stamp}`;
498 + fs.mkdirSync(path.join(deltaDir, 'photos'), { recursive: true });
499 + const photos = new Set(added.flatMap(m => (m.files ?? []).map(f => f.path).filter(Boolean)));
500 + for (const p of photos) fs.copyFileSync(path.join(outDir, p), path.join(deltaDir, p));
501 + fs.writeFileSync(path.join(deltaDir, 'chats.json'), exportJson(added));
502 + console.log(`New only: ${added.length} messages, ${photos.size} images → ${deltaDir}`);
503 + }
488 504 if (failed)
489 505 console.log(`${failed} images failed (HTTP ${[...failures].map(([s, n]) => `${s || 'error'}×${n}`).join(', ')}). Run again with --full into the same folder to retry them.`);
490 506

josefandersson revised this gist 3 weeks ago · 3a898ee

1 file changed, 90 insertions, 82 deletions

teams-export.mjs

Diff is too large to be shown

josefandersson revised this gist 3 weeks ago · 6e989e8

1 file changed, 484 insertions

teams-export.mjs (file created)

Diff is too large to be shown