birdclaw 0.4.1 → 0.5.1

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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -1,6 +1,17 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import {
4
+ createWriteStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ renameSync,
8
+ statSync,
9
+ unlinkSync,
10
+ } from "node:fs";
11
+ import path from "node:path";
12
+ import { pipeline } from "node:stream/promises";
3
13
  import { promisify } from "node:util";
14
+ import { getBirdclawPaths } from "./config";
4
15
  import { getNativeDb } from "./db";
5
16
 
6
17
  const execFileAsync = promisify(execFile);
@@ -29,10 +40,53 @@ interface ImportedArchiveSummary {
29
40
  dmConversations: number;
30
41
  dmMessages: number;
31
42
  profiles: number;
43
+ mediaFiles: ArchiveMediaFileCounts;
44
+ followers: number;
45
+ following: number;
32
46
  };
33
47
  }
34
48
 
49
+ export const ARCHIVE_IMPORT_SLICES = [
50
+ "tweets",
51
+ "likes",
52
+ "bookmarks",
53
+ "directMessages",
54
+ "profiles",
55
+ "followers",
56
+ "following",
57
+ ] as const;
58
+
59
+ export type ArchiveImportSlice = (typeof ARCHIVE_IMPORT_SLICES)[number];
60
+
61
+ export interface ImportArchiveOptions {
62
+ select?: ArchiveImportSlice[];
63
+ }
64
+
35
65
  type ArchiveRecord = Record<string, unknown>;
66
+ type ArchiveMediaKind =
67
+ | "tweets"
68
+ | "dms"
69
+ | "community"
70
+ | "profile"
71
+ | "deleted"
72
+ | "moments"
73
+ | "dmGroup";
74
+ type ArchiveMediaFileCounts = Record<ArchiveMediaKind, number>;
75
+
76
+ const ARCHIVE_MEDIA_DIRECTORIES: Array<{
77
+ directory: string;
78
+ kind: ArchiveMediaKind;
79
+ }> = [
80
+ { directory: "tweets_media", kind: "tweets" },
81
+ { directory: "direct_messages_media", kind: "dms" },
82
+ { directory: "community_tweet_media", kind: "community" },
83
+ { directory: "deleted_tweets_media", kind: "deleted" },
84
+ { directory: "profile_media", kind: "profile" },
85
+ { directory: "moments_tweets_media", kind: "moments" },
86
+ { directory: "direct_messages_group_media", kind: "dmGroup" },
87
+ ];
88
+ type ArchiveFollowDirection = "followers" | "following";
89
+ type ArchiveFollowKey = "follower" | "following";
36
90
 
37
91
  function normalizeArchivePath(value: string) {
38
92
  return value.replaceAll("\\", "/");
@@ -77,6 +131,23 @@ async function listArchiveEntries(archivePath: string) {
77
131
  .filter((item) => item.length > 0);
78
132
  }
79
133
 
134
+ async function listArchiveEntryDetails(archivePath: string) {
135
+ const stdout = await runUnzip(
136
+ archivePath,
137
+ ["-Z", "-l", archivePath],
138
+ 1024 * 1024 * 64,
139
+ );
140
+ return stdout
141
+ .split("\n")
142
+ .map((line) => line.trim().split(/\s+/))
143
+ .filter((parts) => parts.length >= 10 && /^[-d]/.test(parts[0] ?? ""))
144
+ .map((parts) => ({
145
+ path: parts.slice(9).join(" "),
146
+ size: Number(parts[3] ?? 0),
147
+ }))
148
+ .filter((entry) => entry.path.length > 0 && Number.isFinite(entry.size));
149
+ }
150
+
80
151
  async function readArchiveEntry(archivePath: string, entryPath: string) {
81
152
  return runUnzip(archivePath, ["-p", archivePath, entryPath]);
82
153
  }
@@ -89,6 +160,19 @@ function getMatchingEntries(entries: string[], pattern: RegExp) {
89
160
  return entries.filter((entry) => pattern.test(normalizeArchivePath(entry)));
90
161
  }
91
162
 
163
+ function selectedSlices(options: ImportArchiveOptions) {
164
+ return options.select && options.select.length > 0
165
+ ? new Set<ArchiveImportSlice>(options.select)
166
+ : null;
167
+ }
168
+
169
+ function includesSlice(
170
+ selection: Set<ArchiveImportSlice> | null,
171
+ slice: ArchiveImportSlice,
172
+ ) {
173
+ return selection === null || selection.has(slice);
174
+ }
175
+
92
176
  function parseTwitterDate(value: unknown) {
93
177
  if (typeof value !== "string" || value.length === 0) {
94
178
  return new Date(0).toISOString();
@@ -127,9 +211,19 @@ function getTweetMediaCount(tweet: Record<string, unknown>) {
127
211
  return Math.max(entitiesMedia.length, extendedMedia.length);
128
212
  }
129
213
 
214
+ function toFiniteNumber(value: unknown) {
215
+ const number = Number(value);
216
+ return Number.isFinite(number) ? number : undefined;
217
+ }
218
+
130
219
  function extractTweetEntities(tweet: Record<string, unknown>) {
131
220
  const entities = asRecord(tweet.entities);
132
- const urls = asArray<Record<string, unknown>>(entities?.urls)
221
+ const urlEntries = [
222
+ ...asArray<Record<string, unknown>>(entities?.urls),
223
+ ...asArray<Record<string, unknown>>(entities?.media),
224
+ ];
225
+ const seenUrls = new Set<string>();
226
+ const urls = urlEntries
133
227
  .map((entry) => ({
134
228
  url: String(entry.url ?? ""),
135
229
  expandedUrl: String(
@@ -147,8 +241,32 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
147
241
  title: typeof entry.title === "string" ? entry.title : undefined,
148
242
  description:
149
243
  typeof entry.description === "string" ? entry.description : null,
244
+ imageUrl:
245
+ typeof entry.image_url === "string"
246
+ ? entry.image_url
247
+ : typeof entry.imageUrl === "string"
248
+ ? entry.imageUrl
249
+ : typeof entry.thumbnail_url === "string"
250
+ ? entry.thumbnail_url
251
+ : typeof entry.media_url_https === "string"
252
+ ? entry.media_url_https
253
+ : typeof entry.media_url === "string"
254
+ ? entry.media_url
255
+ : undefined,
256
+ siteName:
257
+ typeof entry.site_name === "string"
258
+ ? entry.site_name
259
+ : typeof entry.siteName === "string"
260
+ ? entry.siteName
261
+ : undefined,
150
262
  }))
151
- .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0);
263
+ .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0)
264
+ .filter((entry) => {
265
+ const key = `${entry.start}:${entry.end}:${entry.url}:${entry.expandedUrl}`;
266
+ if (seenUrls.has(key)) return false;
267
+ seenUrls.add(key);
268
+ return true;
269
+ });
152
270
  const mentions = asArray<Record<string, unknown>>(entities?.user_mentions)
153
271
  .map((entry) => ({
154
272
  username: String(entry.screen_name ?? ""),
@@ -172,6 +290,61 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
172
290
  };
173
291
  }
174
292
 
293
+ function archiveMediaType(value: unknown) {
294
+ const type = String(value ?? "image");
295
+ return type === "photo"
296
+ ? "image"
297
+ : type === "video" || type === "animated_gif"
298
+ ? type === "animated_gif"
299
+ ? "gif"
300
+ : "video"
301
+ : "unknown";
302
+ }
303
+
304
+ function archiveMediaSize(entry: Record<string, unknown>) {
305
+ const sizes = asRecord(entry.sizes);
306
+ const large = asRecord(sizes?.large);
307
+ const largeWidth = toFiniteNumber(large?.w ?? large?.width);
308
+ const largeHeight = toFiniteNumber(large?.h ?? large?.height);
309
+ if (largeWidth !== undefined && largeHeight !== undefined) {
310
+ return { width: largeWidth, height: largeHeight };
311
+ }
312
+
313
+ return Object.values(sizes ?? {})
314
+ .map((size) => asRecord(size))
315
+ .map((size) => ({
316
+ width: toFiniteNumber(size?.w ?? size?.width),
317
+ height: toFiniteNumber(size?.h ?? size?.height),
318
+ }))
319
+ .filter(
320
+ (size): size is { width: number; height: number } =>
321
+ size.width !== undefined && size.height !== undefined,
322
+ )
323
+ .sort(
324
+ (left, right) => right.width * right.height - left.width * left.height,
325
+ )[0];
326
+ }
327
+
328
+ function archiveMp4Variants(entry: Record<string, unknown>) {
329
+ const videoInfo = asRecord(entry.video_info);
330
+ return asArray<Record<string, unknown>>(videoInfo?.variants)
331
+ .filter(
332
+ (variant) =>
333
+ variant.content_type === "video/mp4" && typeof variant.url === "string",
334
+ )
335
+ .map((variant) => {
336
+ const bitRate = toFiniteNumber(variant.bitrate ?? variant.bit_rate);
337
+ return {
338
+ url: String(variant.url),
339
+ contentType: String(variant.content_type),
340
+ ...(bitRate !== undefined ? { bitRate } : {}),
341
+ };
342
+ })
343
+ .sort(
344
+ (left, right) => Number(right.bitRate ?? 0) - Number(left.bitRate ?? 0),
345
+ );
346
+ }
347
+
175
348
  function extractTweetMedia(tweet: Record<string, unknown>) {
176
349
  const extendedEntities = asRecord(tweet.extended_entities);
177
350
  const entities = asRecord(tweet.entities);
@@ -189,22 +362,20 @@ function extractTweetMedia(tweet: Record<string, unknown>) {
189
362
  const thumbnailUrl = String(
190
363
  entry.media_url_https ?? entry.media_url ?? url,
191
364
  );
192
- const type = String(entry.type ?? "image");
365
+ const videoInfo = asRecord(entry.video_info);
366
+ const durationMs = toFiniteNumber(videoInfo?.duration_millis);
367
+ const variants = archiveMp4Variants(entry);
193
368
  return {
194
369
  url,
195
- type:
196
- type === "photo"
197
- ? "image"
198
- : type === "video" || type === "animated_gif"
199
- ? type === "animated_gif"
200
- ? "gif"
201
- : "video"
202
- : "unknown",
370
+ type: archiveMediaType(entry.type),
203
371
  altText:
204
372
  typeof entry.ext_alt_text === "string"
205
373
  ? entry.ext_alt_text
206
374
  : undefined,
207
375
  thumbnailUrl,
376
+ ...archiveMediaSize(entry),
377
+ ...(durationMs !== undefined ? { durationMs } : {}),
378
+ ...(variants.length > 0 ? { variants } : {}),
208
379
  };
209
380
  })
210
381
  .filter((entry) => {
@@ -281,6 +452,148 @@ function inferProfileFromDirectory(
281
452
  return { handle, displayName };
282
453
  }
283
454
 
455
+ function createArchiveMediaFileCounts(): ArchiveMediaFileCounts {
456
+ return {
457
+ tweets: 0,
458
+ dms: 0,
459
+ community: 0,
460
+ profile: 0,
461
+ deleted: 0,
462
+ moments: 0,
463
+ dmGroup: 0,
464
+ };
465
+ }
466
+
467
+ function selectedArchiveMediaKinds(selection: Set<ArchiveImportSlice> | null) {
468
+ if (!selection) return null;
469
+ const kinds = new Set<ArchiveMediaKind>();
470
+ if (selection.has("tweets")) {
471
+ for (const kind of ["tweets", "community", "deleted", "moments"] as const) {
472
+ kinds.add(kind);
473
+ }
474
+ }
475
+ if (selection.has("directMessages")) {
476
+ for (const kind of ["dms", "dmGroup"] as const) {
477
+ kinds.add(kind);
478
+ }
479
+ }
480
+ if (selection.has("profiles")) {
481
+ kinds.add("profile");
482
+ }
483
+ return kinds;
484
+ }
485
+
486
+ function getArchiveMediaKind(entryPath: string) {
487
+ const normalized = normalizeArchivePath(entryPath);
488
+ if (normalized.endsWith("/")) return undefined;
489
+ return ARCHIVE_MEDIA_DIRECTORIES.find(({ directory }) =>
490
+ new RegExp(`(?:^|/)data/${directory}/[^/]+$`).test(normalized),
491
+ );
492
+ }
493
+
494
+ function getArchiveMediaOwnerId(entryPath: string) {
495
+ const fileName = path.posix.basename(normalizeArchivePath(entryPath));
496
+ const separator = fileName.indexOf("-");
497
+ return separator > 0 ? fileName.slice(0, separator) : "unknown";
498
+ }
499
+
500
+ function getArchiveMediaDestination(entryPath: string, kind: ArchiveMediaKind) {
501
+ const { mediaOriginalsDir } = getBirdclawPaths();
502
+ const normalized = normalizeArchivePath(entryPath);
503
+ const fileName = path.posix.basename(normalized);
504
+ return path.join(
505
+ mediaOriginalsDir,
506
+ "archive",
507
+ kind,
508
+ getArchiveMediaOwnerId(normalized),
509
+ fileName,
510
+ );
511
+ }
512
+
513
+ function needsArchiveMediaCopy(destinationPath: string, size: number) {
514
+ if (!existsSync(destinationPath)) return true;
515
+ return statSync(destinationPath).size !== size;
516
+ }
517
+
518
+ async function copyArchiveEntryToFile(
519
+ archivePath: string,
520
+ entryPath: string,
521
+ destinationPath: string,
522
+ ) {
523
+ mkdirSync(path.dirname(destinationPath), { recursive: true });
524
+ const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`;
525
+ const child = spawn("unzip", ["-p", archivePath, entryPath], {
526
+ stdio: ["ignore", "pipe", "pipe"],
527
+ });
528
+ let stderr = "";
529
+ child.stderr.setEncoding("utf8");
530
+ child.stderr.on("data", (chunk) => {
531
+ stderr += String(chunk);
532
+ });
533
+ const exit = new Promise<number | null>((resolve, reject) => {
534
+ child.on("error", reject);
535
+ child.on("close", resolve);
536
+ });
537
+
538
+ try {
539
+ await pipeline(child.stdout, createWriteStream(temporaryPath));
540
+ const exitCode = await exit;
541
+ if (exitCode !== 0) {
542
+ throw new Error(
543
+ `Failed to extract ${entryPath}: ${stderr.trim() || `exit ${String(exitCode)}`}`,
544
+ );
545
+ }
546
+ renameSync(temporaryPath, destinationPath);
547
+ } catch (error) {
548
+ child.kill();
549
+ if (existsSync(temporaryPath)) {
550
+ unlinkSync(temporaryPath);
551
+ }
552
+ throw error;
553
+ }
554
+ }
555
+
556
+ async function extractArchiveMediaFiles(
557
+ archivePath: string,
558
+ selectedKinds: Set<ArchiveMediaKind> | null,
559
+ ) {
560
+ const counts = createArchiveMediaFileCounts();
561
+ if (selectedKinds?.size === 0) {
562
+ return counts;
563
+ }
564
+ for (const entry of await listArchiveEntryDetails(archivePath)) {
565
+ const mediaKind = getArchiveMediaKind(entry.path);
566
+ if (!mediaKind) continue;
567
+ if (selectedKinds && !selectedKinds.has(mediaKind.kind)) continue;
568
+
569
+ counts[mediaKind.kind] += 1;
570
+ const destinationPath = getArchiveMediaDestination(
571
+ entry.path,
572
+ mediaKind.kind,
573
+ );
574
+ if (!needsArchiveMediaCopy(destinationPath, entry.size)) continue;
575
+ await copyArchiveEntryToFile(archivePath, entry.path, destinationPath);
576
+ }
577
+ return counts;
578
+ }
579
+
580
+ function getArchiveFollowRows(content: string, key: ArchiveFollowKey) {
581
+ const rows: Array<{ profileId: string; externalUserId: string }> = [];
582
+
583
+ for (const wrapper of parseArchiveArray(content)) {
584
+ const item = asRecord(wrapper[key]);
585
+ const externalUserId = String(item?.accountId ?? "");
586
+ if (!externalUserId) continue;
587
+
588
+ rows.push({
589
+ profileId: `profile_user_${externalUserId}`,
590
+ externalUserId,
591
+ });
592
+ }
593
+
594
+ return rows;
595
+ }
596
+
284
597
  function clearImportedData() {
285
598
  const db = getNativeDb();
286
599
  db.exec(`
@@ -298,34 +611,75 @@ function clearImportedData() {
298
611
  delete from profiles;
299
612
  delete from accounts;
300
613
  `);
614
+ clearAuthoredSyncCursors(db);
615
+ }
616
+
617
+ function clearAuthoredSyncCursors(db = getNativeDb(), accountId?: string) {
618
+ if (accountId) {
619
+ db.prepare("delete from sync_cache where cache_key = ?").run(
620
+ `authored:xurl:${accountId}:cursor`,
621
+ );
622
+ return;
623
+ }
624
+ db.prepare(
625
+ "delete from sync_cache where cache_key like 'authored:xurl:%:cursor'",
626
+ ).run();
627
+ }
628
+
629
+ function clearMentionSyncState(db = getNativeDb()) {
630
+ db.prepare(
631
+ "delete from sync_cache where cache_key like 'mentions:sync:%'",
632
+ ).run();
301
633
  }
302
634
 
303
635
  export async function importArchive(
304
636
  archivePath: string,
637
+ options: ImportArchiveOptions = {},
305
638
  ): Promise<ImportedArchiveSummary> {
306
639
  const entries = await listArchiveEntries(archivePath);
640
+ const selection = selectedSlices(options);
641
+ const includeTweets = includesSlice(selection, "tweets");
642
+ const includeLikes = includesSlice(selection, "likes");
643
+ const includeBookmarks = includesSlice(selection, "bookmarks");
644
+ const includeDirectMessages = includesSlice(selection, "directMessages");
645
+ const includeProfiles = includesSlice(selection, "profiles");
646
+ const includeFollowers = includesSlice(selection, "followers");
647
+ const includeFollowing = includesSlice(selection, "following");
307
648
  const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
308
649
  const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
309
- const tweetEntries = getMatchingEntries(
310
- entries,
311
- /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
312
- );
313
- const noteTweetEntries = getMatchingEntries(
314
- entries,
315
- /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i,
316
- );
317
- const likeEntries = getMatchingEntries(
318
- entries,
319
- /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
320
- );
321
- const bookmarkEntries = getMatchingEntries(
322
- entries,
323
- /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
324
- );
325
- const dmEntries = getMatchingEntries(
326
- entries,
327
- /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
328
- );
650
+ const tweetEntries = includeTweets
651
+ ? getMatchingEntries(
652
+ entries,
653
+ /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
654
+ )
655
+ : [];
656
+ const noteTweetEntries = includeTweets
657
+ ? getMatchingEntries(entries, /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i)
658
+ : [];
659
+ const likeEntries = includeLikes
660
+ ? getMatchingEntries(
661
+ entries,
662
+ /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
663
+ )
664
+ : [];
665
+ const bookmarkEntries = includeBookmarks
666
+ ? getMatchingEntries(
667
+ entries,
668
+ /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
669
+ )
670
+ : [];
671
+ const dmEntries = includeDirectMessages
672
+ ? getMatchingEntries(
673
+ entries,
674
+ /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
675
+ )
676
+ : [];
677
+ const followerEntries = includeFollowers
678
+ ? getMatchingEntries(entries, /(?:^|\/)data\/follower(?:-part\d+)?\.js$/i)
679
+ : [];
680
+ const followingEntries = includeFollowing
681
+ ? getMatchingEntries(entries, /(?:^|\/)data\/following(?:-part\d+)?\.js$/i)
682
+ : [];
329
683
 
330
684
  if (!accountEntry) {
331
685
  throw new Error("Archive missing data/account.js");
@@ -480,10 +834,27 @@ export async function importArchive(
480
834
  bio: string;
481
835
  followersCount: number;
482
836
  followingCount: number;
837
+ publicMetricsJson: string;
483
838
  avatarHue: number;
839
+ avatarUrl: string | null;
840
+ location: string | null;
841
+ url: string | null;
842
+ verifiedType: string | null;
843
+ entitiesJson: string;
844
+ rawJson: string;
484
845
  createdAt: string;
485
846
  }
486
847
  >();
848
+ type ProfileRow =
849
+ typeof profiles extends Map<string, infer Value> ? Value : never;
850
+ const defaultProfileMetadata = {
851
+ publicMetricsJson: "{}",
852
+ location: null,
853
+ url: null,
854
+ verifiedType: null,
855
+ entitiesJson: "{}",
856
+ rawJson: "{}",
857
+ };
487
858
  const conversations = new Map<
488
859
  string,
489
860
  {
@@ -497,27 +868,382 @@ export async function importArchive(
497
868
  }
498
869
  >();
499
870
  const dmMessages: MessageRow[] = [];
500
-
501
- const localProfile = {
502
- id: "profile_me",
503
- handle: accountPayload.username,
504
- displayName: accountPayload.displayName,
505
- bio: accountPayload.bio,
506
- followersCount: 0,
507
- followingCount: 0,
508
- avatarHue: 18,
509
- createdAt: accountPayload.createdAt,
871
+ const followerRows: Array<{ profileId: string; externalUserId: string }> = [];
872
+ const followingRows: Array<{ profileId: string; externalUserId: string }> =
873
+ [];
874
+ const followerIds = new Set<string>();
875
+ const followingIds = new Set<string>();
876
+ type ExistingProfileRow = {
877
+ id: string;
878
+ handle: string;
879
+ display_name: string;
880
+ bio: string;
881
+ followers_count: number;
882
+ following_count: number;
883
+ public_metrics_json: string;
884
+ avatar_hue: number;
885
+ avatar_url: string | null;
886
+ location: string | null;
887
+ url: string | null;
888
+ verified_type: string | null;
889
+ entities_json: string;
890
+ raw_json: string;
891
+ created_at: string;
892
+ };
893
+ const existingProfiles = new Map(
894
+ (
895
+ getNativeDb()
896
+ .prepare(
897
+ `
898
+ select id, handle, display_name, bio, followers_count, following_count,
899
+ public_metrics_json, avatar_hue, avatar_url, location, url,
900
+ verified_type, entities_json, raw_json, created_at
901
+ from profiles
902
+ `,
903
+ )
904
+ .all() as ExistingProfileRow[]
905
+ ).map((profile) => [profile.id, profile]),
906
+ );
907
+ const existingProfilesByHandle = new Map(
908
+ [...existingProfiles.values()].map((profile) => [
909
+ profile.handle.toLowerCase(),
910
+ profile,
911
+ ]),
912
+ );
913
+ const existingPrimaryAccount = getNativeDb()
914
+ .prepare("select handle, external_user_id from accounts where id = ?")
915
+ .get("acct_primary") as
916
+ | { handle: string; external_user_id: string | null }
917
+ | undefined;
918
+ const profileIdAliases = new Map<string, string>();
919
+
920
+ type ArchiveProfileTier =
921
+ | "archive_follow_stub"
922
+ | "archive_dm_stub"
923
+ | "archive_mention_inferred"
924
+ | "live_or_hydrated";
925
+ const archiveProfileTierRank: Record<ArchiveProfileTier, number> = {
926
+ archive_follow_stub: 0,
927
+ archive_dm_stub: 1,
928
+ archive_mention_inferred: 2,
929
+ live_or_hydrated: 3,
510
930
  };
931
+
932
+ function classifyExistingProfile(profile: ProfileRow): ArchiveProfileTier {
933
+ const externalUserId = profile.id.startsWith("profile_user_")
934
+ ? profile.id.slice("profile_user_".length)
935
+ : "";
936
+ const fallbackHandle = externalUserId ? `id${externalUserId}` : profile.id;
937
+ const hasLiveSignals =
938
+ profile.followersCount > 0 ||
939
+ profile.followingCount > 0 ||
940
+ profile.publicMetricsJson.trim() !== "{}" ||
941
+ profile.avatarUrl !== null ||
942
+ profile.location !== null ||
943
+ profile.url !== null ||
944
+ profile.verifiedType !== null ||
945
+ profile.entitiesJson.trim() !== "{}" ||
946
+ profile.rawJson.trim() !== "{}";
947
+
948
+ if (hasLiveSignals) return "live_or_hydrated";
949
+ if (
950
+ profile.handle === fallbackHandle &&
951
+ profile.displayName === "" &&
952
+ profile.bio === ""
953
+ ) {
954
+ return "archive_follow_stub";
955
+ }
956
+ if (profile.bio.startsWith("Imported from archive user ")) {
957
+ return profile.handle === fallbackHandle &&
958
+ profile.displayName === fallbackHandle
959
+ ? "archive_dm_stub"
960
+ : "archive_mention_inferred";
961
+ }
962
+ return profile.handle === fallbackHandle && profile.displayName === ""
963
+ ? "archive_follow_stub"
964
+ : "archive_mention_inferred";
965
+ }
966
+
967
+ function shouldPreserveProfile(
968
+ existingTier: ArchiveProfileTier,
969
+ incomingTier: ArchiveProfileTier,
970
+ ) {
971
+ return (
972
+ archiveProfileTierRank[existingTier] >=
973
+ archiveProfileTierRank[incomingTier]
974
+ );
975
+ }
976
+
977
+ function existingProfileToProfileRow(
978
+ profile: ExistingProfileRow,
979
+ ): ProfileRow {
980
+ return {
981
+ id: profile.id,
982
+ handle: profile.handle,
983
+ displayName: profile.display_name,
984
+ bio: profile.bio,
985
+ followersCount: profile.followers_count,
986
+ followingCount: profile.following_count,
987
+ publicMetricsJson: profile.public_metrics_json,
988
+ avatarHue: profile.avatar_hue,
989
+ avatarUrl: profile.avatar_url,
990
+ location: profile.location,
991
+ url: profile.url,
992
+ verifiedType: profile.verified_type,
993
+ entitiesJson: profile.entities_json,
994
+ rawJson: profile.raw_json,
995
+ createdAt: profile.created_at,
996
+ };
997
+ }
998
+
999
+ function mergeArchiveProfile(incoming: ProfileRow) {
1000
+ const existingById = existingProfiles.get(incoming.id);
1001
+ const existingByHandle = selection
1002
+ ? existingProfilesByHandle.get(incoming.handle.toLowerCase())
1003
+ : undefined;
1004
+ const targetExisting = existingById ?? existingByHandle;
1005
+ const targetId = targetExisting?.id ?? incoming.id;
1006
+ if (targetId !== incoming.id) {
1007
+ profileIdAliases.set(incoming.id, targetId);
1008
+ }
1009
+ const targetIncoming =
1010
+ targetId === incoming.id ? incoming : { ...incoming, id: targetId };
1011
+ const incomingTier = classifyExistingProfile(incoming);
1012
+ const current = profiles.get(targetId);
1013
+ const currentTier = current ? classifyExistingProfile(current) : null;
1014
+ const existingProfile = targetExisting
1015
+ ? existingProfileToProfileRow(targetExisting)
1016
+ : null;
1017
+ const existingTier = existingProfile
1018
+ ? classifyExistingProfile(existingProfile)
1019
+ : null;
1020
+
1021
+ if (
1022
+ current &&
1023
+ currentTier &&
1024
+ shouldPreserveProfile(currentTier, incomingTier) &&
1025
+ (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1026
+ ) {
1027
+ return;
1028
+ }
1029
+
1030
+ if (
1031
+ existingProfile &&
1032
+ existingTier &&
1033
+ shouldPreserveProfile(existingTier, incomingTier)
1034
+ ) {
1035
+ profiles.set(targetId, existingProfile);
1036
+ return;
1037
+ }
1038
+
1039
+ profiles.set(targetId, targetIncoming);
1040
+ }
1041
+
1042
+ function resolveProfileId(profileId: string) {
1043
+ return profileIdAliases.get(profileId) ?? profileId;
1044
+ }
1045
+
1046
+ function isProfileHandleTakenByOtherId(handle: string, profileId: string) {
1047
+ const normalizedHandle = handle.toLowerCase();
1048
+ const existingProfile = existingProfilesByHandle.get(normalizedHandle);
1049
+ if (existingProfile && existingProfile.id !== profileId) return true;
1050
+ for (const profile of profiles.values()) {
1051
+ if (
1052
+ profile.id !== profileId &&
1053
+ profile.handle.toLowerCase() === normalizedHandle
1054
+ ) {
1055
+ return true;
1056
+ }
1057
+ }
1058
+ return false;
1059
+ }
1060
+
1061
+ function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1062
+ if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1063
+ return baseHandle;
1064
+ }
1065
+ let index = 1;
1066
+ while (true) {
1067
+ const suffix = index === 1 ? "archive" : `archive_${index}`;
1068
+ const candidate = `${baseHandle}_${suffix}`;
1069
+ if (!isProfileHandleTakenByOtherId(candidate, profileId)) {
1070
+ return candidate;
1071
+ }
1072
+ index += 1;
1073
+ }
1074
+ }
1075
+
1076
+ function addArchiveFollowProfile(profileId: string, externalUserId: string) {
1077
+ if (!profileId) return;
1078
+ const fallbackId =
1079
+ externalUserId || profileId.replace(/^profile_user_/, "");
1080
+ mergeArchiveProfile({
1081
+ id: profileId,
1082
+ handle: fallbackId ? `id${fallbackId}` : profileId,
1083
+ displayName: "",
1084
+ bio: "",
1085
+ followersCount: 0,
1086
+ followingCount: 0,
1087
+ ...defaultProfileMetadata,
1088
+ avatarHue: 210,
1089
+ avatarUrl: null,
1090
+ createdAt: accountPayload.createdAt,
1091
+ });
1092
+ }
1093
+
1094
+ function assertSelectedAccountMatchesArchive() {
1095
+ if (!selection || !existingPrimaryAccount) return;
1096
+ const existingExternalUserId = existingPrimaryAccount.external_user_id;
1097
+ if (
1098
+ existingExternalUserId &&
1099
+ existingExternalUserId !== accountPayload.accountId
1100
+ ) {
1101
+ throw new Error(
1102
+ `Existing acct_primary (${existingExternalUserId}) does not match archive account ${accountPayload.accountId}`,
1103
+ );
1104
+ }
1105
+ const existingHandle = existingPrimaryAccount.handle
1106
+ .replace(/^@/, "")
1107
+ .toLowerCase();
1108
+ if (
1109
+ !existingExternalUserId &&
1110
+ existingHandle !== accountPayload.username.toLowerCase()
1111
+ ) {
1112
+ throw new Error(
1113
+ `Existing acct_primary (@${existingHandle}) does not match archive account @${accountPayload.username}`,
1114
+ );
1115
+ }
1116
+ }
1117
+
1118
+ assertSelectedAccountMatchesArchive();
1119
+
1120
+ const existingLocalProfile =
1121
+ selection &&
1122
+ (existingProfiles.get("profile_me") ??
1123
+ [...existingProfiles.values()].find(
1124
+ (profile) =>
1125
+ profile.handle.toLowerCase() ===
1126
+ accountPayload.username.toLowerCase(),
1127
+ ));
1128
+ const archivedLocalProfile = existingLocalProfile
1129
+ ? {
1130
+ ...existingProfileToProfileRow(existingLocalProfile),
1131
+ handle: accountPayload.username,
1132
+ displayName: accountPayload.displayName,
1133
+ bio: accountPayload.bio,
1134
+ createdAt: accountPayload.createdAt,
1135
+ }
1136
+ : {
1137
+ id: "profile_me",
1138
+ handle: accountPayload.username,
1139
+ displayName: accountPayload.displayName,
1140
+ bio: accountPayload.bio,
1141
+ followersCount: 0,
1142
+ followingCount: 0,
1143
+ ...defaultProfileMetadata,
1144
+ avatarHue: 18,
1145
+ avatarUrl: null,
1146
+ createdAt: accountPayload.createdAt,
1147
+ };
1148
+ const localProfile =
1149
+ existingLocalProfile && !includeProfiles
1150
+ ? existingProfileToProfileRow(existingLocalProfile)
1151
+ : archivedLocalProfile;
511
1152
  profiles.set(localProfile.id, localProfile);
512
1153
 
1154
+ const existingDmConversationAccounts = new Map(
1155
+ (
1156
+ getNativeDb()
1157
+ .prepare("select id, account_id from dm_conversations")
1158
+ .all() as Array<{ id: string; account_id: string }>
1159
+ ).map((row) => [row.id, row.account_id]),
1160
+ );
1161
+ const existingOtherDmMessageIds = new Set(
1162
+ (
1163
+ getNativeDb()
1164
+ .prepare(
1165
+ `
1166
+ select m.id
1167
+ from dm_messages m
1168
+ join dm_conversations c on c.id = m.conversation_id
1169
+ where c.account_id <> 'acct_primary'
1170
+ `,
1171
+ )
1172
+ .all() as Array<{ id: string }>
1173
+ ).map((row) => row.id),
1174
+ );
1175
+ const archiveDmConversationIdAliases = new Map<string, string>();
1176
+ const archiveDmMessageIdAliases = new Map<string, string>();
1177
+
1178
+ function uniquePrimaryArchiveId(
1179
+ baseId: string,
1180
+ isTakenByOtherAccount: (candidate: string) => boolean,
1181
+ isPending: (candidate: string) => boolean,
1182
+ ) {
1183
+ let index = 1;
1184
+ while (true) {
1185
+ const suffix = index === 1 ? "" : `:${index}`;
1186
+ const candidate = `acct_primary:${baseId}${suffix}`;
1187
+ if (!isTakenByOtherAccount(candidate) && !isPending(candidate)) {
1188
+ return candidate;
1189
+ }
1190
+ index += 1;
1191
+ }
1192
+ }
1193
+
1194
+ function resolveArchiveDmConversationId(conversationId: string) {
1195
+ const existingAlias = archiveDmConversationIdAliases.get(conversationId);
1196
+ if (existingAlias) return existingAlias;
1197
+ if (!selection) {
1198
+ archiveDmConversationIdAliases.set(conversationId, conversationId);
1199
+ return conversationId;
1200
+ }
1201
+
1202
+ const takenByOtherAccount = (candidate: string) => {
1203
+ const accountId = existingDmConversationAccounts.get(candidate);
1204
+ return accountId !== undefined && accountId !== "acct_primary";
1205
+ };
1206
+ const resolved = takenByOtherAccount(conversationId)
1207
+ ? uniquePrimaryArchiveId(
1208
+ conversationId,
1209
+ takenByOtherAccount,
1210
+ (candidate) => conversations.has(candidate),
1211
+ )
1212
+ : conversationId;
1213
+ archiveDmConversationIdAliases.set(conversationId, resolved);
1214
+ return resolved;
1215
+ }
1216
+
1217
+ function resolveArchiveDmMessageId(
1218
+ messageId: string,
1219
+ conversationIdChanged: boolean,
1220
+ ) {
1221
+ const existingAlias = archiveDmMessageIdAliases.get(messageId);
1222
+ if (existingAlias) return existingAlias;
1223
+ const shouldRemap =
1224
+ selection &&
1225
+ (conversationIdChanged || existingOtherDmMessageIds.has(messageId));
1226
+ const resolved = shouldRemap
1227
+ ? uniquePrimaryArchiveId(
1228
+ messageId,
1229
+ (candidate) => existingOtherDmMessageIds.has(candidate),
1230
+ (candidate) => dmMessages.some((message) => message.id === candidate),
1231
+ )
1232
+ : messageId;
1233
+ archiveDmMessageIdAliases.set(messageId, resolved);
1234
+ return resolved;
1235
+ }
1236
+
513
1237
  for (const entry of dmEntries) {
514
1238
  const content = await readArchiveEntry(archivePath, entry);
515
1239
  for (const wrapper of parseArchiveArray(content)) {
516
1240
  const dmConversation = asRecord(wrapper.dmConversation);
517
1241
  if (!dmConversation) continue;
518
1242
 
519
- const conversationId = String(dmConversation.conversationId ?? "");
520
- if (!conversationId) continue;
1243
+ const rawConversationId = String(dmConversation.conversationId ?? "");
1244
+ if (!rawConversationId) continue;
1245
+ const conversationId = resolveArchiveDmConversationId(rawConversationId);
1246
+ const conversationIdChanged = conversationId !== rawConversationId;
521
1247
 
522
1248
  const conversationName = String(dmConversation.name ?? "").trim();
523
1249
  const participantIds = new Set<string>();
@@ -589,7 +1315,9 @@ export async function importArchive(
589
1315
  bio: `Group DM with ${externalParticipantIds.length} participants`,
590
1316
  followersCount: 0,
591
1317
  followingCount: 0,
1318
+ ...defaultProfileMetadata,
592
1319
  avatarHue: 220,
1320
+ avatarUrl: null,
593
1321
  createdAt: accountPayload.createdAt,
594
1322
  });
595
1323
  } else {
@@ -598,14 +1326,16 @@ export async function importArchive(
598
1326
  otherUserId,
599
1327
  mentionDirectory,
600
1328
  );
601
- profiles.set(participantProfileId, {
1329
+ mergeArchiveProfile({
602
1330
  id: participantProfileId,
603
1331
  handle: inferred.handle,
604
1332
  displayName: inferred.displayName,
605
1333
  bio: `Imported from archive user ${otherUserId}`,
606
1334
  followersCount: 0,
607
1335
  followingCount: 0,
1336
+ ...defaultProfileMetadata,
608
1337
  avatarHue: 210,
1338
+ avatarUrl: null,
609
1339
  createdAt: accountPayload.createdAt,
610
1340
  });
611
1341
  }
@@ -616,9 +1346,12 @@ export async function importArchive(
616
1346
  .filter((event): event is Record<string, unknown> => event !== null)
617
1347
  .map((messageCreate) => {
618
1348
  const senderId = String(messageCreate.senderId ?? "");
1349
+ const rawMessageId = String(
1350
+ messageCreate.id ?? `${rawConversationId}-${senderId}`,
1351
+ );
619
1352
  const senderProfileId =
620
1353
  senderId === accountPayload.accountId
621
- ? "profile_me"
1354
+ ? localProfile.id
622
1355
  : `profile_user_${senderId}`;
623
1356
 
624
1357
  if (senderId && senderId !== accountPayload.accountId) {
@@ -627,23 +1360,25 @@ export async function importArchive(
627
1360
  mentionDirectory,
628
1361
  );
629
1362
  if (!profiles.has(senderProfileId)) {
630
- profiles.set(senderProfileId, {
1363
+ mergeArchiveProfile({
631
1364
  id: senderProfileId,
632
1365
  handle: inferred.handle,
633
1366
  displayName: inferred.displayName,
634
1367
  bio: `Imported from archive user ${senderId}`,
635
1368
  followersCount: 0,
636
1369
  followingCount: 0,
1370
+ ...defaultProfileMetadata,
637
1371
  avatarHue: 240,
1372
+ avatarUrl: null,
638
1373
  createdAt: accountPayload.createdAt,
639
1374
  });
640
1375
  }
641
1376
  }
642
1377
 
643
1378
  return {
644
- id: String(messageCreate.id ?? `${conversationId}-${senderId}`),
1379
+ id: resolveArchiveDmMessageId(rawMessageId, conversationIdChanged),
645
1380
  conversationId,
646
- senderProfileId,
1381
+ senderProfileId: resolveProfileId(senderProfileId),
647
1382
  text: String(messageCreate.text ?? ""),
648
1383
  createdAt: parseTwitterDate(messageCreate.createdAt),
649
1384
  direction:
@@ -663,14 +1398,16 @@ export async function importArchive(
663
1398
  if (!lastMessage) continue;
664
1399
 
665
1400
  dmMessages.push(...messageEvents);
1401
+ const resolvedParticipantProfileId =
1402
+ resolveProfileId(participantProfileId);
666
1403
  conversations.set(conversationId, {
667
1404
  id: conversationId,
668
1405
  title:
669
- profiles.get(participantProfileId)?.displayName ||
1406
+ profiles.get(resolvedParticipantProfileId)?.displayName ||
670
1407
  conversationName ||
671
1408
  conversationId,
672
1409
  accountId: "acct_primary",
673
- participantProfileId,
1410
+ participantProfileId: resolvedParticipantProfileId,
674
1411
  lastMessageAt: lastMessage.createdAt,
675
1412
  unreadCount: 0,
676
1413
  needsReply: lastMessage.direction === "inbound" ? 1 : 0,
@@ -746,39 +1483,197 @@ export async function importArchive(
746
1483
  bookmarkCount += bookmarks.length;
747
1484
  }
748
1485
 
1486
+ const mediaFileCounts = await extractArchiveMediaFiles(
1487
+ archivePath,
1488
+ selectedArchiveMediaKinds(selection),
1489
+ );
1490
+
1491
+ for (const entry of followerEntries) {
1492
+ const content = await readArchiveEntry(archivePath, entry);
1493
+ for (const row of getArchiveFollowRows(content, "follower")) {
1494
+ if (followerIds.has(row.externalUserId)) continue;
1495
+ followerIds.add(row.externalUserId);
1496
+ followerRows.push(row);
1497
+ }
1498
+ }
1499
+
1500
+ for (const entry of followingEntries) {
1501
+ const content = await readArchiveEntry(archivePath, entry);
1502
+ for (const row of getArchiveFollowRows(content, "following")) {
1503
+ if (followingIds.has(row.externalUserId)) continue;
1504
+ followingIds.add(row.externalUserId);
1505
+ followingRows.push(row);
1506
+ }
1507
+ }
1508
+
1509
+ for (const row of [...followerRows, ...followingRows]) {
1510
+ addArchiveFollowProfile(row.profileId, row.externalUserId);
1511
+ }
1512
+
1513
+ const clearedFollowDirections = new Set<ArchiveFollowDirection>();
1514
+ if (includeFollowers && followerEntries.length === 0) {
1515
+ clearedFollowDirections.add("followers");
1516
+ }
1517
+ if (includeFollowing && followingEntries.length === 0) {
1518
+ clearedFollowDirections.add("following");
1519
+ }
1520
+ const retainedFollowProfiles = getNativeDb()
1521
+ .prepare(
1522
+ `
1523
+ select direction, profile_id, external_user_id, source, null as snapshot_id, null as snapshot_source
1524
+ from follow_edges
1525
+ union
1526
+ select ev.direction, ev.profile_id, ev.external_user_id, null as source, ev.snapshot_id, snap.source as snapshot_source
1527
+ from follow_events ev
1528
+ left join follow_snapshots snap on snap.id = ev.snapshot_id
1529
+ `,
1530
+ )
1531
+ .all() as Array<{
1532
+ direction: ArchiveFollowDirection;
1533
+ profile_id: string;
1534
+ external_user_id: string;
1535
+ source: string | null;
1536
+ snapshot_id: string | null;
1537
+ snapshot_source: string | null;
1538
+ }>;
1539
+ for (const row of retainedFollowProfiles) {
1540
+ const isClearedArchiveRow =
1541
+ clearedFollowDirections.has(row.direction) &&
1542
+ (row.source === "archive" ||
1543
+ row.snapshot_source === "archive" ||
1544
+ row.snapshot_id ===
1545
+ `follow_snapshot_archive_acct_primary_${row.direction}`);
1546
+ if (isClearedArchiveRow) continue;
1547
+ addArchiveFollowProfile(row.profile_id, row.external_user_id);
1548
+ }
1549
+
749
1550
  if (tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")) {
750
- profiles.set("profile_unknown", {
1551
+ const unknownProfile = {
751
1552
  id: "profile_unknown",
752
- handle: "unknown",
1553
+ handle: selection
1554
+ ? uniqueArchiveProfileHandle("unknown", "profile_unknown")
1555
+ : "unknown",
753
1556
  displayName: "Unknown",
754
1557
  bio: "Imported from archive collection metadata",
755
1558
  followersCount: 0,
756
1559
  followingCount: 0,
1560
+ ...defaultProfileMetadata,
757
1561
  avatarHue: 210,
1562
+ avatarUrl: null,
758
1563
  createdAt: accountPayload.createdAt,
759
- });
1564
+ };
1565
+ const existingUnknownProfile = existingProfiles.get("profile_unknown");
1566
+ profiles.set(
1567
+ "profile_unknown",
1568
+ existingUnknownProfile
1569
+ ? existingProfileToProfileRow(existingUnknownProfile)
1570
+ : unknownProfile,
1571
+ );
760
1572
  }
761
1573
 
762
- clearImportedData();
1574
+ if (!selection) {
1575
+ clearImportedData();
1576
+ clearMentionSyncState();
1577
+ }
763
1578
 
764
1579
  const db = getNativeDb();
765
1580
  const insertAccount = db.prepare(`
766
1581
  insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
767
1582
  values (?, ?, ?, ?, ?, 1, ?)
1583
+ on conflict(id) do update set
1584
+ name = excluded.name,
1585
+ handle = excluded.handle,
1586
+ external_user_id = excluded.external_user_id,
1587
+ transport = excluded.transport,
1588
+ is_default = 1,
1589
+ created_at = excluded.created_at
768
1590
  `);
769
- const insertProfile = db.prepare(`
770
- insert into profiles (id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url, created_at)
771
- values (?, ?, ?, ?, ?, ?, ?, ?, ?)
1591
+ const insertAccountIfMissing = db.prepare(`
1592
+ insert or ignore into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1593
+ values (?, ?, ?, ?, ?, 1, ?)
772
1594
  `);
1595
+ const insertProfile = db.prepare(`
1596
+ insert into profiles (
1597
+ id, handle, display_name, bio, followers_count, following_count,
1598
+ public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
1599
+ entities_json, raw_json, created_at
1600
+ )
1601
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1602
+ on conflict(id) do update set
1603
+ handle = excluded.handle,
1604
+ display_name = excluded.display_name,
1605
+ bio = excluded.bio,
1606
+ followers_count = excluded.followers_count,
1607
+ following_count = excluded.following_count,
1608
+ public_metrics_json = excluded.public_metrics_json,
1609
+ avatar_hue = excluded.avatar_hue,
1610
+ avatar_url = excluded.avatar_url,
1611
+ location = excluded.location,
1612
+ url = excluded.url,
1613
+ verified_type = excluded.verified_type,
1614
+ entities_json = excluded.entities_json,
1615
+ raw_json = excluded.raw_json,
1616
+ created_at = excluded.created_at
1617
+ `);
1618
+ const insertProfileIfMissing = db.prepare(`
1619
+ insert or ignore into profiles (
1620
+ id, handle, display_name, bio, followers_count, following_count,
1621
+ public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
1622
+ entities_json, raw_json, created_at
1623
+ )
1624
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1625
+ `);
773
1626
  const insertTweet = db.prepare(`
774
1627
  insert into tweets (
775
1628
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
776
1629
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
777
1630
  ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1631
+ on conflict(id) do update set
1632
+ account_id = tweets.account_id,
1633
+ author_profile_id = case
1634
+ when tweets.author_profile_id = 'profile_unknown' then excluded.author_profile_id
1635
+ else tweets.author_profile_id
1636
+ end,
1637
+ kind = case
1638
+ when tweets.kind in ('home', 'mention', 'authored') and excluded.kind in ('like', 'bookmark')
1639
+ then tweets.kind
1640
+ else excluded.kind
1641
+ end,
1642
+ text = case
1643
+ when excluded.kind in ('like', 'bookmark')
1644
+ and tweets.text <> ''
1645
+ then tweets.text
1646
+ when excluded.text <> '' then excluded.text
1647
+ else tweets.text
1648
+ end,
1649
+ created_at = case
1650
+ when excluded.kind in ('like', 'bookmark')
1651
+ then tweets.created_at
1652
+ else excluded.created_at
1653
+ end,
1654
+ is_replied = max(tweets.is_replied, excluded.is_replied),
1655
+ reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
1656
+ like_count = max(tweets.like_count, excluded.like_count),
1657
+ media_count = max(tweets.media_count, excluded.media_count),
1658
+ bookmarked = case
1659
+ when tweets.account_id = excluded.account_id then max(tweets.bookmarked, excluded.bookmarked)
1660
+ else tweets.bookmarked
1661
+ end,
1662
+ liked = case
1663
+ when tweets.account_id = excluded.account_id then max(tweets.liked, excluded.liked)
1664
+ else tweets.liked
1665
+ end,
1666
+ entities_json = case when excluded.entities_json <> '{}' then excluded.entities_json else tweets.entities_json end,
1667
+ media_json = case when excluded.media_json <> '[]' then excluded.media_json else tweets.media_json end,
1668
+ quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
778
1669
  `);
1670
+ const deleteTweetFts = db.prepare(
1671
+ "delete from tweets_fts where tweet_id = ?",
1672
+ );
779
1673
  const insertTweetFts = db.prepare(
780
1674
  "insert into tweets_fts (tweet_id, text) values (?, ?)",
781
1675
  );
1676
+ const selectTweetFtsText = db.prepare("select text from tweets where id = ?");
782
1677
  const insertTimelineEdge = db.prepare(`
783
1678
  insert into tweet_account_edges (
784
1679
  account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
@@ -795,10 +1690,16 @@ export async function importArchive(
795
1690
  ) values (?, ?, ?, ?, ?, ?, ?)
796
1691
  on conflict(account_id, tweet_id, kind) do update set
797
1692
  collected_at = coalesce(excluded.collected_at, tweet_collections.collected_at),
798
- source = excluded.source,
799
- raw_json = excluded.raw_json,
800
- updated_at = excluded.updated_at
801
- `);
1693
+ source = case
1694
+ when tweet_collections.source = 'archive' then excluded.source
1695
+ else tweet_collections.source
1696
+ end,
1697
+ raw_json = case
1698
+ when tweet_collections.source = 'archive' then excluded.raw_json
1699
+ else tweet_collections.raw_json
1700
+ end,
1701
+ updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1702
+ `);
802
1703
  const insertConversation = db.prepare(`
803
1704
  insert into dm_conversations (
804
1705
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
@@ -812,9 +1713,573 @@ export async function importArchive(
812
1713
  const insertDmFts = db.prepare(
813
1714
  "insert into dm_fts (message_id, text) values (?, ?)",
814
1715
  );
1716
+ const insertFollowSnapshot = db.prepare(`
1717
+ insert into follow_snapshots (
1718
+ id, account_id, direction, source, status, page_count, result_count,
1719
+ started_at, completed_at, raw_meta_json
1720
+ ) values (?, ?, ?, 'archive', 'complete', ?, ?, ?, ?, ?)
1721
+ on conflict(id) do update set
1722
+ account_id = excluded.account_id,
1723
+ direction = excluded.direction,
1724
+ source = excluded.source,
1725
+ status = excluded.status,
1726
+ page_count = excluded.page_count,
1727
+ result_count = excluded.result_count,
1728
+ started_at = excluded.started_at,
1729
+ completed_at = excluded.completed_at,
1730
+ raw_meta_json = excluded.raw_meta_json
1731
+ `);
1732
+ const insertFollowSnapshotMember = db.prepare(`
1733
+ insert into follow_snapshot_members (
1734
+ snapshot_id, profile_id, external_user_id, position
1735
+ ) values (?, ?, ?, ?)
1736
+ `);
1737
+ const selectFollowSnapshotMembers = db.prepare(`
1738
+ select profile_id, external_user_id
1739
+ from follow_snapshot_members
1740
+ where snapshot_id = ?
1741
+ order by position, profile_id
1742
+ `);
1743
+ const deleteFollowSnapshotMembers = db.prepare(
1744
+ "delete from follow_snapshot_members where snapshot_id = ?",
1745
+ );
1746
+ const deleteArchiveFollowEvents = db.prepare(`
1747
+ delete from follow_events
1748
+ where account_id = ? and direction = ? and (
1749
+ snapshot_id = ? or snapshot_id in (
1750
+ select id from follow_snapshots
1751
+ where account_id = ? and direction = ? and source = 'archive'
1752
+ )
1753
+ )
1754
+ `);
1755
+ const deleteArchiveFollowSnapshotMembers = db.prepare(`
1756
+ delete from follow_snapshot_members
1757
+ where snapshot_id in (
1758
+ select id from follow_snapshots
1759
+ where account_id = ? and direction = ? and source = 'archive'
1760
+ )
1761
+ `);
1762
+ const deleteArchiveFollowSnapshots = db.prepare(`
1763
+ delete from follow_snapshots
1764
+ where account_id = ? and direction = ? and source = 'archive'
1765
+ `);
1766
+ const deleteArchiveFollowEdges = db.prepare(`
1767
+ delete from follow_edges
1768
+ where account_id = ? and direction = ? and source = 'archive'
1769
+ `);
1770
+ const selectFollowEdges = db.prepare(`
1771
+ select profile_id, external_user_id, current
1772
+ from follow_edges
1773
+ where account_id = ? and direction = ?
1774
+ `);
1775
+ const insertFollowEdge = db.prepare(`
1776
+ insert into follow_edges (
1777
+ account_id, direction, profile_id, external_user_id, source, current,
1778
+ first_seen_at, last_seen_at, ended_at, updated_at
1779
+ ) values (?, ?, ?, ?, 'archive', 1, ?, ?, null, ?)
1780
+ on conflict(account_id, direction, profile_id) do update set
1781
+ external_user_id = excluded.external_user_id,
1782
+ source = case
1783
+ when follow_edges.source = 'archive' then excluded.source
1784
+ else follow_edges.source
1785
+ end,
1786
+ current = 1,
1787
+ last_seen_at = excluded.last_seen_at,
1788
+ ended_at = null,
1789
+ updated_at = excluded.updated_at
1790
+ `);
1791
+ const endFollowEdge = db.prepare(`
1792
+ update follow_edges
1793
+ set current = 0, ended_at = ?, updated_at = ?
1794
+ where account_id = ? and direction = ? and profile_id = ?
1795
+ `);
1796
+ const insertFollowEvent = db.prepare(`
1797
+ insert into follow_events (
1798
+ id, account_id, direction, profile_id, external_user_id, kind, event_at, snapshot_id
1799
+ ) values (?, ?, ?, ?, ?, ?, ?, ?)
1800
+ `);
1801
+ const clearSelectedLikes = db.prepare(`
1802
+ delete from tweet_collections
1803
+ where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1804
+ `);
1805
+ const clearSelectedBookmarks = db.prepare(`
1806
+ delete from tweet_collections
1807
+ where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1808
+ `);
1809
+ const clearTweetLikedFlag = db.prepare(`
1810
+ update tweets
1811
+ set liked = 0
1812
+ where account_id = ?
1813
+ and id in (
1814
+ select tweet_id
1815
+ from tweet_collections
1816
+ where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1817
+ )
1818
+ `);
1819
+ const clearTweetBookmarkedFlag = db.prepare(`
1820
+ update tweets
1821
+ set bookmarked = 0
1822
+ where account_id = ?
1823
+ and id in (
1824
+ select tweet_id
1825
+ from tweet_collections
1826
+ where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1827
+ )
1828
+ `);
1829
+ const clearSelectedArchiveTweetEdges = db.prepare(`
1830
+ delete from tweet_account_edges
1831
+ where account_id = ?
1832
+ and kind in ('home', 'authored')
1833
+ and (
1834
+ source = 'archive'
1835
+ or (
1836
+ source = 'legacy'
1837
+ and exists (
1838
+ select 1
1839
+ from tweets
1840
+ where tweets.id = tweet_account_edges.tweet_id
1841
+ and tweets.account_id = ?
1842
+ and tweets.author_profile_id = ?
1843
+ )
1844
+ )
1845
+ )
1846
+ `);
1847
+ const deleteOrphanTweetLinkOccurrences = db.prepare(`
1848
+ delete from link_occurrences
1849
+ where source_kind = 'tweet'
1850
+ and source_id not in (select id from tweets)
1851
+ `);
1852
+ const deleteOrphanArchiveCollectionTweets = db.prepare(`
1853
+ delete from tweets
1854
+ where account_id = ?
1855
+ and kind in ('like', 'bookmark')
1856
+ and not exists (
1857
+ select 1
1858
+ from tweet_collections collection
1859
+ where collection.tweet_id = tweets.id
1860
+ )
1861
+ and not exists (
1862
+ select 1
1863
+ from tweet_account_edges edge
1864
+ where edge.tweet_id = tweets.id
1865
+ )
1866
+ and not exists (
1867
+ select 1
1868
+ from tweets referencing_tweet
1869
+ where referencing_tweet.reply_to_id = tweets.id
1870
+ or referencing_tweet.quoted_tweet_id = tweets.id
1871
+ )
1872
+ `);
1873
+ const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
1874
+ update tweets
1875
+ set kind = case
1876
+ when exists (
1877
+ select 1
1878
+ from tweet_collections collection
1879
+ where collection.account_id = ?
1880
+ and collection.tweet_id = tweets.id
1881
+ and collection.kind = 'likes'
1882
+ ) then 'like'
1883
+ when exists (
1884
+ select 1
1885
+ from tweet_collections collection
1886
+ where collection.account_id = ?
1887
+ and collection.tweet_id = tweets.id
1888
+ and collection.kind = 'bookmarks'
1889
+ ) then 'bookmark'
1890
+ else kind
1891
+ end
1892
+ where account_id = ?
1893
+ and id in (
1894
+ select tweet_id
1895
+ from tweet_account_edges edge
1896
+ join tweets edge_tweet on edge_tweet.id = edge.tweet_id
1897
+ where edge.account_id = ?
1898
+ and edge.kind in ('home', 'authored')
1899
+ and (
1900
+ edge.source = 'archive'
1901
+ or (
1902
+ edge.source = 'legacy'
1903
+ and edge_tweet.account_id = ?
1904
+ and edge_tweet.author_profile_id = ?
1905
+ )
1906
+ )
1907
+ )
1908
+ and id in (
1909
+ select tweet_id
1910
+ from tweet_collections
1911
+ where account_id = ?
1912
+ )
1913
+ `);
1914
+ const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
1915
+ update tweets
1916
+ set kind = 'archive_stale'
1917
+ where account_id = ?
1918
+ and id in (
1919
+ select tweet_id
1920
+ from tweet_account_edges edge
1921
+ join tweets edge_tweet on edge_tweet.id = edge.tweet_id
1922
+ where edge.account_id = ?
1923
+ and edge.kind in ('home', 'authored')
1924
+ and (
1925
+ edge.source = 'archive'
1926
+ or (
1927
+ edge.source = 'legacy'
1928
+ and edge_tweet.account_id = ?
1929
+ and edge_tweet.author_profile_id = ?
1930
+ )
1931
+ )
1932
+ )
1933
+ and id not in (
1934
+ select tweet_id
1935
+ from tweet_collections
1936
+ where account_id = ?
1937
+ )
1938
+ and exists (
1939
+ select 1
1940
+ from tweet_account_edges edge
1941
+ where edge.tweet_id = tweets.id
1942
+ and not (
1943
+ edge.account_id = ?
1944
+ and edge.kind in ('home', 'authored')
1945
+ and (
1946
+ edge.source = 'archive'
1947
+ or (
1948
+ edge.source = 'legacy'
1949
+ and tweets.account_id = ?
1950
+ and tweets.author_profile_id = ?
1951
+ )
1952
+ )
1953
+ )
1954
+ union all
1955
+ select 1
1956
+ from tweet_collections collection
1957
+ where collection.tweet_id = tweets.id
1958
+ union all
1959
+ select 1
1960
+ from tweets referencing_tweet
1961
+ where (
1962
+ referencing_tweet.reply_to_id = tweets.id
1963
+ or referencing_tweet.quoted_tweet_id = tweets.id
1964
+ )
1965
+ and (
1966
+ exists (
1967
+ select 1
1968
+ from tweet_collections collection
1969
+ where collection.tweet_id = referencing_tweet.id
1970
+ )
1971
+ or exists (
1972
+ select 1
1973
+ from tweet_account_edges edge
1974
+ where edge.tweet_id = referencing_tweet.id
1975
+ and not (
1976
+ edge.account_id = ?
1977
+ and edge.kind in ('home', 'authored')
1978
+ and (
1979
+ edge.source = 'archive'
1980
+ or (
1981
+ edge.source = 'legacy'
1982
+ and referencing_tweet.account_id = ?
1983
+ and referencing_tweet.author_profile_id = ?
1984
+ )
1985
+ )
1986
+ )
1987
+ )
1988
+ )
1989
+ )
1990
+ `);
1991
+ const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
1992
+ delete from tweets
1993
+ where account_id = ?
1994
+ and id in (
1995
+ select tweet_id
1996
+ from tweet_account_edges edge
1997
+ join tweets edge_tweet on edge_tweet.id = edge.tweet_id
1998
+ where edge.account_id = ?
1999
+ and edge.kind in ('home', 'authored')
2000
+ and (
2001
+ edge.source = 'archive'
2002
+ or (
2003
+ edge.source = 'legacy'
2004
+ and edge_tweet.account_id = ?
2005
+ and edge_tweet.author_profile_id = ?
2006
+ )
2007
+ )
2008
+ )
2009
+ and not exists (
2010
+ select 1
2011
+ from tweet_collections collection
2012
+ where collection.tweet_id = tweets.id
2013
+ )
2014
+ and not exists (
2015
+ select 1
2016
+ from tweet_account_edges edge
2017
+ where edge.tweet_id = tweets.id
2018
+ and not (
2019
+ edge.account_id = ?
2020
+ and edge.kind in ('home', 'authored')
2021
+ and (
2022
+ edge.source = 'archive'
2023
+ or (
2024
+ edge.source = 'legacy'
2025
+ and tweets.account_id = ?
2026
+ and tweets.author_profile_id = ?
2027
+ )
2028
+ )
2029
+ )
2030
+ )
2031
+ and not exists (
2032
+ select 1
2033
+ from tweets referencing_tweet
2034
+ where (
2035
+ referencing_tweet.reply_to_id = tweets.id
2036
+ or referencing_tweet.quoted_tweet_id = tweets.id
2037
+ )
2038
+ and (
2039
+ exists (
2040
+ select 1
2041
+ from tweet_collections collection
2042
+ where collection.tweet_id = referencing_tweet.id
2043
+ )
2044
+ or exists (
2045
+ select 1
2046
+ from tweet_account_edges edge
2047
+ where edge.tweet_id = referencing_tweet.id
2048
+ and not (
2049
+ edge.account_id = ?
2050
+ and edge.kind in ('home', 'authored')
2051
+ and (
2052
+ edge.source = 'archive'
2053
+ or (
2054
+ edge.source = 'legacy'
2055
+ and referencing_tweet.account_id = ?
2056
+ and referencing_tweet.author_profile_id = ?
2057
+ )
2058
+ )
2059
+ )
2060
+ )
2061
+ )
2062
+ )
2063
+ `);
2064
+ const deleteOrphanTweetFts = db.prepare(`
2065
+ delete from tweets_fts
2066
+ where tweet_id not in (select id from tweets)
2067
+ `);
2068
+ const clearDmFts = db.prepare(`
2069
+ delete from dm_fts
2070
+ where message_id in (
2071
+ select m.id
2072
+ from dm_messages m
2073
+ join dm_conversations c on c.id = m.conversation_id
2074
+ where c.account_id = ?
2075
+ )
2076
+ `);
2077
+ const clearDmLinkOccurrences = db.prepare(`
2078
+ delete from link_occurrences
2079
+ where source_kind = 'dm'
2080
+ and source_id in (
2081
+ select m.id
2082
+ from dm_messages m
2083
+ join dm_conversations c on c.id = m.conversation_id
2084
+ where c.account_id = ?
2085
+ )
2086
+ `);
2087
+ const clearDmMessages = db.prepare(`
2088
+ delete from dm_messages
2089
+ where conversation_id in (
2090
+ select id from dm_conversations where account_id = ?
2091
+ )
2092
+ `);
2093
+ const clearDmConversations = db.prepare(
2094
+ "delete from dm_conversations where account_id = ?",
2095
+ );
2096
+
2097
+ function importFollowRows(
2098
+ direction: ArchiveFollowDirection,
2099
+ rows: Array<{ profileId: string; externalUserId: string }>,
2100
+ entryCount: number,
2101
+ now: string,
2102
+ ) {
2103
+ const snapshotId = `follow_snapshot_archive_acct_primary_${direction}`;
2104
+ const existingEdges = new Map(
2105
+ (
2106
+ selectFollowEdges.all("acct_primary", direction) as Array<{
2107
+ profile_id: string;
2108
+ external_user_id: string;
2109
+ current: number;
2110
+ }>
2111
+ ).map((row) => [row.profile_id, row]),
2112
+ );
2113
+ const existingMemberKey = (
2114
+ selectFollowSnapshotMembers.all(snapshotId) as Array<{
2115
+ profile_id: string;
2116
+ external_user_id: string;
2117
+ }>
2118
+ )
2119
+ .map(
2120
+ (row, index) =>
2121
+ `${String(index)}:${row.profile_id}:${row.external_user_id}`,
2122
+ )
2123
+ .join("\n");
2124
+ const nextMemberKey = rows
2125
+ .map(
2126
+ (row, index) =>
2127
+ `${String(index)}:${row.profileId}:${row.externalUserId}`,
2128
+ )
2129
+ .join("\n");
2130
+ const membersChanged = existingMemberKey !== nextMemberKey;
2131
+ const currentProfileIds = new Set<string>();
2132
+
2133
+ insertFollowSnapshot.run(
2134
+ snapshotId,
2135
+ "acct_primary",
2136
+ direction,
2137
+ entryCount,
2138
+ rows.length,
2139
+ now,
2140
+ now,
2141
+ JSON.stringify({ archivePath, result_count: rows.length }),
2142
+ );
2143
+
2144
+ if (membersChanged) {
2145
+ deleteFollowSnapshotMembers.run(snapshotId);
2146
+ }
2147
+ rows.forEach((row, index) => {
2148
+ const profileId = resolveProfileId(row.profileId);
2149
+ currentProfileIds.add(profileId);
2150
+ if (membersChanged) {
2151
+ insertFollowSnapshotMember.run(
2152
+ snapshotId,
2153
+ profileId,
2154
+ row.externalUserId,
2155
+ index,
2156
+ );
2157
+ }
2158
+
2159
+ const previous = existingEdges.get(profileId);
2160
+ insertFollowEdge.run(
2161
+ "acct_primary",
2162
+ direction,
2163
+ profileId,
2164
+ row.externalUserId,
2165
+ now,
2166
+ now,
2167
+ now,
2168
+ );
2169
+ if (!previous || previous.current === 0) {
2170
+ insertFollowEvent.run(
2171
+ `follow_event_${randomUUID()}`,
2172
+ "acct_primary",
2173
+ direction,
2174
+ profileId,
2175
+ row.externalUserId,
2176
+ "started",
2177
+ now,
2178
+ snapshotId,
2179
+ );
2180
+ }
2181
+ });
2182
+
2183
+ for (const [profileId, previous] of existingEdges) {
2184
+ if (previous.current === 0 || currentProfileIds.has(profileId)) {
2185
+ continue;
2186
+ }
2187
+ endFollowEdge.run(now, now, "acct_primary", direction, profileId);
2188
+ insertFollowEvent.run(
2189
+ `follow_event_${randomUUID()}`,
2190
+ "acct_primary",
2191
+ direction,
2192
+ profileId,
2193
+ previous.external_user_id,
2194
+ "ended",
2195
+ now,
2196
+ snapshotId,
2197
+ );
2198
+ }
2199
+ }
2200
+
2201
+ function clearArchiveFollowRows(direction: ArchiveFollowDirection) {
2202
+ deleteArchiveFollowEvents.run(
2203
+ "acct_primary",
2204
+ direction,
2205
+ `follow_snapshot_archive_acct_primary_${direction}`,
2206
+ "acct_primary",
2207
+ direction,
2208
+ );
2209
+ deleteArchiveFollowSnapshotMembers.run("acct_primary", direction);
2210
+ deleteArchiveFollowSnapshots.run("acct_primary", direction);
2211
+ deleteArchiveFollowEdges.run("acct_primary", direction);
2212
+ }
815
2213
 
816
2214
  db.transaction(() => {
817
- insertAccount.run(
2215
+ if (selection) {
2216
+ if (includeTweets) {
2217
+ clearAuthoredSyncCursors(db, "acct_primary");
2218
+ demoteSelectedArchiveTweetsWithCollections.run(
2219
+ "acct_primary",
2220
+ "acct_primary",
2221
+ "acct_primary",
2222
+ "acct_primary",
2223
+ "acct_primary",
2224
+ localProfile.id,
2225
+ "acct_primary",
2226
+ );
2227
+ preserveSelectedArchiveTweetsReferencedElsewhere.run(
2228
+ "acct_primary",
2229
+ "acct_primary",
2230
+ "acct_primary",
2231
+ localProfile.id,
2232
+ "acct_primary",
2233
+ "acct_primary",
2234
+ "acct_primary",
2235
+ localProfile.id,
2236
+ "acct_primary",
2237
+ "acct_primary",
2238
+ localProfile.id,
2239
+ );
2240
+ deleteSelectedArchiveTweetsWithoutCollections.run(
2241
+ "acct_primary",
2242
+ "acct_primary",
2243
+ "acct_primary",
2244
+ localProfile.id,
2245
+ "acct_primary",
2246
+ "acct_primary",
2247
+ localProfile.id,
2248
+ "acct_primary",
2249
+ "acct_primary",
2250
+ localProfile.id,
2251
+ );
2252
+ deleteOrphanTweetFts.run();
2253
+ deleteOrphanTweetLinkOccurrences.run();
2254
+ clearSelectedArchiveTweetEdges.run(
2255
+ "acct_primary",
2256
+ "acct_primary",
2257
+ localProfile.id,
2258
+ );
2259
+ }
2260
+ if (includeLikes) {
2261
+ clearTweetLikedFlag.run("acct_primary", "acct_primary");
2262
+ clearSelectedLikes.run("acct_primary");
2263
+ }
2264
+ if (includeBookmarks) {
2265
+ clearTweetBookmarkedFlag.run("acct_primary", "acct_primary");
2266
+ clearSelectedBookmarks.run("acct_primary");
2267
+ }
2268
+ if (includeLikes || includeBookmarks) {
2269
+ deleteOrphanArchiveCollectionTweets.run("acct_primary");
2270
+ deleteOrphanTweetFts.run();
2271
+ deleteOrphanTweetLinkOccurrences.run();
2272
+ }
2273
+ if (includeDirectMessages) {
2274
+ clearDmLinkOccurrences.run("acct_primary");
2275
+ clearDmFts.run("acct_primary");
2276
+ clearDmMessages.run("acct_primary");
2277
+ clearDmConversations.run("acct_primary");
2278
+ }
2279
+ }
2280
+
2281
+ const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2282
+ writeAccount.run(
818
2283
  "acct_primary",
819
2284
  accountPayload.displayName,
820
2285
  `@${accountPayload.username}`,
@@ -823,25 +2288,37 @@ export async function importArchive(
823
2288
  accountPayload.createdAt,
824
2289
  );
825
2290
 
2291
+ const writeProfile =
2292
+ !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
826
2293
  for (const profile of profiles.values()) {
827
- insertProfile.run(
2294
+ writeProfile.run(
828
2295
  profile.id,
829
2296
  profile.handle,
830
2297
  profile.displayName,
831
2298
  profile.bio,
832
2299
  profile.followersCount,
833
2300
  profile.followingCount,
2301
+ profile.publicMetricsJson,
834
2302
  profile.avatarHue,
835
- null,
2303
+ profile.avatarUrl,
2304
+ profile.location,
2305
+ profile.url,
2306
+ profile.verifiedType,
2307
+ profile.entitiesJson,
2308
+ profile.rawJson,
836
2309
  profile.createdAt,
837
2310
  );
838
2311
  }
839
2312
 
840
2313
  for (const tweet of tweetRows) {
2314
+ const authorProfileId =
2315
+ tweet.authorProfileId === "profile_me"
2316
+ ? localProfile.id
2317
+ : resolveProfileId(tweet.authorProfileId);
841
2318
  insertTweet.run(
842
2319
  tweet.id,
843
2320
  "acct_primary",
844
- tweet.authorProfileId,
2321
+ authorProfileId,
845
2322
  tweet.kind,
846
2323
  tweet.text,
847
2324
  tweet.createdAt,
@@ -855,6 +2332,7 @@ export async function importArchive(
855
2332
  tweet.mediaJson,
856
2333
  tweet.quotedTweetId,
857
2334
  );
2335
+ deleteTweetFts.run(tweet.id);
858
2336
  if (tweet.kind === "home") {
859
2337
  insertTimelineEdge.run(
860
2338
  "acct_primary",
@@ -865,7 +2343,20 @@ export async function importArchive(
865
2343
  new Date().toISOString(),
866
2344
  );
867
2345
  }
868
- insertTweetFts.run(tweet.id, tweet.text);
2346
+ if (authorProfileId === localProfile.id) {
2347
+ insertTimelineEdge.run(
2348
+ "acct_primary",
2349
+ tweet.id,
2350
+ "authored",
2351
+ tweet.createdAt,
2352
+ tweet.createdAt,
2353
+ new Date().toISOString(),
2354
+ );
2355
+ }
2356
+ const storedTweet = selectTweetFtsText.get(tweet.id) as
2357
+ | { text: string }
2358
+ | undefined;
2359
+ insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
869
2360
  }
870
2361
 
871
2362
  const importedAt = new Date().toISOString();
@@ -906,6 +2397,27 @@ export async function importArchive(
906
2397
  );
907
2398
  insertDmFts.run(message.id, message.text);
908
2399
  }
2400
+
2401
+ if (includeFollowers && followerEntries.length > 0) {
2402
+ importFollowRows(
2403
+ "followers",
2404
+ followerRows,
2405
+ followerEntries.length,
2406
+ importedAt,
2407
+ );
2408
+ } else if (includeFollowers) {
2409
+ clearArchiveFollowRows("followers");
2410
+ }
2411
+ if (includeFollowing && followingEntries.length > 0) {
2412
+ importFollowRows(
2413
+ "following",
2414
+ followingRows,
2415
+ followingEntries.length,
2416
+ importedAt,
2417
+ );
2418
+ } else if (includeFollowing) {
2419
+ clearArchiveFollowRows("following");
2420
+ }
909
2421
  })();
910
2422
 
911
2423
  return {
@@ -923,6 +2435,9 @@ export async function importArchive(
923
2435
  dmConversations: conversations.size,
924
2436
  dmMessages: dmMessages.length,
925
2437
  profiles: profiles.size,
2438
+ mediaFiles: mediaFileCounts,
2439
+ followers: followerRows.length,
2440
+ following: followingRows.length,
926
2441
  },
927
2442
  };
928
2443
  }