birdclaw 0.5.1 → 0.7.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 (116) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +75 -5
  3. package/package.json +8 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +812 -37
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +37 -7
  9. package/src/components/AvatarChip.tsx +9 -3
  10. package/src/components/DmWorkspace.tsx +18 -8
  11. package/src/components/LinkPreviewCard.tsx +40 -18
  12. package/src/components/MarkdownViewer.tsx +818 -0
  13. package/src/components/ProfileAnalysisStream.tsx +428 -0
  14. package/src/components/ProfilePreview.tsx +120 -9
  15. package/src/components/SavedTimelineView.tsx +30 -8
  16. package/src/components/SyncNowButton.tsx +60 -25
  17. package/src/components/ThemeSlider.tsx +55 -50
  18. package/src/components/TimelineCard.tsx +240 -92
  19. package/src/components/TimelineRouteFrame.tsx +38 -8
  20. package/src/components/TweetMediaGrid.tsx +87 -38
  21. package/src/components/TweetRichText.tsx +45 -17
  22. package/src/components/account-selection.ts +64 -0
  23. package/src/components/useTimelineRouteData.ts +97 -13
  24. package/src/lib/account-sync-job.ts +666 -0
  25. package/src/lib/actions-transport.ts +216 -146
  26. package/src/lib/api-client.ts +128 -53
  27. package/src/lib/archive-finder.ts +78 -63
  28. package/src/lib/archive-import.ts +1593 -1291
  29. package/src/lib/authored-live.ts +262 -204
  30. package/src/lib/avatar-cache.ts +208 -43
  31. package/src/lib/backup.ts +1536 -954
  32. package/src/lib/bird-actions.ts +139 -57
  33. package/src/lib/bird-command.ts +101 -28
  34. package/src/lib/bird.ts +582 -194
  35. package/src/lib/blocklist.ts +40 -23
  36. package/src/lib/blocks-write.ts +129 -80
  37. package/src/lib/blocks.ts +165 -97
  38. package/src/lib/bookmark-sync-job.ts +250 -160
  39. package/src/lib/config.ts +35 -2
  40. package/src/lib/conversation-surface.ts +79 -48
  41. package/src/lib/data-sources.ts +219 -0
  42. package/src/lib/db.ts +95 -4
  43. package/src/lib/dms-live.ts +720 -66
  44. package/src/lib/effect-runtime.ts +45 -0
  45. package/src/lib/follow-graph.ts +224 -180
  46. package/src/lib/geocoding.ts +296 -0
  47. package/src/lib/http-effect.ts +222 -0
  48. package/src/lib/inbox.ts +74 -43
  49. package/src/lib/link-index.ts +88 -76
  50. package/src/lib/link-insights.ts +24 -0
  51. package/src/lib/link-preview-metadata.ts +472 -52
  52. package/src/lib/location.ts +137 -0
  53. package/src/lib/media-fetch.ts +286 -213
  54. package/src/lib/mention-threads-live.ts +445 -288
  55. package/src/lib/mentions-live.ts +549 -354
  56. package/src/lib/moderation-target.ts +102 -65
  57. package/src/lib/moderation-write.ts +77 -18
  58. package/src/lib/mutes-write.ts +129 -80
  59. package/src/lib/mutes.ts +8 -1
  60. package/src/lib/network-map.ts +382 -0
  61. package/src/lib/openai.ts +84 -53
  62. package/src/lib/period-digest.ts +1317 -0
  63. package/src/lib/profile-affiliation-hydration.ts +93 -54
  64. package/src/lib/profile-analysis.ts +1272 -0
  65. package/src/lib/profile-bio-entities.ts +1 -1
  66. package/src/lib/profile-hydration.ts +124 -72
  67. package/src/lib/profile-replies.ts +60 -43
  68. package/src/lib/profile-resolver.ts +402 -294
  69. package/src/lib/queries.ts +983 -203
  70. package/src/lib/research.ts +165 -120
  71. package/src/lib/search-discussion.ts +1016 -0
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +325 -51
  75. package/src/lib/tweet-account-edges.ts +2 -0
  76. package/src/lib/tweet-lookup.ts +30 -19
  77. package/src/lib/tweet-render.ts +141 -1
  78. package/src/lib/tweet-search-live.ts +565 -0
  79. package/src/lib/types.ts +75 -3
  80. package/src/lib/ui.ts +31 -8
  81. package/src/lib/url-expansion.ts +226 -55
  82. package/src/lib/url-safety.ts +220 -0
  83. package/src/lib/web-sync.ts +222 -149
  84. package/src/lib/whois.ts +166 -147
  85. package/src/lib/x-web.ts +102 -71
  86. package/src/lib/xurl-rate-limits.ts +267 -0
  87. package/src/lib/xurl.ts +1185 -405
  88. package/src/routeTree.gen.ts +273 -0
  89. package/src/routes/__root.tsx +24 -5
  90. package/src/routes/api/action.tsx +127 -78
  91. package/src/routes/api/avatar.tsx +39 -30
  92. package/src/routes/api/blocks.tsx +26 -23
  93. package/src/routes/api/conversation.tsx +25 -14
  94. package/src/routes/api/data-sources.tsx +24 -0
  95. package/src/routes/api/inbox.tsx +27 -21
  96. package/src/routes/api/link-insights.tsx +31 -25
  97. package/src/routes/api/link-preview.tsx +25 -21
  98. package/src/routes/api/network-map.tsx +55 -0
  99. package/src/routes/api/period-digest.tsx +133 -0
  100. package/src/routes/api/profile-analysis.tsx +152 -0
  101. package/src/routes/api/profile-hydrate.tsx +31 -28
  102. package/src/routes/api/query.tsx +80 -55
  103. package/src/routes/api/search-discussion.tsx +169 -0
  104. package/src/routes/api/status.tsx +18 -10
  105. package/src/routes/api/sync.tsx +75 -29
  106. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  107. package/src/routes/data-sources.tsx +255 -0
  108. package/src/routes/discuss.tsx +419 -0
  109. package/src/routes/dms.tsx +95 -28
  110. package/src/routes/inbox.tsx +32 -19
  111. package/src/routes/network-map.tsx +1035 -0
  112. package/src/routes/profile-analyze.tsx +112 -0
  113. package/src/routes/profiles.$handle.tsx +228 -0
  114. package/src/routes/rate-limits.tsx +309 -0
  115. package/src/routes/today.tsx +455 -0
  116. package/src/styles.css +22 -0
@@ -11,8 +11,11 @@ import {
11
11
  import path from "node:path";
12
12
  import { pipeline } from "node:stream/promises";
13
13
  import { promisify } from "node:util";
14
+ import { Effect } from "effect";
14
15
  import { getBirdclawPaths } from "./config";
15
16
  import { getNativeDb } from "./db";
17
+ import { runEffectPromise, tryPromise } from "./effect-runtime";
18
+ import { safeHttpUrl } from "./url-safety";
16
19
 
17
20
  const execFileAsync = promisify(execFile);
18
21
  const ARCHIVE_JSON_PAYLOAD = /=\s*(\[[\s\S]*\]|\{[\s\S]*\})/s;
@@ -58,8 +61,45 @@ export const ARCHIVE_IMPORT_SLICES = [
58
61
 
59
62
  export type ArchiveImportSlice = (typeof ARCHIVE_IMPORT_SLICES)[number];
60
63
 
64
+ export type ImportProgressSlice =
65
+ | "tweets"
66
+ | "noteTweets"
67
+ | "directMessages"
68
+ | "likes"
69
+ | "bookmarks"
70
+ | "media"
71
+ | "followers"
72
+ | "following";
73
+
74
+ export type ImportWritePhase =
75
+ | "profiles"
76
+ | "tweets"
77
+ | "collections"
78
+ | "dmMessages";
79
+
80
+ export type ImportProgressEvent =
81
+ | { kind: "scanned"; entryCount: number }
82
+ | { kind: "slice-start"; slice: ImportProgressSlice; files: number }
83
+ | {
84
+ kind: "slice-file";
85
+ slice: ImportProgressSlice;
86
+ processed: number;
87
+ files: number;
88
+ }
89
+ | { kind: "slice-done"; slice: ImportProgressSlice; count: number }
90
+ | { kind: "writing" }
91
+ | { kind: "write-start"; phase: ImportWritePhase; total: number }
92
+ | {
93
+ kind: "write-progress";
94
+ phase: ImportWritePhase;
95
+ processed: number;
96
+ total: number;
97
+ }
98
+ | { kind: "done" };
99
+
61
100
  export interface ImportArchiveOptions {
62
101
  select?: ArchiveImportSlice[];
102
+ onProgress?: (event: ImportProgressEvent) => void;
63
103
  }
64
104
 
65
105
  type ArchiveRecord = Record<string, unknown>;
@@ -108,48 +148,60 @@ function parseArchiveArray(content: string): ArchiveRecord[] {
108
148
  : [];
109
149
  }
110
150
 
111
- async function runUnzip(
151
+ function runUnzipEffect(
112
152
  _archivePath: string,
113
153
  args: string[],
114
154
  maxBuffer = 1024 * 1024 * 256,
115
155
  ) {
116
- const { stdout } = await execFileAsync("unzip", args, {
117
- maxBuffer,
118
- });
119
- return stdout;
156
+ return tryPromise(() =>
157
+ execFileAsync("unzip", args, {
158
+ maxBuffer,
159
+ }),
160
+ ).pipe(Effect.map(({ stdout }) => stdout));
120
161
  }
121
162
 
122
- async function listArchiveEntries(archivePath: string) {
123
- const stdout = await runUnzip(
124
- archivePath,
125
- ["-Z1", archivePath],
126
- 1024 * 1024 * 64,
127
- );
128
- return stdout
129
- .split("\n")
130
- .map((item) => item.trim())
131
- .filter((item) => item.length > 0);
163
+ function listArchiveEntriesEffect(
164
+ archivePath: string,
165
+ ): Effect.Effect<string[], unknown> {
166
+ return Effect.gen(function* () {
167
+ const stdout = yield* runUnzipEffect(
168
+ archivePath,
169
+ ["-Z1", archivePath],
170
+ 1024 * 1024 * 64,
171
+ );
172
+ return stdout
173
+ .split("\n")
174
+ .map((item) => item.trim())
175
+ .filter((item) => item.length > 0);
176
+ });
132
177
  }
133
178
 
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));
179
+ function listArchiveEntryDetailsEffect(
180
+ archivePath: string,
181
+ ): Effect.Effect<Array<{ path: string; size: number }>, unknown> {
182
+ return Effect.gen(function* () {
183
+ const stdout = yield* runUnzipEffect(
184
+ archivePath,
185
+ ["-Z", "-l", archivePath],
186
+ 1024 * 1024 * 64,
187
+ );
188
+ return stdout
189
+ .split("\n")
190
+ .map((line) => line.trim().split(/\s+/))
191
+ .filter((parts) => parts.length >= 10 && /^[-d]/.test(parts[0] ?? ""))
192
+ .map((parts) => ({
193
+ path: parts.slice(9).join(" "),
194
+ size: Number(parts[3] ?? 0),
195
+ }))
196
+ .filter((entry) => entry.path.length > 0 && Number.isFinite(entry.size));
197
+ });
149
198
  }
150
199
 
151
- async function readArchiveEntry(archivePath: string, entryPath: string) {
152
- return runUnzip(archivePath, ["-p", archivePath, entryPath]);
200
+ function readArchiveEntryEffect(
201
+ archivePath: string,
202
+ entryPath: string,
203
+ ): Effect.Effect<string, unknown> {
204
+ return runUnzipEffect(archivePath, ["-p", archivePath, entryPath]);
153
205
  }
154
206
 
155
207
  function getFirstEntry(entries: string[], pattern: RegExp) {
@@ -216,6 +268,10 @@ function toFiniteNumber(value: unknown) {
216
268
  return Number.isFinite(number) ? number : undefined;
217
269
  }
218
270
 
271
+ function archiveHttpUrl(value: unknown) {
272
+ return safeHttpUrl(typeof value === "string" ? value : String(value ?? ""));
273
+ }
274
+
219
275
  function extractTweetEntities(tweet: Record<string, unknown>) {
220
276
  const entities = asRecord(tweet.entities);
221
277
  const urlEntries = [
@@ -225,10 +281,10 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
225
281
  const seenUrls = new Set<string>();
226
282
  const urls = urlEntries
227
283
  .map((entry) => ({
228
- url: String(entry.url ?? ""),
229
- expandedUrl: String(
230
- entry.expanded_url ?? entry.expandedUrl ?? entry.url ?? "",
231
- ),
284
+ url: archiveHttpUrl(entry.url) ?? "",
285
+ expandedUrl:
286
+ archiveHttpUrl(entry.expanded_url ?? entry.expandedUrl ?? entry.url) ??
287
+ "",
232
288
  displayUrl: String(
233
289
  entry.display_url ??
234
290
  entry.displayUrl ??
@@ -242,17 +298,13 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
242
298
  description:
243
299
  typeof entry.description === "string" ? entry.description : null,
244
300
  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,
301
+ archiveHttpUrl(
302
+ entry.image_url ??
303
+ entry.imageUrl ??
304
+ entry.thumbnail_url ??
305
+ entry.media_url_https ??
306
+ entry.media_url,
307
+ ) ?? undefined,
256
308
  siteName:
257
309
  typeof entry.site_name === "string"
258
310
  ? entry.site_name
@@ -356,12 +408,11 @@ function extractTweetMedia(tweet: Record<string, unknown>) {
356
408
 
357
409
  return sourceMedia
358
410
  .map((entry) => {
359
- const url = String(
360
- entry.media_url_https ?? entry.media_url ?? entry.url ?? "",
361
- );
362
- const thumbnailUrl = String(
363
- entry.media_url_https ?? entry.media_url ?? url,
364
- );
411
+ const url =
412
+ archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? entry.url) ??
413
+ "";
414
+ const thumbnailUrl =
415
+ archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? url) ?? url;
365
416
  const videoInfo = asRecord(entry.video_info);
366
417
  const durationMs = toFiniteNumber(videoInfo?.duration_millis);
367
418
  const variants = archiveMp4Variants(entry);
@@ -515,66 +566,78 @@ function needsArchiveMediaCopy(destinationPath: string, size: number) {
515
566
  return statSync(destinationPath).size !== size;
516
567
  }
517
568
 
518
- async function copyArchiveEntryToFile(
569
+ function copyArchiveEntryToFileEffect(
519
570
  archivePath: string,
520
571
  entryPath: string,
521
572
  destinationPath: string,
522
573
  ) {
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
- });
574
+ return tryPromise(() => {
575
+ mkdirSync(path.dirname(destinationPath), { recursive: true });
576
+ const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`;
577
+ const child = spawn("unzip", ["-p", archivePath, entryPath], {
578
+ stdio: ["ignore", "pipe", "pipe"],
579
+ });
580
+ let stderr = "";
581
+ child.stderr.setEncoding("utf8");
582
+ child.stderr.on("data", (chunk) => {
583
+ stderr += String(chunk);
584
+ });
585
+ const exit = new Promise<number | null>((resolve, reject) => {
586
+ child.on("error", reject);
587
+ child.on("close", resolve);
588
+ });
537
589
 
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
- }
590
+ return pipeline(child.stdout, createWriteStream(temporaryPath))
591
+ .then(() => exit)
592
+ .then((exitCode) => {
593
+ if (exitCode !== 0) {
594
+ throw new Error(
595
+ `Failed to extract ${entryPath}: ${
596
+ stderr.trim() || `exit ${String(exitCode)}`
597
+ }`,
598
+ );
599
+ }
600
+ renameSync(temporaryPath, destinationPath);
601
+ })
602
+ .catch((error: unknown) => {
603
+ child.kill();
604
+ if (existsSync(temporaryPath)) {
605
+ unlinkSync(temporaryPath);
606
+ }
607
+ throw error;
608
+ });
609
+ });
554
610
  }
555
611
 
556
- async function extractArchiveMediaFiles(
612
+ function extractArchiveMediaFilesEffect(
557
613
  archivePath: string,
558
614
  selectedKinds: Set<ArchiveMediaKind> | null,
559
- ) {
560
- const counts = createArchiveMediaFileCounts();
561
- if (selectedKinds?.size === 0) {
615
+ ): Effect.Effect<ArchiveMediaFileCounts, unknown> {
616
+ return Effect.gen(function* () {
617
+ const counts = createArchiveMediaFileCounts();
618
+ if (selectedKinds?.size === 0) {
619
+ return counts;
620
+ }
621
+ const entries = yield* listArchiveEntryDetailsEffect(archivePath);
622
+ for (const entry of entries) {
623
+ const mediaKind = getArchiveMediaKind(entry.path);
624
+ if (!mediaKind) continue;
625
+ if (selectedKinds && !selectedKinds.has(mediaKind.kind)) continue;
626
+
627
+ counts[mediaKind.kind] += 1;
628
+ const destinationPath = getArchiveMediaDestination(
629
+ entry.path,
630
+ mediaKind.kind,
631
+ );
632
+ if (!needsArchiveMediaCopy(destinationPath, entry.size)) continue;
633
+ yield* copyArchiveEntryToFileEffect(
634
+ archivePath,
635
+ entry.path,
636
+ destinationPath,
637
+ );
638
+ }
562
639
  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;
640
+ });
578
641
  }
579
642
 
580
643
  function getArchiveFollowRows(content: string, key: ArchiveFollowKey) {
@@ -594,8 +657,7 @@ function getArchiveFollowRows(content: string, key: ArchiveFollowKey) {
594
657
  return rows;
595
658
  }
596
659
 
597
- function clearImportedData() {
598
- const db = getNativeDb();
660
+ function clearImportedData(db = getNativeDb()) {
599
661
  db.exec(`
600
662
  delete from ai_scores;
601
663
  delete from tweet_actions;
@@ -632,894 +694,1062 @@ function clearMentionSyncState(db = getNativeDb()) {
632
694
  ).run();
633
695
  }
634
696
 
635
- export async function importArchive(
697
+ function importArchiveInternalEffect(
636
698
  archivePath: string,
637
699
  options: ImportArchiveOptions = {},
638
- ): Promise<ImportedArchiveSummary> {
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");
648
- const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
649
- const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
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
- : [];
700
+ ): Effect.Effect<ImportedArchiveSummary, unknown> {
701
+ return Effect.gen(function* () {
702
+ const onProgress = options.onProgress ?? (() => {});
703
+ const entries = yield* listArchiveEntriesEffect(archivePath);
704
+ onProgress({ kind: "scanned", entryCount: entries.length });
705
+ const selection = selectedSlices(options);
706
+ const includeTweets = includesSlice(selection, "tweets");
707
+ const includeLikes = includesSlice(selection, "likes");
708
+ const includeBookmarks = includesSlice(selection, "bookmarks");
709
+ const includeDirectMessages = includesSlice(selection, "directMessages");
710
+ const includeProfiles = includesSlice(selection, "profiles");
711
+ const includeFollowers = includesSlice(selection, "followers");
712
+ const includeFollowing = includesSlice(selection, "following");
713
+ const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
714
+ const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
715
+ const tweetEntries = includeTweets
716
+ ? getMatchingEntries(
717
+ entries,
718
+ /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
719
+ )
720
+ : [];
721
+ const noteTweetEntries = includeTweets
722
+ ? getMatchingEntries(
723
+ entries,
724
+ /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i,
725
+ )
726
+ : [];
727
+ const likeEntries = includeLikes
728
+ ? getMatchingEntries(
729
+ entries,
730
+ /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
731
+ )
732
+ : [];
733
+ const bookmarkEntries = includeBookmarks
734
+ ? getMatchingEntries(
735
+ entries,
736
+ /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
737
+ )
738
+ : [];
739
+ const dmEntries = includeDirectMessages
740
+ ? getMatchingEntries(
741
+ entries,
742
+ /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
743
+ )
744
+ : [];
745
+ const followerEntries = includeFollowers
746
+ ? getMatchingEntries(entries, /(?:^|\/)data\/follower(?:-part\d+)?\.js$/i)
747
+ : [];
748
+ const followingEntries = includeFollowing
749
+ ? getMatchingEntries(
750
+ entries,
751
+ /(?:^|\/)data\/following(?:-part\d+)?\.js$/i,
752
+ )
753
+ : [];
683
754
 
684
- if (!accountEntry) {
685
- throw new Error("Archive missing data/account.js");
686
- }
755
+ if (!accountEntry) {
756
+ return yield* Effect.fail(new Error("Archive missing data/account.js"));
757
+ }
687
758
 
688
- const [accountContent, profileContent] = await Promise.all([
689
- readArchiveEntry(archivePath, accountEntry),
690
- profileEntry
691
- ? readArchiveEntry(archivePath, profileEntry)
692
- : Promise.resolve("[]"),
693
- ]);
759
+ const [accountContent, profileContent] = yield* Effect.all([
760
+ readArchiveEntryEffect(archivePath, accountEntry),
761
+ profileEntry
762
+ ? readArchiveEntryEffect(archivePath, profileEntry)
763
+ : Effect.succeed("[]"),
764
+ ]);
694
765
 
695
- const accountPayload = buildAccountPayload(
696
- parseArchiveArray(accountContent)[0] ?? null,
697
- parseArchiveArray(profileContent)[0] ?? null,
698
- );
766
+ const accountPayload = buildAccountPayload(
767
+ parseArchiveArray(accountContent)[0] ?? null,
768
+ parseArchiveArray(profileContent)[0] ?? null,
769
+ );
699
770
 
700
- const mentionDirectory = new Map<
701
- string,
702
- { handle?: string; displayName?: string }
703
- >();
704
- const tweetRows: Array<{
705
- id: string;
706
- kind: "home" | "like" | "bookmark";
707
- authorProfileId: string;
708
- text: string;
709
- createdAt: string;
710
- isReplied: number;
711
- replyToId: string | null;
712
- likeCount: number;
713
- mediaCount: number;
714
- bookmarked: number;
715
- liked: number;
716
- entitiesJson: string;
717
- mediaJson: string;
718
- quotedTweetId: string | null;
719
- }> = [];
720
- const collectionRows: Array<{
721
- tweetId: string;
722
- kind: "likes" | "bookmarks";
723
- collectedAt: string | null;
724
- source: string;
725
- rawJson: string;
726
- }> = [];
727
- const tweetRowsById = new Map<string, (typeof tweetRows)[number]>();
728
-
729
- function addTweetRow(row: (typeof tweetRows)[number]) {
730
- const existing = tweetRowsById.get(row.id);
731
- if (existing) {
732
- existing.bookmarked = Math.max(existing.bookmarked, row.bookmarked);
733
- existing.liked = Math.max(existing.liked, row.liked);
734
- if (!existing.text && row.text) existing.text = row.text;
735
- return;
771
+ const mentionDirectory = new Map<
772
+ string,
773
+ { handle?: string; displayName?: string }
774
+ >();
775
+ const tweetRows: Array<{
776
+ id: string;
777
+ kind: "home" | "like" | "bookmark";
778
+ authorProfileId: string;
779
+ text: string;
780
+ createdAt: string;
781
+ isReplied: number;
782
+ replyToId: string | null;
783
+ likeCount: number;
784
+ mediaCount: number;
785
+ bookmarked: number;
786
+ liked: number;
787
+ entitiesJson: string;
788
+ mediaJson: string;
789
+ quotedTweetId: string | null;
790
+ }> = [];
791
+ const collectionRows: Array<{
792
+ tweetId: string;
793
+ kind: "likes" | "bookmarks";
794
+ collectedAt: string | null;
795
+ source: string;
796
+ rawJson: string;
797
+ }> = [];
798
+ const tweetRowsById = new Map<string, (typeof tweetRows)[number]>();
799
+
800
+ function addTweetRow(row: (typeof tweetRows)[number]) {
801
+ const existing = tweetRowsById.get(row.id);
802
+ if (existing) {
803
+ existing.bookmarked = Math.max(existing.bookmarked, row.bookmarked);
804
+ existing.liked = Math.max(existing.liked, row.liked);
805
+ if (!existing.text && row.text) existing.text = row.text;
806
+ return;
807
+ }
808
+ tweetRows.push(row);
809
+ tweetRowsById.set(row.id, row);
736
810
  }
737
- tweetRows.push(row);
738
- tweetRowsById.set(row.id, row);
739
- }
740
811
 
741
- for (const entry of tweetEntries) {
742
- const content = await readArchiveEntry(archivePath, entry);
743
- for (const wrapper of parseArchiveArray(content)) {
744
- const tweet = asRecord(wrapper.tweet);
745
- if (!tweet) continue;
746
-
747
- for (const mention of asArray<Record<string, unknown>>(
748
- asRecord(tweet.entities)?.user_mentions,
749
- )) {
750
- const mentionId = String(mention.id_str ?? mention.id ?? "");
751
- if (!mentionId) continue;
752
- mentionDirectory.set(mentionId, {
753
- handle: String(mention.screen_name ?? ""),
754
- displayName: String(mention.name ?? mention.screen_name ?? mentionId),
812
+ if (tweetEntries.length > 0) {
813
+ onProgress({
814
+ kind: "slice-start",
815
+ slice: "tweets",
816
+ files: tweetEntries.length,
817
+ });
818
+ }
819
+ for (const [tweetFileIndex, entry] of tweetEntries.entries()) {
820
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
821
+ for (const wrapper of parseArchiveArray(content)) {
822
+ const tweet = asRecord(wrapper.tweet);
823
+ if (!tweet) continue;
824
+
825
+ for (const mention of asArray<Record<string, unknown>>(
826
+ asRecord(tweet.entities)?.user_mentions,
827
+ )) {
828
+ const mentionId = String(mention.id_str ?? mention.id ?? "");
829
+ if (!mentionId) continue;
830
+ mentionDirectory.set(mentionId, {
831
+ handle: String(mention.screen_name ?? ""),
832
+ displayName: String(
833
+ mention.name ?? mention.screen_name ?? mentionId,
834
+ ),
835
+ });
836
+ }
837
+
838
+ const replyUserId = String(
839
+ tweet.in_reply_to_user_id_str ?? tweet.in_reply_to_user_id ?? "",
840
+ );
841
+ const replyScreenName = String(tweet.in_reply_to_screen_name ?? "");
842
+ if (replyUserId && replyScreenName) {
843
+ mentionDirectory.set(replyUserId, {
844
+ handle: replyScreenName,
845
+ displayName: replyScreenName,
846
+ });
847
+ }
848
+
849
+ addTweetRow({
850
+ id: String(tweet.id_str ?? tweet.id),
851
+ kind: "home",
852
+ authorProfileId: "profile_me",
853
+ text: String(tweet.full_text ?? tweet.text ?? ""),
854
+ createdAt: parseTwitterDate(tweet.created_at),
855
+ isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
856
+ replyToId: tweet.in_reply_to_status_id_str
857
+ ? String(tweet.in_reply_to_status_id_str)
858
+ : null,
859
+ likeCount: toInt(tweet.favorite_count),
860
+ mediaCount: getTweetMediaCount(tweet),
861
+ bookmarked: 0,
862
+ liked: 0,
863
+ entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
864
+ mediaJson: JSON.stringify(extractTweetMedia(tweet)),
865
+ quotedTweetId: tweet.quoted_status_id_str
866
+ ? String(tweet.quoted_status_id_str)
867
+ : null,
755
868
  });
756
869
  }
870
+ onProgress({
871
+ kind: "slice-file",
872
+ slice: "tweets",
873
+ processed: tweetFileIndex + 1,
874
+ files: tweetEntries.length,
875
+ });
876
+ }
877
+ if (tweetEntries.length > 0) {
878
+ onProgress({
879
+ kind: "slice-done",
880
+ slice: "tweets",
881
+ count: tweetRows.length,
882
+ });
883
+ }
757
884
 
758
- const replyUserId = String(
759
- tweet.in_reply_to_user_id_str ?? tweet.in_reply_to_user_id ?? "",
760
- );
761
- const replyScreenName = String(tweet.in_reply_to_screen_name ?? "");
762
- if (replyUserId && replyScreenName) {
763
- mentionDirectory.set(replyUserId, {
764
- handle: replyScreenName,
765
- displayName: replyScreenName,
885
+ if (noteTweetEntries.length > 0) {
886
+ onProgress({
887
+ kind: "slice-start",
888
+ slice: "noteTweets",
889
+ files: noteTweetEntries.length,
890
+ });
891
+ }
892
+ const tweetRowsBeforeNotes = tweetRows.length;
893
+ for (const [noteFileIndex, entry] of noteTweetEntries.entries()) {
894
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
895
+ for (const wrapper of parseArchiveArray(content)) {
896
+ const noteTweet = asRecord(wrapper.noteTweet);
897
+ if (!noteTweet) continue;
898
+ const core = asRecord(noteTweet.core);
899
+ addTweetRow({
900
+ id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
901
+ kind: "home",
902
+ authorProfileId: "profile_me",
903
+ text: String(core?.text ?? ""),
904
+ createdAt: parseTwitterDate(noteTweet.createdAt),
905
+ isReplied: 0,
906
+ replyToId: null,
907
+ likeCount: 0,
908
+ mediaCount: 0,
909
+ bookmarked: 0,
910
+ liked: 0,
911
+ entitiesJson: "{}",
912
+ mediaJson: "[]",
913
+ quotedTweetId: null,
766
914
  });
767
915
  }
768
-
769
- addTweetRow({
770
- id: String(tweet.id_str ?? tweet.id),
771
- kind: "home",
772
- authorProfileId: "profile_me",
773
- text: String(tweet.full_text ?? tweet.text ?? ""),
774
- createdAt: parseTwitterDate(tweet.created_at),
775
- isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
776
- replyToId: tweet.in_reply_to_status_id_str
777
- ? String(tweet.in_reply_to_status_id_str)
778
- : null,
779
- likeCount: toInt(tweet.favorite_count),
780
- mediaCount: getTweetMediaCount(tweet),
781
- bookmarked: 0,
782
- liked: 0,
783
- entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
784
- mediaJson: JSON.stringify(extractTweetMedia(tweet)),
785
- quotedTweetId: tweet.quoted_status_id_str
786
- ? String(tweet.quoted_status_id_str)
787
- : null,
916
+ onProgress({
917
+ kind: "slice-file",
918
+ slice: "noteTweets",
919
+ processed: noteFileIndex + 1,
920
+ files: noteTweetEntries.length,
788
921
  });
789
922
  }
790
- }
791
-
792
- for (const entry of noteTweetEntries) {
793
- const content = await readArchiveEntry(archivePath, entry);
794
- for (const wrapper of parseArchiveArray(content)) {
795
- const noteTweet = asRecord(wrapper.noteTweet);
796
- if (!noteTweet) continue;
797
- const core = asRecord(noteTweet.core);
798
- addTweetRow({
799
- id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
800
- kind: "home",
801
- authorProfileId: "profile_me",
802
- text: String(core?.text ?? ""),
803
- createdAt: parseTwitterDate(noteTweet.createdAt),
804
- isReplied: 0,
805
- replyToId: null,
806
- likeCount: 0,
807
- mediaCount: 0,
808
- bookmarked: 0,
809
- liked: 0,
810
- entitiesJson: "{}",
811
- mediaJson: "[]",
812
- quotedTweetId: null,
923
+ if (noteTweetEntries.length > 0) {
924
+ onProgress({
925
+ kind: "slice-done",
926
+ slice: "noteTweets",
927
+ count: tweetRows.length - tweetRowsBeforeNotes,
813
928
  });
814
929
  }
815
- }
816
- const authoredTweetCount = tweetRows.length;
930
+ const authoredTweetCount = tweetRows.length;
817
931
 
818
- type MessageRow = {
819
- id: string;
820
- conversationId: string;
821
- senderProfileId: string;
822
- text: string;
823
- createdAt: string;
824
- direction: "inbound" | "outbound";
825
- mediaCount: number;
826
- };
932
+ type MessageRow = {
933
+ id: string;
934
+ conversationId: string;
935
+ senderProfileId: string;
936
+ text: string;
937
+ createdAt: string;
938
+ direction: "inbound" | "outbound";
939
+ mediaCount: number;
940
+ };
827
941
 
828
- const profiles = new Map<
829
- string,
830
- {
942
+ const profiles = new Map<
943
+ string,
944
+ {
945
+ id: string;
946
+ handle: string;
947
+ displayName: string;
948
+ bio: string;
949
+ followersCount: number;
950
+ followingCount: number;
951
+ publicMetricsJson: string;
952
+ avatarHue: number;
953
+ avatarUrl: string | null;
954
+ location: string | null;
955
+ url: string | null;
956
+ verifiedType: string | null;
957
+ entitiesJson: string;
958
+ rawJson: string;
959
+ createdAt: string;
960
+ }
961
+ >();
962
+ type ProfileRow =
963
+ typeof profiles extends Map<string, infer Value> ? Value : never;
964
+ const defaultProfileMetadata = {
965
+ publicMetricsJson: "{}",
966
+ location: null,
967
+ url: null,
968
+ verifiedType: null,
969
+ entitiesJson: "{}",
970
+ rawJson: "{}",
971
+ };
972
+ const conversations = new Map<
973
+ string,
974
+ {
975
+ id: string;
976
+ title: string;
977
+ accountId: string;
978
+ participantProfileId: string;
979
+ lastMessageAt: string;
980
+ unreadCount: number;
981
+ needsReply: number;
982
+ }
983
+ >();
984
+ const dmMessages: MessageRow[] = [];
985
+ const followerRows: Array<{ profileId: string; externalUserId: string }> =
986
+ [];
987
+ const followingRows: Array<{ profileId: string; externalUserId: string }> =
988
+ [];
989
+ const followerIds = new Set<string>();
990
+ const followingIds = new Set<string>();
991
+ type ExistingProfileRow = {
831
992
  id: string;
832
993
  handle: string;
833
- displayName: string;
994
+ display_name: string;
834
995
  bio: string;
835
- followersCount: number;
836
- followingCount: number;
837
- publicMetricsJson: string;
838
- avatarHue: number;
839
- avatarUrl: string | null;
996
+ followers_count: number;
997
+ following_count: number;
998
+ public_metrics_json: string;
999
+ avatar_hue: number;
1000
+ avatar_url: string | null;
840
1001
  location: string | null;
841
1002
  url: string | null;
842
- verifiedType: string | null;
843
- entitiesJson: string;
844
- rawJson: string;
845
- createdAt: string;
846
- }
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
- };
858
- const conversations = new Map<
859
- string,
860
- {
861
- id: string;
862
- title: string;
863
- accountId: string;
864
- participantProfileId: string;
865
- lastMessageAt: string;
866
- unreadCount: number;
867
- needsReply: number;
868
- }
869
- >();
870
- const dmMessages: MessageRow[] = [];
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
- `
1003
+ verified_type: string | null;
1004
+ entities_json: string;
1005
+ raw_json: string;
1006
+ created_at: string;
1007
+ };
1008
+ const existingProfiles = new Map(
1009
+ (
1010
+ getNativeDb()
1011
+ .prepare(
1012
+ `
898
1013
  select id, handle, display_name, bio, followers_count, following_count,
899
1014
  public_metrics_json, avatar_hue, avatar_url, location, url,
900
1015
  verified_type, entities_json, raw_json, created_at
901
1016
  from profiles
902
1017
  `,
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,
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]
1018
+ )
1019
+ .all() as ExistingProfileRow[]
1020
+ ).map((profile) => [profile.id, profile]),
974
1021
  );
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,
1022
+ const existingProfilesByHandle = new Map(
1023
+ [...existingProfiles.values()].map((profile) => [
1024
+ profile.handle.toLowerCase(),
1025
+ profile,
1026
+ ]),
1027
+ );
1028
+ const existingPrimaryAccount = getNativeDb()
1029
+ .prepare("select handle, external_user_id from accounts where id = ?")
1030
+ .get("acct_primary") as
1031
+ | { handle: string; external_user_id: string | null }
1032
+ | undefined;
1033
+ const profileIdAliases = new Map<string, string>();
1034
+
1035
+ type ArchiveProfileTier =
1036
+ | "archive_follow_stub"
1037
+ | "archive_dm_stub"
1038
+ | "archive_mention_inferred"
1039
+ | "live_or_hydrated";
1040
+ const archiveProfileTierRank: Record<ArchiveProfileTier, number> = {
1041
+ archive_follow_stub: 0,
1042
+ archive_dm_stub: 1,
1043
+ archive_mention_inferred: 2,
1044
+ live_or_hydrated: 3,
996
1045
  };
997
- }
998
1046
 
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);
1047
+ function classifyExistingProfile(profile: ProfileRow): ArchiveProfileTier {
1048
+ const externalUserId = profile.id.startsWith("profile_user_")
1049
+ ? profile.id.slice("profile_user_".length)
1050
+ : "";
1051
+ const fallbackHandle = externalUserId
1052
+ ? `id${externalUserId}`
1053
+ : profile.id;
1054
+ const hasLiveSignals =
1055
+ profile.followersCount > 0 ||
1056
+ profile.followingCount > 0 ||
1057
+ profile.publicMetricsJson.trim() !== "{}" ||
1058
+ profile.avatarUrl !== null ||
1059
+ profile.location !== null ||
1060
+ profile.url !== null ||
1061
+ profile.verifiedType !== null ||
1062
+ profile.entitiesJson.trim() !== "{}" ||
1063
+ profile.rawJson.trim() !== "{}";
1064
+
1065
+ if (hasLiveSignals) return "live_or_hydrated";
1066
+ if (
1067
+ profile.handle === fallbackHandle &&
1068
+ profile.displayName === "" &&
1069
+ profile.bio === ""
1070
+ ) {
1071
+ return "archive_follow_stub";
1072
+ }
1073
+ if (profile.bio.startsWith("Imported from archive user ")) {
1074
+ return profile.handle === fallbackHandle &&
1075
+ profile.displayName === fallbackHandle
1076
+ ? "archive_dm_stub"
1077
+ : "archive_mention_inferred";
1078
+ }
1079
+ return profile.handle === fallbackHandle && profile.displayName === ""
1080
+ ? "archive_follow_stub"
1081
+ : "archive_mention_inferred";
1008
1082
  }
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
1083
 
1021
- if (
1022
- current &&
1023
- currentTier &&
1024
- shouldPreserveProfile(currentTier, incomingTier) &&
1025
- (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1084
+ function shouldPreserveProfile(
1085
+ existingTier: ArchiveProfileTier,
1086
+ incomingTier: ArchiveProfileTier,
1026
1087
  ) {
1027
- return;
1088
+ return (
1089
+ archiveProfileTierRank[existingTier] >=
1090
+ archiveProfileTierRank[incomingTier]
1091
+ );
1028
1092
  }
1029
1093
 
1030
- if (
1031
- existingProfile &&
1032
- existingTier &&
1033
- shouldPreserveProfile(existingTier, incomingTier)
1034
- ) {
1035
- profiles.set(targetId, existingProfile);
1036
- return;
1094
+ function existingProfileToProfileRow(
1095
+ profile: ExistingProfileRow,
1096
+ ): ProfileRow {
1097
+ return {
1098
+ id: profile.id,
1099
+ handle: profile.handle,
1100
+ displayName: profile.display_name,
1101
+ bio: profile.bio,
1102
+ followersCount: profile.followers_count,
1103
+ followingCount: profile.following_count,
1104
+ publicMetricsJson: profile.public_metrics_json,
1105
+ avatarHue: profile.avatar_hue,
1106
+ avatarUrl: profile.avatar_url,
1107
+ location: profile.location,
1108
+ url: profile.url,
1109
+ verifiedType: profile.verified_type,
1110
+ entitiesJson: profile.entities_json,
1111
+ rawJson: profile.raw_json,
1112
+ createdAt: profile.created_at,
1113
+ };
1037
1114
  }
1038
1115
 
1039
- profiles.set(targetId, targetIncoming);
1040
- }
1116
+ function mergeArchiveProfile(incoming: ProfileRow) {
1117
+ const existingById = existingProfiles.get(incoming.id);
1118
+ const existingByHandle = selection
1119
+ ? existingProfilesByHandle.get(incoming.handle.toLowerCase())
1120
+ : undefined;
1121
+ const targetExisting = existingById ?? existingByHandle;
1122
+ const targetId = targetExisting?.id ?? incoming.id;
1123
+ if (targetId !== incoming.id) {
1124
+ profileIdAliases.set(incoming.id, targetId);
1125
+ }
1126
+ const targetIncoming =
1127
+ targetId === incoming.id ? incoming : { ...incoming, id: targetId };
1128
+ const incomingTier = classifyExistingProfile(incoming);
1129
+ const current = profiles.get(targetId);
1130
+ const currentTier = current ? classifyExistingProfile(current) : null;
1131
+ const existingProfile = targetExisting
1132
+ ? existingProfileToProfileRow(targetExisting)
1133
+ : null;
1134
+ const existingTier = existingProfile
1135
+ ? classifyExistingProfile(existingProfile)
1136
+ : null;
1041
1137
 
1042
- function resolveProfileId(profileId: string) {
1043
- return profileIdAliases.get(profileId) ?? profileId;
1044
- }
1138
+ if (
1139
+ current &&
1140
+ currentTier &&
1141
+ shouldPreserveProfile(currentTier, incomingTier) &&
1142
+ (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1143
+ ) {
1144
+ return;
1145
+ }
1045
1146
 
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
1147
  if (
1052
- profile.id !== profileId &&
1053
- profile.handle.toLowerCase() === normalizedHandle
1148
+ existingProfile &&
1149
+ existingTier &&
1150
+ shouldPreserveProfile(existingTier, incomingTier)
1054
1151
  ) {
1055
- return true;
1152
+ profiles.set(targetId, existingProfile);
1153
+ return;
1056
1154
  }
1155
+
1156
+ profiles.set(targetId, targetIncoming);
1057
1157
  }
1058
- return false;
1059
- }
1060
1158
 
1061
- function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1062
- if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1063
- return baseHandle;
1159
+ function resolveProfileId(profileId: string) {
1160
+ return profileIdAliases.get(profileId) ?? profileId;
1064
1161
  }
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;
1162
+
1163
+ function isProfileHandleTakenByOtherId(handle: string, profileId: string) {
1164
+ const normalizedHandle = handle.toLowerCase();
1165
+ const existingProfile = existingProfilesByHandle.get(normalizedHandle);
1166
+ if (existingProfile && existingProfile.id !== profileId) return true;
1167
+ for (const profile of profiles.values()) {
1168
+ if (
1169
+ profile.id !== profileId &&
1170
+ profile.handle.toLowerCase() === normalizedHandle
1171
+ ) {
1172
+ return true;
1173
+ }
1071
1174
  }
1072
- index += 1;
1175
+ return false;
1073
1176
  }
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
1177
 
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
- );
1178
+ function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1179
+ if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1180
+ return baseHandle;
1181
+ }
1182
+ let index = 1;
1183
+ while (true) {
1184
+ const suffix = index === 1 ? "archive" : `archive_${index}`;
1185
+ const candidate = `${baseHandle}_${suffix}`;
1186
+ if (!isProfileHandleTakenByOtherId(candidate, profileId)) {
1187
+ return candidate;
1188
+ }
1189
+ index += 1;
1190
+ }
1115
1191
  }
1116
- }
1117
1192
 
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,
1193
+ function addArchiveFollowProfile(
1194
+ profileId: string,
1195
+ externalUserId: string,
1196
+ ) {
1197
+ if (!profileId) return;
1198
+ const fallbackId =
1199
+ externalUserId || profileId.replace(/^profile_user_/, "");
1200
+ mergeArchiveProfile({
1201
+ id: profileId,
1202
+ handle: fallbackId ? `id${fallbackId}` : profileId,
1203
+ displayName: "",
1204
+ bio: "",
1141
1205
  followersCount: 0,
1142
1206
  followingCount: 0,
1143
1207
  ...defaultProfileMetadata,
1144
- avatarHue: 18,
1208
+ avatarHue: 210,
1145
1209
  avatarUrl: null,
1146
1210
  createdAt: accountPayload.createdAt,
1147
- };
1148
- const localProfile =
1149
- existingLocalProfile && !includeProfiles
1150
- ? existingProfileToProfileRow(existingLocalProfile)
1151
- : archivedLocalProfile;
1152
- profiles.set(localProfile.id, localProfile);
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
- `
1211
+ });
1212
+ }
1213
+
1214
+ function assertSelectedAccountMatchesArchive() {
1215
+ if (!selection || !existingPrimaryAccount) return;
1216
+ const existingExternalUserId = existingPrimaryAccount.external_user_id;
1217
+ if (
1218
+ existingExternalUserId &&
1219
+ existingExternalUserId !== accountPayload.accountId
1220
+ ) {
1221
+ throw new Error(
1222
+ `Existing acct_primary (${existingExternalUserId}) does not match archive account ${accountPayload.accountId}`,
1223
+ );
1224
+ }
1225
+ const existingHandle = existingPrimaryAccount.handle
1226
+ .replace(/^@/, "")
1227
+ .toLowerCase();
1228
+ if (
1229
+ !existingExternalUserId &&
1230
+ existingHandle !== accountPayload.username.toLowerCase()
1231
+ ) {
1232
+ throw new Error(
1233
+ `Existing acct_primary (@${existingHandle}) does not match archive account @${accountPayload.username}`,
1234
+ );
1235
+ }
1236
+ }
1237
+
1238
+ assertSelectedAccountMatchesArchive();
1239
+
1240
+ const existingLocalProfile =
1241
+ selection &&
1242
+ (existingProfiles.get("profile_me") ??
1243
+ [...existingProfiles.values()].find(
1244
+ (profile) =>
1245
+ profile.handle.toLowerCase() ===
1246
+ accountPayload.username.toLowerCase(),
1247
+ ));
1248
+ const archivedLocalProfile = existingLocalProfile
1249
+ ? {
1250
+ ...existingProfileToProfileRow(existingLocalProfile),
1251
+ handle: accountPayload.username,
1252
+ displayName: accountPayload.displayName,
1253
+ bio: accountPayload.bio,
1254
+ createdAt: accountPayload.createdAt,
1255
+ }
1256
+ : {
1257
+ id: "profile_me",
1258
+ handle: accountPayload.username,
1259
+ displayName: accountPayload.displayName,
1260
+ bio: accountPayload.bio,
1261
+ followersCount: 0,
1262
+ followingCount: 0,
1263
+ ...defaultProfileMetadata,
1264
+ avatarHue: 18,
1265
+ avatarUrl: null,
1266
+ createdAt: accountPayload.createdAt,
1267
+ };
1268
+ const localProfile =
1269
+ existingLocalProfile && !includeProfiles
1270
+ ? existingProfileToProfileRow(existingLocalProfile)
1271
+ : archivedLocalProfile;
1272
+ profiles.set(localProfile.id, localProfile);
1273
+
1274
+ const existingDmConversationAccounts = new Map(
1275
+ (
1276
+ getNativeDb()
1277
+ .prepare("select id, account_id from dm_conversations")
1278
+ .all() as Array<{ id: string; account_id: string }>
1279
+ ).map((row) => [row.id, row.account_id]),
1280
+ );
1281
+ const existingOtherDmMessageIds = new Set(
1282
+ (
1283
+ getNativeDb()
1284
+ .prepare(
1285
+ `
1166
1286
  select m.id
1167
1287
  from dm_messages m
1168
1288
  join dm_conversations c on c.id = m.conversation_id
1169
1289
  where c.account_id <> 'acct_primary'
1170
1290
  `,
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;
1291
+ )
1292
+ .all() as Array<{ id: string }>
1293
+ ).map((row) => row.id),
1294
+ );
1295
+ const archiveDmConversationIdAliases = new Map<string, string>();
1296
+ const archiveDmMessageIdAliases = new Map<string, string>();
1297
+
1298
+ function uniquePrimaryArchiveId(
1299
+ baseId: string,
1300
+ isTakenByOtherAccount: (candidate: string) => boolean,
1301
+ isPending: (candidate: string) => boolean,
1302
+ ) {
1303
+ let index = 1;
1304
+ while (true) {
1305
+ const suffix = index === 1 ? "" : `:${index}`;
1306
+ const candidate = `acct_primary:${baseId}${suffix}`;
1307
+ if (!isTakenByOtherAccount(candidate) && !isPending(candidate)) {
1308
+ return candidate;
1309
+ }
1310
+ index += 1;
1189
1311
  }
1190
- index += 1;
1191
1312
  }
1192
- }
1193
1313
 
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
- }
1314
+ function resolveArchiveDmConversationId(conversationId: string) {
1315
+ const existingAlias = archiveDmConversationIdAliases.get(conversationId);
1316
+ if (existingAlias) return existingAlias;
1317
+ if (!selection) {
1318
+ archiveDmConversationIdAliases.set(conversationId, conversationId);
1319
+ return conversationId;
1320
+ }
1201
1321
 
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
- }
1322
+ const takenByOtherAccount = (candidate: string) => {
1323
+ const accountId = existingDmConversationAccounts.get(candidate);
1324
+ return accountId !== undefined && accountId !== "acct_primary";
1325
+ };
1326
+ const resolved = takenByOtherAccount(conversationId)
1327
+ ? uniquePrimaryArchiveId(
1328
+ conversationId,
1329
+ takenByOtherAccount,
1330
+ (candidate) => conversations.has(candidate),
1331
+ )
1332
+ : conversationId;
1333
+ archiveDmConversationIdAliases.set(conversationId, resolved);
1334
+ return resolved;
1335
+ }
1216
1336
 
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
- }
1337
+ function resolveArchiveDmMessageId(
1338
+ messageId: string,
1339
+ conversationIdChanged: boolean,
1340
+ ) {
1341
+ const existingAlias = archiveDmMessageIdAliases.get(messageId);
1342
+ if (existingAlias) return existingAlias;
1343
+ const shouldRemap =
1344
+ selection &&
1345
+ (conversationIdChanged || existingOtherDmMessageIds.has(messageId));
1346
+ const resolved = shouldRemap
1347
+ ? uniquePrimaryArchiveId(
1348
+ messageId,
1349
+ (candidate) => existingOtherDmMessageIds.has(candidate),
1350
+ (candidate) =>
1351
+ dmMessages.some((message) => message.id === candidate),
1352
+ )
1353
+ : messageId;
1354
+ archiveDmMessageIdAliases.set(messageId, resolved);
1355
+ return resolved;
1356
+ }
1236
1357
 
1237
- for (const entry of dmEntries) {
1238
- const content = await readArchiveEntry(archivePath, entry);
1239
- for (const wrapper of parseArchiveArray(content)) {
1240
- const dmConversation = asRecord(wrapper.dmConversation);
1241
- if (!dmConversation) continue;
1242
-
1243
- const rawConversationId = String(dmConversation.conversationId ?? "");
1244
- if (!rawConversationId) continue;
1245
- const conversationId = resolveArchiveDmConversationId(rawConversationId);
1246
- const conversationIdChanged = conversationId !== rawConversationId;
1247
-
1248
- const conversationName = String(dmConversation.name ?? "").trim();
1249
- const participantIds = new Set<string>();
1250
- const rawMessages = asArray<Record<string, unknown>>(
1251
- dmConversation.messages,
1252
- );
1358
+ if (dmEntries.length > 0) {
1359
+ onProgress({
1360
+ kind: "slice-start",
1361
+ slice: "directMessages",
1362
+ files: dmEntries.length,
1363
+ });
1364
+ }
1365
+ for (const [dmFileIndex, entry] of dmEntries.entries()) {
1366
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1367
+ for (const wrapper of parseArchiveArray(content)) {
1368
+ const dmConversation = asRecord(wrapper.dmConversation);
1369
+ if (!dmConversation) continue;
1370
+
1371
+ const rawConversationId = String(dmConversation.conversationId ?? "");
1372
+ if (!rawConversationId) continue;
1373
+ const conversationId =
1374
+ resolveArchiveDmConversationId(rawConversationId);
1375
+ const conversationIdChanged = conversationId !== rawConversationId;
1376
+
1377
+ const conversationName = String(dmConversation.name ?? "").trim();
1378
+ const participantIds = new Set<string>();
1379
+ const rawMessages = asArray<Record<string, unknown>>(
1380
+ dmConversation.messages,
1381
+ );
1253
1382
 
1254
- for (const event of rawMessages) {
1255
- const messageCreate = asRecord(event.messageCreate);
1256
- if (messageCreate) {
1257
- const senderId = String(messageCreate.senderId ?? "");
1258
- const recipientId = String(messageCreate.recipientId ?? "");
1259
- if (senderId) participantIds.add(senderId);
1260
- if (recipientId) participantIds.add(recipientId);
1261
- }
1383
+ for (const event of rawMessages) {
1384
+ const messageCreate = asRecord(event.messageCreate);
1385
+ if (messageCreate) {
1386
+ const senderId = String(messageCreate.senderId ?? "");
1387
+ const recipientId = String(messageCreate.recipientId ?? "");
1388
+ if (senderId) participantIds.add(senderId);
1389
+ if (recipientId) participantIds.add(recipientId);
1390
+ }
1262
1391
 
1263
- const joinConversation = asRecord(event.joinConversation);
1264
- if (joinConversation) {
1265
- for (const userId of asArray<string>(
1266
- joinConversation.participantsSnapshot,
1267
- )) {
1268
- participantIds.add(String(userId));
1392
+ const joinConversation = asRecord(event.joinConversation);
1393
+ if (joinConversation) {
1394
+ for (const userId of asArray<string>(
1395
+ joinConversation.participantsSnapshot,
1396
+ )) {
1397
+ participantIds.add(String(userId));
1398
+ }
1269
1399
  }
1270
- }
1271
1400
 
1272
- const participantsJoin = asRecord(event.participantsJoin);
1273
- if (participantsJoin) {
1274
- for (const userId of asArray<string>(participantsJoin.userIds)) {
1275
- participantIds.add(String(userId));
1401
+ const participantsJoin = asRecord(event.participantsJoin);
1402
+ if (participantsJoin) {
1403
+ for (const userId of asArray<string>(participantsJoin.userIds)) {
1404
+ participantIds.add(String(userId));
1405
+ }
1406
+ const initiatingUserId = String(
1407
+ participantsJoin.initiatingUserId ?? "",
1408
+ );
1409
+ if (initiatingUserId) {
1410
+ participantIds.add(initiatingUserId);
1411
+ }
1276
1412
  }
1277
- const initiatingUserId = String(
1278
- participantsJoin.initiatingUserId ?? "",
1279
- );
1280
- if (initiatingUserId) {
1281
- participantIds.add(initiatingUserId);
1413
+
1414
+ const participantsLeave = asRecord(event.participantsLeave);
1415
+ if (participantsLeave) {
1416
+ for (const userId of asArray<string>(participantsLeave.userIds)) {
1417
+ participantIds.add(String(userId));
1418
+ }
1419
+ const initiatingUserId = String(
1420
+ participantsLeave.initiatingUserId ?? "",
1421
+ );
1422
+ if (initiatingUserId) {
1423
+ participantIds.add(initiatingUserId);
1424
+ }
1282
1425
  }
1283
1426
  }
1284
1427
 
1285
- const participantsLeave = asRecord(event.participantsLeave);
1286
- if (participantsLeave) {
1287
- for (const userId of asArray<string>(participantsLeave.userIds)) {
1288
- participantIds.add(String(userId));
1289
- }
1290
- const initiatingUserId = String(
1291
- participantsLeave.initiatingUserId ?? "",
1292
- );
1293
- if (initiatingUserId) {
1294
- participantIds.add(initiatingUserId);
1428
+ const externalParticipantIds = [...participantIds].filter(
1429
+ (userId) => userId && userId !== accountPayload.accountId,
1430
+ );
1431
+ const isGroup =
1432
+ conversationName.length > 0 || externalParticipantIds.length > 1;
1433
+ const participantProfileId = isGroup
1434
+ ? `profile_group_${conversationId}`
1435
+ : `profile_user_${externalParticipantIds[0] ?? conversationId}`;
1436
+
1437
+ if (!profiles.has(participantProfileId)) {
1438
+ if (isGroup) {
1439
+ profiles.set(participantProfileId, {
1440
+ id: participantProfileId,
1441
+ handle: `group-${conversationId}`,
1442
+ displayName:
1443
+ conversationName || `Group DM ${externalParticipantIds.length}`,
1444
+ bio: `Group DM with ${externalParticipantIds.length} participants`,
1445
+ followersCount: 0,
1446
+ followingCount: 0,
1447
+ ...defaultProfileMetadata,
1448
+ avatarHue: 220,
1449
+ avatarUrl: null,
1450
+ createdAt: accountPayload.createdAt,
1451
+ });
1452
+ } else {
1453
+ const otherUserId = externalParticipantIds[0] ?? conversationId;
1454
+ const inferred = inferProfileFromDirectory(
1455
+ otherUserId,
1456
+ mentionDirectory,
1457
+ );
1458
+ mergeArchiveProfile({
1459
+ id: participantProfileId,
1460
+ handle: inferred.handle,
1461
+ displayName: inferred.displayName,
1462
+ bio: `Imported from archive user ${otherUserId}`,
1463
+ followersCount: 0,
1464
+ followingCount: 0,
1465
+ ...defaultProfileMetadata,
1466
+ avatarHue: 210,
1467
+ avatarUrl: null,
1468
+ createdAt: accountPayload.createdAt,
1469
+ });
1295
1470
  }
1296
1471
  }
1297
- }
1298
1472
 
1299
- const externalParticipantIds = [...participantIds].filter(
1300
- (userId) => userId && userId !== accountPayload.accountId,
1301
- );
1302
- const isGroup =
1303
- conversationName.length > 0 || externalParticipantIds.length > 1;
1304
- const participantProfileId = isGroup
1305
- ? `profile_group_${conversationId}`
1306
- : `profile_user_${externalParticipantIds[0] ?? conversationId}`;
1307
-
1308
- if (!profiles.has(participantProfileId)) {
1309
- if (isGroup) {
1310
- profiles.set(participantProfileId, {
1311
- id: participantProfileId,
1312
- handle: `group-${conversationId}`,
1313
- displayName:
1314
- conversationName || `Group DM ${externalParticipantIds.length}`,
1315
- bio: `Group DM with ${externalParticipantIds.length} participants`,
1316
- followersCount: 0,
1317
- followingCount: 0,
1318
- ...defaultProfileMetadata,
1319
- avatarHue: 220,
1320
- avatarUrl: null,
1321
- createdAt: accountPayload.createdAt,
1322
- });
1323
- } else {
1324
- const otherUserId = externalParticipantIds[0] ?? conversationId;
1325
- const inferred = inferProfileFromDirectory(
1326
- otherUserId,
1327
- mentionDirectory,
1328
- );
1329
- mergeArchiveProfile({
1330
- id: participantProfileId,
1331
- handle: inferred.handle,
1332
- displayName: inferred.displayName,
1333
- bio: `Imported from archive user ${otherUserId}`,
1334
- followersCount: 0,
1335
- followingCount: 0,
1336
- ...defaultProfileMetadata,
1337
- avatarHue: 210,
1338
- avatarUrl: null,
1339
- createdAt: accountPayload.createdAt,
1340
- });
1341
- }
1342
- }
1473
+ const messageEvents = rawMessages
1474
+ .map((event) => asRecord(event.messageCreate))
1475
+ .filter((event): event is Record<string, unknown> => event !== null)
1476
+ .map((messageCreate) => {
1477
+ const senderId = String(messageCreate.senderId ?? "");
1478
+ const rawMessageId = String(
1479
+ messageCreate.id ?? `${rawConversationId}-${senderId}`,
1480
+ );
1481
+ const senderProfileId =
1482
+ senderId === accountPayload.accountId
1483
+ ? localProfile.id
1484
+ : `profile_user_${senderId}`;
1485
+
1486
+ if (senderId && senderId !== accountPayload.accountId) {
1487
+ const inferred = inferProfileFromDirectory(
1488
+ senderId,
1489
+ mentionDirectory,
1490
+ );
1491
+ if (!profiles.has(senderProfileId)) {
1492
+ mergeArchiveProfile({
1493
+ id: senderProfileId,
1494
+ handle: inferred.handle,
1495
+ displayName: inferred.displayName,
1496
+ bio: `Imported from archive user ${senderId}`,
1497
+ followersCount: 0,
1498
+ followingCount: 0,
1499
+ ...defaultProfileMetadata,
1500
+ avatarHue: 240,
1501
+ avatarUrl: null,
1502
+ createdAt: accountPayload.createdAt,
1503
+ });
1504
+ }
1505
+ }
1343
1506
 
1344
- const messageEvents = rawMessages
1345
- .map((event) => asRecord(event.messageCreate))
1346
- .filter((event): event is Record<string, unknown> => event !== null)
1347
- .map((messageCreate) => {
1348
- const senderId = String(messageCreate.senderId ?? "");
1349
- const rawMessageId = String(
1350
- messageCreate.id ?? `${rawConversationId}-${senderId}`,
1507
+ return {
1508
+ id: resolveArchiveDmMessageId(
1509
+ rawMessageId,
1510
+ conversationIdChanged,
1511
+ ),
1512
+ conversationId,
1513
+ senderProfileId: resolveProfileId(senderProfileId),
1514
+ text: String(messageCreate.text ?? ""),
1515
+ createdAt: parseTwitterDate(messageCreate.createdAt),
1516
+ direction:
1517
+ senderId === accountPayload.accountId ? "outbound" : "inbound",
1518
+ mediaCount: asArray(messageCreate.mediaUrls).length,
1519
+ } satisfies MessageRow;
1520
+ })
1521
+ .sort((left, right) =>
1522
+ compareIsoTimestamp(left.createdAt, right.createdAt),
1351
1523
  );
1352
- const senderProfileId =
1353
- senderId === accountPayload.accountId
1354
- ? localProfile.id
1355
- : `profile_user_${senderId}`;
1356
1524
 
1357
- if (senderId && senderId !== accountPayload.accountId) {
1358
- const inferred = inferProfileFromDirectory(
1359
- senderId,
1360
- mentionDirectory,
1361
- );
1362
- if (!profiles.has(senderProfileId)) {
1363
- mergeArchiveProfile({
1364
- id: senderProfileId,
1365
- handle: inferred.handle,
1366
- displayName: inferred.displayName,
1367
- bio: `Imported from archive user ${senderId}`,
1368
- followersCount: 0,
1369
- followingCount: 0,
1370
- ...defaultProfileMetadata,
1371
- avatarHue: 240,
1372
- avatarUrl: null,
1373
- createdAt: accountPayload.createdAt,
1374
- });
1375
- }
1376
- }
1525
+ if (messageEvents.length === 0) {
1526
+ continue;
1527
+ }
1377
1528
 
1378
- return {
1379
- id: resolveArchiveDmMessageId(rawMessageId, conversationIdChanged),
1529
+ const lastMessage = messageEvents.at(-1);
1530
+ if (!lastMessage) continue;
1531
+
1532
+ dmMessages.push(...messageEvents);
1533
+ const resolvedParticipantProfileId =
1534
+ resolveProfileId(participantProfileId);
1535
+ conversations.set(conversationId, {
1536
+ id: conversationId,
1537
+ title:
1538
+ profiles.get(resolvedParticipantProfileId)?.displayName ||
1539
+ conversationName ||
1380
1540
  conversationId,
1381
- senderProfileId: resolveProfileId(senderProfileId),
1382
- text: String(messageCreate.text ?? ""),
1383
- createdAt: parseTwitterDate(messageCreate.createdAt),
1384
- direction:
1385
- senderId === accountPayload.accountId ? "outbound" : "inbound",
1386
- mediaCount: asArray(messageCreate.mediaUrls).length,
1387
- } satisfies MessageRow;
1388
- })
1389
- .sort((left, right) =>
1390
- compareIsoTimestamp(left.createdAt, right.createdAt),
1391
- );
1392
-
1393
- if (messageEvents.length === 0) {
1394
- continue;
1541
+ accountId: "acct_primary",
1542
+ participantProfileId: resolvedParticipantProfileId,
1543
+ lastMessageAt: lastMessage.createdAt,
1544
+ unreadCount: 0,
1545
+ needsReply: lastMessage.direction === "inbound" ? 1 : 0,
1546
+ });
1395
1547
  }
1396
-
1397
- const lastMessage = messageEvents.at(-1);
1398
- if (!lastMessage) continue;
1399
-
1400
- dmMessages.push(...messageEvents);
1401
- const resolvedParticipantProfileId =
1402
- resolveProfileId(participantProfileId);
1403
- conversations.set(conversationId, {
1404
- id: conversationId,
1405
- title:
1406
- profiles.get(resolvedParticipantProfileId)?.displayName ||
1407
- conversationName ||
1408
- conversationId,
1409
- accountId: "acct_primary",
1410
- participantProfileId: resolvedParticipantProfileId,
1411
- lastMessageAt: lastMessage.createdAt,
1412
- unreadCount: 0,
1413
- needsReply: lastMessage.direction === "inbound" ? 1 : 0,
1548
+ onProgress({
1549
+ kind: "slice-file",
1550
+ slice: "directMessages",
1551
+ processed: dmFileIndex + 1,
1552
+ files: dmEntries.length,
1553
+ });
1554
+ }
1555
+ if (dmEntries.length > 0) {
1556
+ onProgress({
1557
+ kind: "slice-done",
1558
+ slice: "directMessages",
1559
+ count: dmMessages.length,
1414
1560
  });
1415
1561
  }
1416
- }
1417
1562
 
1418
- let likeCount = 0;
1419
- for (const entry of likeEntries) {
1420
- const content = await readArchiveEntry(archivePath, entry);
1421
- const likes = parseArchiveArray(content);
1422
- for (const like of likes) {
1423
- const tweet = extractCollectionTweet(like, "like");
1424
- if (!tweet) continue;
1425
- collectionRows.push({
1426
- tweetId: tweet.id,
1427
- kind: "likes",
1428
- collectedAt: tweet.createdAt,
1429
- source: "archive",
1430
- rawJson: JSON.stringify(like),
1563
+ if (likeEntries.length > 0) {
1564
+ onProgress({
1565
+ kind: "slice-start",
1566
+ slice: "likes",
1567
+ files: likeEntries.length,
1431
1568
  });
1432
- addTweetRow({
1433
- id: tweet.id,
1434
- kind: "like",
1435
- authorProfileId: "profile_unknown",
1436
- text: tweet.text,
1437
- createdAt: tweet.createdAt,
1438
- isReplied: 0,
1439
- replyToId: null,
1440
- likeCount: tweet.likeCount,
1441
- mediaCount: 0,
1442
- bookmarked: 0,
1443
- liked: 1,
1444
- entitiesJson: "{}",
1445
- mediaJson: "[]",
1446
- quotedTweetId: null,
1569
+ }
1570
+ let likeCount = 0;
1571
+ for (const [likeFileIndex, entry] of likeEntries.entries()) {
1572
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1573
+ const likes = parseArchiveArray(content);
1574
+ for (const like of likes) {
1575
+ const tweet = extractCollectionTweet(like, "like");
1576
+ if (!tweet) continue;
1577
+ collectionRows.push({
1578
+ tweetId: tweet.id,
1579
+ kind: "likes",
1580
+ collectedAt: tweet.createdAt,
1581
+ source: "archive",
1582
+ rawJson: JSON.stringify(like),
1583
+ });
1584
+ addTweetRow({
1585
+ id: tweet.id,
1586
+ kind: "like",
1587
+ authorProfileId: "profile_unknown",
1588
+ text: tweet.text,
1589
+ createdAt: tweet.createdAt,
1590
+ isReplied: 0,
1591
+ replyToId: null,
1592
+ likeCount: tweet.likeCount,
1593
+ mediaCount: 0,
1594
+ bookmarked: 0,
1595
+ liked: 1,
1596
+ entitiesJson: "{}",
1597
+ mediaJson: "[]",
1598
+ quotedTweetId: null,
1599
+ });
1600
+ }
1601
+ likeCount += likes.length;
1602
+ onProgress({
1603
+ kind: "slice-file",
1604
+ slice: "likes",
1605
+ processed: likeFileIndex + 1,
1606
+ files: likeEntries.length,
1447
1607
  });
1448
1608
  }
1449
- likeCount += likes.length;
1450
- }
1609
+ if (likeEntries.length > 0) {
1610
+ onProgress({ kind: "slice-done", slice: "likes", count: likeCount });
1611
+ }
1451
1612
 
1452
- let bookmarkCount = 0;
1453
- for (const entry of bookmarkEntries) {
1454
- const content = await readArchiveEntry(archivePath, entry);
1455
- const bookmarks = parseArchiveArray(content);
1456
- for (const bookmark of bookmarks) {
1457
- const tweet = extractCollectionTweet(bookmark, "bookmark");
1458
- if (!tweet) continue;
1459
- collectionRows.push({
1460
- tweetId: tweet.id,
1461
- kind: "bookmarks",
1462
- collectedAt: tweet.createdAt,
1463
- source: "archive",
1464
- rawJson: JSON.stringify(bookmark),
1613
+ if (bookmarkEntries.length > 0) {
1614
+ onProgress({
1615
+ kind: "slice-start",
1616
+ slice: "bookmarks",
1617
+ files: bookmarkEntries.length,
1465
1618
  });
1466
- addTweetRow({
1467
- id: tweet.id,
1468
- kind: "bookmark",
1469
- authorProfileId: "profile_unknown",
1470
- text: tweet.text,
1471
- createdAt: tweet.createdAt,
1472
- isReplied: 0,
1473
- replyToId: null,
1474
- likeCount: tweet.likeCount,
1475
- mediaCount: 0,
1476
- bookmarked: 1,
1477
- liked: 0,
1478
- entitiesJson: "{}",
1479
- mediaJson: "[]",
1480
- quotedTweetId: null,
1619
+ }
1620
+ let bookmarkCount = 0;
1621
+ for (const [bookmarkFileIndex, entry] of bookmarkEntries.entries()) {
1622
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1623
+ const bookmarks = parseArchiveArray(content);
1624
+ for (const bookmark of bookmarks) {
1625
+ const tweet = extractCollectionTweet(bookmark, "bookmark");
1626
+ if (!tweet) continue;
1627
+ collectionRows.push({
1628
+ tweetId: tweet.id,
1629
+ kind: "bookmarks",
1630
+ collectedAt: tweet.createdAt,
1631
+ source: "archive",
1632
+ rawJson: JSON.stringify(bookmark),
1633
+ });
1634
+ addTweetRow({
1635
+ id: tweet.id,
1636
+ kind: "bookmark",
1637
+ authorProfileId: "profile_unknown",
1638
+ text: tweet.text,
1639
+ createdAt: tweet.createdAt,
1640
+ isReplied: 0,
1641
+ replyToId: null,
1642
+ likeCount: tweet.likeCount,
1643
+ mediaCount: 0,
1644
+ bookmarked: 1,
1645
+ liked: 0,
1646
+ entitiesJson: "{}",
1647
+ mediaJson: "[]",
1648
+ quotedTweetId: null,
1649
+ });
1650
+ }
1651
+ bookmarkCount += bookmarks.length;
1652
+ onProgress({
1653
+ kind: "slice-file",
1654
+ slice: "bookmarks",
1655
+ processed: bookmarkFileIndex + 1,
1656
+ files: bookmarkEntries.length,
1657
+ });
1658
+ }
1659
+ if (bookmarkEntries.length > 0) {
1660
+ onProgress({
1661
+ kind: "slice-done",
1662
+ slice: "bookmarks",
1663
+ count: bookmarkCount,
1481
1664
  });
1482
1665
  }
1483
- bookmarkCount += bookmarks.length;
1484
- }
1485
1666
 
1486
- const mediaFileCounts = await extractArchiveMediaFiles(
1487
- archivePath,
1488
- selectedArchiveMediaKinds(selection),
1489
- );
1667
+ onProgress({ kind: "slice-start", slice: "media", files: 0 });
1668
+ const mediaFileCounts = yield* extractArchiveMediaFilesEffect(
1669
+ archivePath,
1670
+ selectedArchiveMediaKinds(selection),
1671
+ );
1672
+ onProgress({
1673
+ kind: "slice-done",
1674
+ slice: "media",
1675
+ count: Object.values(mediaFileCounts).reduce(
1676
+ (total, value) => total + value,
1677
+ 0,
1678
+ ),
1679
+ });
1490
1680
 
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);
1681
+ if (followerEntries.length > 0) {
1682
+ onProgress({
1683
+ kind: "slice-start",
1684
+ slice: "followers",
1685
+ files: followerEntries.length,
1686
+ });
1687
+ }
1688
+ for (const [followerFileIndex, entry] of followerEntries.entries()) {
1689
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1690
+ for (const row of getArchiveFollowRows(content, "follower")) {
1691
+ if (followerIds.has(row.externalUserId)) continue;
1692
+ followerIds.add(row.externalUserId);
1693
+ followerRows.push(row);
1694
+ }
1695
+ onProgress({
1696
+ kind: "slice-file",
1697
+ slice: "followers",
1698
+ processed: followerFileIndex + 1,
1699
+ files: followerEntries.length,
1700
+ });
1701
+ }
1702
+ if (followerEntries.length > 0) {
1703
+ onProgress({
1704
+ kind: "slice-done",
1705
+ slice: "followers",
1706
+ count: followerRows.length,
1707
+ });
1497
1708
  }
1498
- }
1499
1709
 
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);
1710
+ if (followingEntries.length > 0) {
1711
+ onProgress({
1712
+ kind: "slice-start",
1713
+ slice: "following",
1714
+ files: followingEntries.length,
1715
+ });
1716
+ }
1717
+ for (const [followingFileIndex, entry] of followingEntries.entries()) {
1718
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1719
+ for (const row of getArchiveFollowRows(content, "following")) {
1720
+ if (followingIds.has(row.externalUserId)) continue;
1721
+ followingIds.add(row.externalUserId);
1722
+ followingRows.push(row);
1723
+ }
1724
+ onProgress({
1725
+ kind: "slice-file",
1726
+ slice: "following",
1727
+ processed: followingFileIndex + 1,
1728
+ files: followingEntries.length,
1729
+ });
1730
+ }
1731
+ if (followingEntries.length > 0) {
1732
+ onProgress({
1733
+ kind: "slice-done",
1734
+ slice: "following",
1735
+ count: followingRows.length,
1736
+ });
1506
1737
  }
1507
- }
1508
1738
 
1509
- for (const row of [...followerRows, ...followingRows]) {
1510
- addArchiveFollowProfile(row.profileId, row.externalUserId);
1511
- }
1739
+ for (const row of [...followerRows, ...followingRows]) {
1740
+ addArchiveFollowProfile(row.profileId, row.externalUserId);
1741
+ }
1512
1742
 
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
- `
1743
+ const clearedFollowDirections = new Set<ArchiveFollowDirection>();
1744
+ if (includeFollowers && followerEntries.length === 0) {
1745
+ clearedFollowDirections.add("followers");
1746
+ }
1747
+ if (includeFollowing && followingEntries.length === 0) {
1748
+ clearedFollowDirections.add("following");
1749
+ }
1750
+ const retainedFollowProfiles = getNativeDb()
1751
+ .prepare(
1752
+ `
1523
1753
  select direction, profile_id, external_user_id, source, null as snapshot_id, null as snapshot_source
1524
1754
  from follow_edges
1525
1755
  union
@@ -1527,57 +1757,54 @@ export async function importArchive(
1527
1757
  from follow_events ev
1528
1758
  left join follow_snapshots snap on snap.id = ev.snapshot_id
1529
1759
  `,
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
-
1550
- if (tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")) {
1551
- const unknownProfile = {
1552
- id: "profile_unknown",
1553
- handle: selection
1554
- ? uniqueArchiveProfileHandle("unknown", "profile_unknown")
1555
- : "unknown",
1556
- displayName: "Unknown",
1557
- bio: "Imported from archive collection metadata",
1558
- followersCount: 0,
1559
- followingCount: 0,
1560
- ...defaultProfileMetadata,
1561
- avatarHue: 210,
1562
- avatarUrl: null,
1563
- createdAt: accountPayload.createdAt,
1564
- };
1565
- const existingUnknownProfile = existingProfiles.get("profile_unknown");
1566
- profiles.set(
1567
- "profile_unknown",
1568
- existingUnknownProfile
1569
- ? existingProfileToProfileRow(existingUnknownProfile)
1570
- : unknownProfile,
1571
- );
1572
- }
1760
+ )
1761
+ .all() as Array<{
1762
+ direction: ArchiveFollowDirection;
1763
+ profile_id: string;
1764
+ external_user_id: string;
1765
+ source: string | null;
1766
+ snapshot_id: string | null;
1767
+ snapshot_source: string | null;
1768
+ }>;
1769
+ for (const row of retainedFollowProfiles) {
1770
+ const isClearedArchiveRow =
1771
+ clearedFollowDirections.has(row.direction) &&
1772
+ (row.source === "archive" ||
1773
+ row.snapshot_source === "archive" ||
1774
+ row.snapshot_id ===
1775
+ `follow_snapshot_archive_acct_primary_${row.direction}`);
1776
+ if (isClearedArchiveRow) continue;
1777
+ addArchiveFollowProfile(row.profile_id, row.external_user_id);
1778
+ }
1573
1779
 
1574
- if (!selection) {
1575
- clearImportedData();
1576
- clearMentionSyncState();
1577
- }
1780
+ if (
1781
+ tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")
1782
+ ) {
1783
+ const unknownProfile = {
1784
+ id: "profile_unknown",
1785
+ handle: selection
1786
+ ? uniqueArchiveProfileHandle("unknown", "profile_unknown")
1787
+ : "unknown",
1788
+ displayName: "Unknown",
1789
+ bio: "Imported from archive collection metadata",
1790
+ followersCount: 0,
1791
+ followingCount: 0,
1792
+ ...defaultProfileMetadata,
1793
+ avatarHue: 210,
1794
+ avatarUrl: null,
1795
+ createdAt: accountPayload.createdAt,
1796
+ };
1797
+ const existingUnknownProfile = existingProfiles.get("profile_unknown");
1798
+ profiles.set(
1799
+ "profile_unknown",
1800
+ existingUnknownProfile
1801
+ ? existingProfileToProfileRow(existingUnknownProfile)
1802
+ : unknownProfile,
1803
+ );
1804
+ }
1578
1805
 
1579
- const db = getNativeDb();
1580
- const insertAccount = db.prepare(`
1806
+ const db = getNativeDb();
1807
+ const insertAccount = db.prepare(`
1581
1808
  insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1582
1809
  values (?, ?, ?, ?, ?, 1, ?)
1583
1810
  on conflict(id) do update set
@@ -1588,11 +1815,11 @@ export async function importArchive(
1588
1815
  is_default = 1,
1589
1816
  created_at = excluded.created_at
1590
1817
  `);
1591
- const insertAccountIfMissing = db.prepare(`
1818
+ const insertAccountIfMissing = db.prepare(`
1592
1819
  insert or ignore into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1593
1820
  values (?, ?, ?, ?, ?, 1, ?)
1594
1821
  `);
1595
- const insertProfile = db.prepare(`
1822
+ const insertProfile = db.prepare(`
1596
1823
  insert into profiles (
1597
1824
  id, handle, display_name, bio, followers_count, following_count,
1598
1825
  public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
@@ -1615,7 +1842,7 @@ export async function importArchive(
1615
1842
  raw_json = excluded.raw_json,
1616
1843
  created_at = excluded.created_at
1617
1844
  `);
1618
- const insertProfileIfMissing = db.prepare(`
1845
+ const insertProfileIfMissing = db.prepare(`
1619
1846
  insert or ignore into profiles (
1620
1847
  id, handle, display_name, bio, followers_count, following_count,
1621
1848
  public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
@@ -1623,7 +1850,7 @@ export async function importArchive(
1623
1850
  )
1624
1851
  values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1625
1852
  `);
1626
- const insertTweet = db.prepare(`
1853
+ const insertTweet = db.prepare(`
1627
1854
  insert into tweets (
1628
1855
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
1629
1856
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
@@ -1667,14 +1894,16 @@ export async function importArchive(
1667
1894
  media_json = case when excluded.media_json <> '[]' then excluded.media_json else tweets.media_json end,
1668
1895
  quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
1669
1896
  `);
1670
- const deleteTweetFts = db.prepare(
1671
- "delete from tweets_fts where tweet_id = ?",
1672
- );
1673
- const insertTweetFts = db.prepare(
1674
- "insert into tweets_fts (tweet_id, text) values (?, ?)",
1675
- );
1676
- const selectTweetFtsText = db.prepare("select text from tweets where id = ?");
1677
- const insertTimelineEdge = db.prepare(`
1897
+ const deleteTweetFts = db.prepare(
1898
+ "delete from tweets_fts where tweet_id = ?",
1899
+ );
1900
+ const insertTweetFts = db.prepare(
1901
+ "insert into tweets_fts (tweet_id, text) values (?, ?)",
1902
+ );
1903
+ const selectTweetFtsText = db.prepare(
1904
+ "select text from tweets where id = ?",
1905
+ );
1906
+ const insertTimelineEdge = db.prepare(`
1678
1907
  insert into tweet_account_edges (
1679
1908
  account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
1680
1909
  raw_json, updated_at
@@ -1684,7 +1913,7 @@ export async function importArchive(
1684
1913
  last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1685
1914
  updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1686
1915
  `);
1687
- const insertCollection = db.prepare(`
1916
+ const insertCollection = db.prepare(`
1688
1917
  insert into tweet_collections (
1689
1918
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
1690
1919
  ) values (?, ?, ?, ?, ?, ?, ?)
@@ -1700,20 +1929,20 @@ export async function importArchive(
1700
1929
  end,
1701
1930
  updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1702
1931
  `);
1703
- const insertConversation = db.prepare(`
1932
+ const insertConversation = db.prepare(`
1704
1933
  insert into dm_conversations (
1705
1934
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1706
1935
  ) values (?, ?, ?, ?, ?, ?, ?)
1707
1936
  `);
1708
- const insertMessage = db.prepare(`
1937
+ const insertMessage = db.prepare(`
1709
1938
  insert into dm_messages (
1710
1939
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1711
1940
  ) values (?, ?, ?, ?, ?, ?, ?, ?)
1712
1941
  `);
1713
- const insertDmFts = db.prepare(
1714
- "insert into dm_fts (message_id, text) values (?, ?)",
1715
- );
1716
- const insertFollowSnapshot = db.prepare(`
1942
+ const insertDmFts = db.prepare(
1943
+ "insert into dm_fts (message_id, text) values (?, ?)",
1944
+ );
1945
+ const insertFollowSnapshot = db.prepare(`
1717
1946
  insert into follow_snapshots (
1718
1947
  id, account_id, direction, source, status, page_count, result_count,
1719
1948
  started_at, completed_at, raw_meta_json
@@ -1729,21 +1958,21 @@ export async function importArchive(
1729
1958
  completed_at = excluded.completed_at,
1730
1959
  raw_meta_json = excluded.raw_meta_json
1731
1960
  `);
1732
- const insertFollowSnapshotMember = db.prepare(`
1961
+ const insertFollowSnapshotMember = db.prepare(`
1733
1962
  insert into follow_snapshot_members (
1734
1963
  snapshot_id, profile_id, external_user_id, position
1735
1964
  ) values (?, ?, ?, ?)
1736
1965
  `);
1737
- const selectFollowSnapshotMembers = db.prepare(`
1966
+ const selectFollowSnapshotMembers = db.prepare(`
1738
1967
  select profile_id, external_user_id
1739
1968
  from follow_snapshot_members
1740
1969
  where snapshot_id = ?
1741
1970
  order by position, profile_id
1742
1971
  `);
1743
- const deleteFollowSnapshotMembers = db.prepare(
1744
- "delete from follow_snapshot_members where snapshot_id = ?",
1745
- );
1746
- const deleteArchiveFollowEvents = db.prepare(`
1972
+ const deleteFollowSnapshotMembers = db.prepare(
1973
+ "delete from follow_snapshot_members where snapshot_id = ?",
1974
+ );
1975
+ const deleteArchiveFollowEvents = db.prepare(`
1747
1976
  delete from follow_events
1748
1977
  where account_id = ? and direction = ? and (
1749
1978
  snapshot_id = ? or snapshot_id in (
@@ -1752,27 +1981,27 @@ export async function importArchive(
1752
1981
  )
1753
1982
  )
1754
1983
  `);
1755
- const deleteArchiveFollowSnapshotMembers = db.prepare(`
1984
+ const deleteArchiveFollowSnapshotMembers = db.prepare(`
1756
1985
  delete from follow_snapshot_members
1757
1986
  where snapshot_id in (
1758
1987
  select id from follow_snapshots
1759
1988
  where account_id = ? and direction = ? and source = 'archive'
1760
1989
  )
1761
1990
  `);
1762
- const deleteArchiveFollowSnapshots = db.prepare(`
1991
+ const deleteArchiveFollowSnapshots = db.prepare(`
1763
1992
  delete from follow_snapshots
1764
1993
  where account_id = ? and direction = ? and source = 'archive'
1765
1994
  `);
1766
- const deleteArchiveFollowEdges = db.prepare(`
1995
+ const deleteArchiveFollowEdges = db.prepare(`
1767
1996
  delete from follow_edges
1768
1997
  where account_id = ? and direction = ? and source = 'archive'
1769
1998
  `);
1770
- const selectFollowEdges = db.prepare(`
1999
+ const selectFollowEdges = db.prepare(`
1771
2000
  select profile_id, external_user_id, current
1772
2001
  from follow_edges
1773
2002
  where account_id = ? and direction = ?
1774
2003
  `);
1775
- const insertFollowEdge = db.prepare(`
2004
+ const insertFollowEdge = db.prepare(`
1776
2005
  insert into follow_edges (
1777
2006
  account_id, direction, profile_id, external_user_id, source, current,
1778
2007
  first_seen_at, last_seen_at, ended_at, updated_at
@@ -1788,25 +2017,25 @@ export async function importArchive(
1788
2017
  ended_at = null,
1789
2018
  updated_at = excluded.updated_at
1790
2019
  `);
1791
- const endFollowEdge = db.prepare(`
2020
+ const endFollowEdge = db.prepare(`
1792
2021
  update follow_edges
1793
2022
  set current = 0, ended_at = ?, updated_at = ?
1794
2023
  where account_id = ? and direction = ? and profile_id = ?
1795
2024
  `);
1796
- const insertFollowEvent = db.prepare(`
2025
+ const insertFollowEvent = db.prepare(`
1797
2026
  insert into follow_events (
1798
2027
  id, account_id, direction, profile_id, external_user_id, kind, event_at, snapshot_id
1799
2028
  ) values (?, ?, ?, ?, ?, ?, ?, ?)
1800
2029
  `);
1801
- const clearSelectedLikes = db.prepare(`
2030
+ const clearSelectedLikes = db.prepare(`
1802
2031
  delete from tweet_collections
1803
2032
  where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1804
2033
  `);
1805
- const clearSelectedBookmarks = db.prepare(`
2034
+ const clearSelectedBookmarks = db.prepare(`
1806
2035
  delete from tweet_collections
1807
2036
  where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1808
2037
  `);
1809
- const clearTweetLikedFlag = db.prepare(`
2038
+ const clearTweetLikedFlag = db.prepare(`
1810
2039
  update tweets
1811
2040
  set liked = 0
1812
2041
  where account_id = ?
@@ -1816,7 +2045,7 @@ export async function importArchive(
1816
2045
  where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1817
2046
  )
1818
2047
  `);
1819
- const clearTweetBookmarkedFlag = db.prepare(`
2048
+ const clearTweetBookmarkedFlag = db.prepare(`
1820
2049
  update tweets
1821
2050
  set bookmarked = 0
1822
2051
  where account_id = ?
@@ -1826,7 +2055,7 @@ export async function importArchive(
1826
2055
  where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1827
2056
  )
1828
2057
  `);
1829
- const clearSelectedArchiveTweetEdges = db.prepare(`
2058
+ const clearSelectedArchiveTweetEdges = db.prepare(`
1830
2059
  delete from tweet_account_edges
1831
2060
  where account_id = ?
1832
2061
  and kind in ('home', 'authored')
@@ -1844,12 +2073,12 @@ export async function importArchive(
1844
2073
  )
1845
2074
  )
1846
2075
  `);
1847
- const deleteOrphanTweetLinkOccurrences = db.prepare(`
2076
+ const deleteOrphanTweetLinkOccurrences = db.prepare(`
1848
2077
  delete from link_occurrences
1849
2078
  where source_kind = 'tweet'
1850
2079
  and source_id not in (select id from tweets)
1851
2080
  `);
1852
- const deleteOrphanArchiveCollectionTweets = db.prepare(`
2081
+ const deleteOrphanArchiveCollectionTweets = db.prepare(`
1853
2082
  delete from tweets
1854
2083
  where account_id = ?
1855
2084
  and kind in ('like', 'bookmark')
@@ -1870,7 +2099,7 @@ export async function importArchive(
1870
2099
  or referencing_tweet.quoted_tweet_id = tweets.id
1871
2100
  )
1872
2101
  `);
1873
- const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
2102
+ const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
1874
2103
  update tweets
1875
2104
  set kind = case
1876
2105
  when exists (
@@ -1911,7 +2140,7 @@ export async function importArchive(
1911
2140
  where account_id = ?
1912
2141
  )
1913
2142
  `);
1914
- const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
2143
+ const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
1915
2144
  update tweets
1916
2145
  set kind = 'archive_stale'
1917
2146
  where account_id = ?
@@ -1988,7 +2217,7 @@ export async function importArchive(
1988
2217
  )
1989
2218
  )
1990
2219
  `);
1991
- const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
2220
+ const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
1992
2221
  delete from tweets
1993
2222
  where account_id = ?
1994
2223
  and id in (
@@ -2061,11 +2290,11 @@ export async function importArchive(
2061
2290
  )
2062
2291
  )
2063
2292
  `);
2064
- const deleteOrphanTweetFts = db.prepare(`
2293
+ const deleteOrphanTweetFts = db.prepare(`
2065
2294
  delete from tweets_fts
2066
2295
  where tweet_id not in (select id from tweets)
2067
2296
  `);
2068
- const clearDmFts = db.prepare(`
2297
+ const clearDmFts = db.prepare(`
2069
2298
  delete from dm_fts
2070
2299
  where message_id in (
2071
2300
  select m.id
@@ -2074,7 +2303,7 @@ export async function importArchive(
2074
2303
  where c.account_id = ?
2075
2304
  )
2076
2305
  `);
2077
- const clearDmLinkOccurrences = db.prepare(`
2306
+ const clearDmLinkOccurrences = db.prepare(`
2078
2307
  delete from link_occurrences
2079
2308
  where source_kind = 'dm'
2080
2309
  and source_id in (
@@ -2084,362 +2313,435 @@ export async function importArchive(
2084
2313
  where c.account_id = ?
2085
2314
  )
2086
2315
  `);
2087
- const clearDmMessages = db.prepare(`
2316
+ const clearDmMessages = db.prepare(`
2088
2317
  delete from dm_messages
2089
2318
  where conversation_id in (
2090
2319
  select id from dm_conversations where account_id = ?
2091
2320
  )
2092
2321
  `);
2093
- const clearDmConversations = db.prepare(
2094
- "delete from dm_conversations where account_id = ?",
2095
- );
2322
+ const clearDmConversations = db.prepare(
2323
+ "delete from dm_conversations where account_id = ?",
2324
+ );
2096
2325
 
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<{
2326
+ function importFollowRows(
2327
+ direction: ArchiveFollowDirection,
2328
+ rows: Array<{ profileId: string; externalUserId: string }>,
2329
+ entryCount: number,
2330
+ now: string,
2331
+ ) {
2332
+ const snapshotId = `follow_snapshot_archive_acct_primary_${direction}`;
2333
+ const existingEdges = new Map(
2334
+ (
2335
+ selectFollowEdges.all("acct_primary", direction) as Array<{
2336
+ profile_id: string;
2337
+ external_user_id: string;
2338
+ current: number;
2339
+ }>
2340
+ ).map((row) => [row.profile_id, row]),
2341
+ );
2342
+ const existingMemberKey = (
2343
+ selectFollowSnapshotMembers.all(snapshotId) as Array<{
2107
2344
  profile_id: string;
2108
2345
  external_user_id: string;
2109
- current: number;
2110
2346
  }>
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
2347
  )
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
- }
2348
+ .map(
2349
+ (row, index) =>
2350
+ `${String(index)}:${row.profile_id}:${row.external_user_id}`,
2351
+ )
2352
+ .join("\n");
2353
+ const nextMemberKey = rows
2354
+ .map(
2355
+ (row, index) =>
2356
+ `${String(index)}:${row.profileId}:${row.externalUserId}`,
2357
+ )
2358
+ .join("\n");
2359
+ const membersChanged = existingMemberKey !== nextMemberKey;
2360
+ const currentProfileIds = new Set<string>();
2158
2361
 
2159
- const previous = existingEdges.get(profileId);
2160
- insertFollowEdge.run(
2362
+ insertFollowSnapshot.run(
2363
+ snapshotId,
2161
2364
  "acct_primary",
2162
2365
  direction,
2163
- profileId,
2164
- row.externalUserId,
2165
- now,
2366
+ entryCount,
2367
+ rows.length,
2166
2368
  now,
2167
2369
  now,
2370
+ JSON.stringify({ archivePath, result_count: rows.length }),
2168
2371
  );
2169
- if (!previous || previous.current === 0) {
2372
+
2373
+ if (membersChanged) {
2374
+ deleteFollowSnapshotMembers.run(snapshotId);
2375
+ }
2376
+ rows.forEach((row, index) => {
2377
+ const profileId = resolveProfileId(row.profileId);
2378
+ currentProfileIds.add(profileId);
2379
+ if (membersChanged) {
2380
+ insertFollowSnapshotMember.run(
2381
+ snapshotId,
2382
+ profileId,
2383
+ row.externalUserId,
2384
+ index,
2385
+ );
2386
+ }
2387
+
2388
+ const previous = existingEdges.get(profileId);
2389
+ insertFollowEdge.run(
2390
+ "acct_primary",
2391
+ direction,
2392
+ profileId,
2393
+ row.externalUserId,
2394
+ now,
2395
+ now,
2396
+ now,
2397
+ );
2398
+ if (!previous || previous.current === 0) {
2399
+ insertFollowEvent.run(
2400
+ `follow_event_${randomUUID()}`,
2401
+ "acct_primary",
2402
+ direction,
2403
+ profileId,
2404
+ row.externalUserId,
2405
+ "started",
2406
+ now,
2407
+ snapshotId,
2408
+ );
2409
+ }
2410
+ });
2411
+
2412
+ for (const [profileId, previous] of existingEdges) {
2413
+ if (previous.current === 0 || currentProfileIds.has(profileId)) {
2414
+ continue;
2415
+ }
2416
+ endFollowEdge.run(now, now, "acct_primary", direction, profileId);
2170
2417
  insertFollowEvent.run(
2171
2418
  `follow_event_${randomUUID()}`,
2172
2419
  "acct_primary",
2173
2420
  direction,
2174
2421
  profileId,
2175
- row.externalUserId,
2176
- "started",
2422
+ previous.external_user_id,
2423
+ "ended",
2177
2424
  now,
2178
2425
  snapshotId,
2179
2426
  );
2180
2427
  }
2181
- });
2428
+ }
2182
2429
 
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()}`,
2430
+ function clearArchiveFollowRows(direction: ArchiveFollowDirection) {
2431
+ deleteArchiveFollowEvents.run(
2432
+ "acct_primary",
2433
+ direction,
2434
+ `follow_snapshot_archive_acct_primary_${direction}`,
2190
2435
  "acct_primary",
2191
2436
  direction,
2192
- profileId,
2193
- previous.external_user_id,
2194
- "ended",
2195
- now,
2196
- snapshotId,
2197
2437
  );
2438
+ deleteArchiveFollowSnapshotMembers.run("acct_primary", direction);
2439
+ deleteArchiveFollowSnapshots.run("acct_primary", direction);
2440
+ deleteArchiveFollowEdges.run("acct_primary", direction);
2198
2441
  }
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
- }
2213
2442
 
2214
- db.transaction(() => {
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");
2443
+ onProgress({ kind: "writing" });
2444
+ const WRITE_PROGRESS_INTERVAL = 1000;
2445
+ function tickWrite(
2446
+ phase: ImportWritePhase,
2447
+ processed: number,
2448
+ total: number,
2449
+ ) {
2450
+ if (processed === total || processed % WRITE_PROGRESS_INTERVAL === 0) {
2451
+ onProgress({ kind: "write-progress", phase, processed, total });
2278
2452
  }
2279
2453
  }
2454
+ db.transaction(() => {
2455
+ if (!selection) {
2456
+ clearImportedData(db);
2457
+ clearMentionSyncState(db);
2458
+ }
2280
2459
 
2281
- const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2282
- writeAccount.run(
2283
- "acct_primary",
2284
- accountPayload.displayName,
2285
- `@${accountPayload.username}`,
2286
- accountPayload.accountId,
2287
- "archive",
2288
- accountPayload.createdAt,
2289
- );
2290
-
2291
- const writeProfile =
2292
- !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
2293
- for (const profile of profiles.values()) {
2294
- writeProfile.run(
2295
- profile.id,
2296
- profile.handle,
2297
- profile.displayName,
2298
- profile.bio,
2299
- profile.followersCount,
2300
- profile.followingCount,
2301
- profile.publicMetricsJson,
2302
- profile.avatarHue,
2303
- profile.avatarUrl,
2304
- profile.location,
2305
- profile.url,
2306
- profile.verifiedType,
2307
- profile.entitiesJson,
2308
- profile.rawJson,
2309
- profile.createdAt,
2310
- );
2311
- }
2460
+ if (selection) {
2461
+ if (includeTweets) {
2462
+ clearAuthoredSyncCursors(db, "acct_primary");
2463
+ demoteSelectedArchiveTweetsWithCollections.run(
2464
+ "acct_primary",
2465
+ "acct_primary",
2466
+ "acct_primary",
2467
+ "acct_primary",
2468
+ "acct_primary",
2469
+ localProfile.id,
2470
+ "acct_primary",
2471
+ );
2472
+ preserveSelectedArchiveTweetsReferencedElsewhere.run(
2473
+ "acct_primary",
2474
+ "acct_primary",
2475
+ "acct_primary",
2476
+ localProfile.id,
2477
+ "acct_primary",
2478
+ "acct_primary",
2479
+ "acct_primary",
2480
+ localProfile.id,
2481
+ "acct_primary",
2482
+ "acct_primary",
2483
+ localProfile.id,
2484
+ );
2485
+ deleteSelectedArchiveTweetsWithoutCollections.run(
2486
+ "acct_primary",
2487
+ "acct_primary",
2488
+ "acct_primary",
2489
+ localProfile.id,
2490
+ "acct_primary",
2491
+ "acct_primary",
2492
+ localProfile.id,
2493
+ "acct_primary",
2494
+ "acct_primary",
2495
+ localProfile.id,
2496
+ );
2497
+ deleteOrphanTweetFts.run();
2498
+ deleteOrphanTweetLinkOccurrences.run();
2499
+ clearSelectedArchiveTweetEdges.run(
2500
+ "acct_primary",
2501
+ "acct_primary",
2502
+ localProfile.id,
2503
+ );
2504
+ }
2505
+ if (includeLikes) {
2506
+ clearTweetLikedFlag.run("acct_primary", "acct_primary");
2507
+ clearSelectedLikes.run("acct_primary");
2508
+ }
2509
+ if (includeBookmarks) {
2510
+ clearTweetBookmarkedFlag.run("acct_primary", "acct_primary");
2511
+ clearSelectedBookmarks.run("acct_primary");
2512
+ }
2513
+ if (includeLikes || includeBookmarks) {
2514
+ deleteOrphanArchiveCollectionTweets.run("acct_primary");
2515
+ deleteOrphanTweetFts.run();
2516
+ deleteOrphanTweetLinkOccurrences.run();
2517
+ }
2518
+ if (includeDirectMessages) {
2519
+ clearDmLinkOccurrences.run("acct_primary");
2520
+ clearDmFts.run("acct_primary");
2521
+ clearDmMessages.run("acct_primary");
2522
+ clearDmConversations.run("acct_primary");
2523
+ }
2524
+ }
2312
2525
 
2313
- for (const tweet of tweetRows) {
2314
- const authorProfileId =
2315
- tweet.authorProfileId === "profile_me"
2316
- ? localProfile.id
2317
- : resolveProfileId(tweet.authorProfileId);
2318
- insertTweet.run(
2319
- tweet.id,
2526
+ const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2527
+ writeAccount.run(
2320
2528
  "acct_primary",
2321
- authorProfileId,
2322
- tweet.kind,
2323
- tweet.text,
2324
- tweet.createdAt,
2325
- tweet.isReplied,
2326
- tweet.replyToId,
2327
- tweet.likeCount,
2328
- tweet.mediaCount,
2329
- tweet.bookmarked,
2330
- tweet.liked,
2331
- tweet.entitiesJson,
2332
- tweet.mediaJson,
2333
- tweet.quotedTweetId,
2529
+ accountPayload.displayName,
2530
+ `@${accountPayload.username}`,
2531
+ accountPayload.accountId,
2532
+ "archive",
2533
+ accountPayload.createdAt,
2334
2534
  );
2335
- deleteTweetFts.run(tweet.id);
2336
- if (tweet.kind === "home") {
2337
- insertTimelineEdge.run(
2338
- "acct_primary",
2535
+
2536
+ const writeProfile =
2537
+ !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
2538
+ const profilesTotal = profiles.size;
2539
+ if (profilesTotal > 0) {
2540
+ onProgress({
2541
+ kind: "write-start",
2542
+ phase: "profiles",
2543
+ total: profilesTotal,
2544
+ });
2545
+ }
2546
+ let profileIndex = 0;
2547
+ for (const profile of profiles.values()) {
2548
+ writeProfile.run(
2549
+ profile.id,
2550
+ profile.handle,
2551
+ profile.displayName,
2552
+ profile.bio,
2553
+ profile.followersCount,
2554
+ profile.followingCount,
2555
+ profile.publicMetricsJson,
2556
+ profile.avatarHue,
2557
+ profile.avatarUrl,
2558
+ profile.location,
2559
+ profile.url,
2560
+ profile.verifiedType,
2561
+ profile.entitiesJson,
2562
+ profile.rawJson,
2563
+ profile.createdAt,
2564
+ );
2565
+ profileIndex += 1;
2566
+ tickWrite("profiles", profileIndex, profilesTotal);
2567
+ }
2568
+
2569
+ if (tweetRows.length > 0) {
2570
+ onProgress({
2571
+ kind: "write-start",
2572
+ phase: "tweets",
2573
+ total: tweetRows.length,
2574
+ });
2575
+ }
2576
+ let tweetWriteIndex = 0;
2577
+ for (const tweet of tweetRows) {
2578
+ const authorProfileId =
2579
+ tweet.authorProfileId === "profile_me"
2580
+ ? localProfile.id
2581
+ : resolveProfileId(tweet.authorProfileId);
2582
+ insertTweet.run(
2339
2583
  tweet.id,
2584
+ "acct_primary",
2585
+ authorProfileId,
2340
2586
  tweet.kind,
2587
+ tweet.text,
2341
2588
  tweet.createdAt,
2342
- tweet.createdAt,
2343
- new Date().toISOString(),
2589
+ tweet.isReplied,
2590
+ tweet.replyToId,
2591
+ tweet.likeCount,
2592
+ tweet.mediaCount,
2593
+ tweet.bookmarked,
2594
+ tweet.liked,
2595
+ tweet.entitiesJson,
2596
+ tweet.mediaJson,
2597
+ tweet.quotedTweetId,
2344
2598
  );
2599
+ deleteTweetFts.run(tweet.id);
2600
+ if (tweet.kind === "home") {
2601
+ insertTimelineEdge.run(
2602
+ "acct_primary",
2603
+ tweet.id,
2604
+ tweet.kind,
2605
+ tweet.createdAt,
2606
+ tweet.createdAt,
2607
+ new Date().toISOString(),
2608
+ );
2609
+ }
2610
+ if (authorProfileId === localProfile.id) {
2611
+ insertTimelineEdge.run(
2612
+ "acct_primary",
2613
+ tweet.id,
2614
+ "authored",
2615
+ tweet.createdAt,
2616
+ tweet.createdAt,
2617
+ new Date().toISOString(),
2618
+ );
2619
+ }
2620
+ const storedTweet = selectTweetFtsText.get(tweet.id) as
2621
+ | { text: string }
2622
+ | undefined;
2623
+ insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2624
+ tweetWriteIndex += 1;
2625
+ tickWrite("tweets", tweetWriteIndex, tweetRows.length);
2626
+ }
2627
+
2628
+ const importedAt = new Date().toISOString();
2629
+ if (collectionRows.length > 0) {
2630
+ onProgress({
2631
+ kind: "write-start",
2632
+ phase: "collections",
2633
+ total: collectionRows.length,
2634
+ });
2345
2635
  }
2346
- if (authorProfileId === localProfile.id) {
2347
- insertTimelineEdge.run(
2636
+ let collectionIndex = 0;
2637
+ for (const collection of collectionRows) {
2638
+ insertCollection.run(
2348
2639
  "acct_primary",
2349
- tweet.id,
2350
- "authored",
2351
- tweet.createdAt,
2352
- tweet.createdAt,
2353
- new Date().toISOString(),
2640
+ collection.tweetId,
2641
+ collection.kind,
2642
+ collection.collectedAt,
2643
+ collection.source,
2644
+ collection.rawJson,
2645
+ importedAt,
2354
2646
  );
2647
+ collectionIndex += 1;
2648
+ tickWrite("collections", collectionIndex, collectionRows.length);
2355
2649
  }
2356
- const storedTweet = selectTweetFtsText.get(tweet.id) as
2357
- | { text: string }
2358
- | undefined;
2359
- insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2360
- }
2361
2650
 
2362
- const importedAt = new Date().toISOString();
2363
- for (const collection of collectionRows) {
2364
- insertCollection.run(
2365
- "acct_primary",
2366
- collection.tweetId,
2367
- collection.kind,
2368
- collection.collectedAt,
2369
- collection.source,
2370
- collection.rawJson,
2371
- importedAt,
2372
- );
2373
- }
2651
+ for (const conversation of conversations.values()) {
2652
+ insertConversation.run(
2653
+ conversation.id,
2654
+ conversation.accountId,
2655
+ conversation.participantProfileId,
2656
+ conversation.title,
2657
+ conversation.lastMessageAt,
2658
+ conversation.unreadCount,
2659
+ conversation.needsReply,
2660
+ );
2661
+ }
2374
2662
 
2375
- for (const conversation of conversations.values()) {
2376
- insertConversation.run(
2377
- conversation.id,
2378
- conversation.accountId,
2379
- conversation.participantProfileId,
2380
- conversation.title,
2381
- conversation.lastMessageAt,
2382
- conversation.unreadCount,
2383
- conversation.needsReply,
2384
- );
2385
- }
2663
+ if (dmMessages.length > 0) {
2664
+ onProgress({
2665
+ kind: "write-start",
2666
+ phase: "dmMessages",
2667
+ total: dmMessages.length,
2668
+ });
2669
+ }
2670
+ let dmWriteIndex = 0;
2671
+ for (const message of dmMessages) {
2672
+ insertMessage.run(
2673
+ message.id,
2674
+ message.conversationId,
2675
+ message.senderProfileId,
2676
+ message.text,
2677
+ message.createdAt,
2678
+ message.direction,
2679
+ message.direction === "outbound" ? 1 : 0,
2680
+ message.mediaCount,
2681
+ );
2682
+ insertDmFts.run(message.id, message.text);
2683
+ dmWriteIndex += 1;
2684
+ tickWrite("dmMessages", dmWriteIndex, dmMessages.length);
2685
+ }
2386
2686
 
2387
- for (const message of dmMessages) {
2388
- insertMessage.run(
2389
- message.id,
2390
- message.conversationId,
2391
- message.senderProfileId,
2392
- message.text,
2393
- message.createdAt,
2394
- message.direction,
2395
- message.direction === "outbound" ? 1 : 0,
2396
- message.mediaCount,
2397
- );
2398
- insertDmFts.run(message.id, message.text);
2399
- }
2687
+ if (includeFollowers && followerEntries.length > 0) {
2688
+ importFollowRows(
2689
+ "followers",
2690
+ followerRows,
2691
+ followerEntries.length,
2692
+ importedAt,
2693
+ );
2694
+ } else if (includeFollowers) {
2695
+ clearArchiveFollowRows("followers");
2696
+ }
2697
+ if (includeFollowing && followingEntries.length > 0) {
2698
+ importFollowRows(
2699
+ "following",
2700
+ followingRows,
2701
+ followingEntries.length,
2702
+ importedAt,
2703
+ );
2704
+ } else if (includeFollowing) {
2705
+ clearArchiveFollowRows("following");
2706
+ }
2707
+ })();
2708
+ onProgress({ kind: "done" });
2400
2709
 
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
- }
2421
- })();
2710
+ return {
2711
+ ok: true,
2712
+ archivePath,
2713
+ account: {
2714
+ id: accountPayload.accountId,
2715
+ handle: accountPayload.username,
2716
+ displayName: accountPayload.displayName,
2717
+ },
2718
+ counts: {
2719
+ tweets: authoredTweetCount,
2720
+ likes: likeCount,
2721
+ bookmarks: bookmarkCount,
2722
+ dmConversations: conversations.size,
2723
+ dmMessages: dmMessages.length,
2724
+ profiles: profiles.size,
2725
+ mediaFiles: mediaFileCounts,
2726
+ followers: followerRows.length,
2727
+ following: followingRows.length,
2728
+ },
2729
+ };
2730
+ });
2731
+ }
2422
2732
 
2423
- return {
2424
- ok: true,
2425
- archivePath,
2426
- account: {
2427
- id: accountPayload.accountId,
2428
- handle: accountPayload.username,
2429
- displayName: accountPayload.displayName,
2430
- },
2431
- counts: {
2432
- tweets: authoredTweetCount,
2433
- likes: likeCount,
2434
- bookmarks: bookmarkCount,
2435
- dmConversations: conversations.size,
2436
- dmMessages: dmMessages.length,
2437
- profiles: profiles.size,
2438
- mediaFiles: mediaFileCounts,
2439
- followers: followerRows.length,
2440
- following: followingRows.length,
2441
- },
2442
- };
2733
+ export function importArchiveEffect(
2734
+ archivePath: string,
2735
+ options: ImportArchiveOptions = {},
2736
+ ): Effect.Effect<ImportedArchiveSummary, unknown> {
2737
+ return importArchiveInternalEffect(archivePath, options);
2738
+ }
2739
+
2740
+ export function importArchive(
2741
+ archivePath: string,
2742
+ options: ImportArchiveOptions = {},
2743
+ ): Promise<ImportedArchiveSummary> {
2744
+ return runEffectPromise(importArchiveEffect(archivePath, options));
2443
2745
  }
2444
2746
 
2445
2747
  export const __test__ = {