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