birdclaw 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +50 -5
  3. package/package.json +3 -2
  4. package/scripts/browser-perf.mjs +1 -0
  5. package/scripts/start-test-server.mjs +16 -3
  6. package/src/cli.ts +376 -13
  7. package/src/components/AccountSwitcher.tsx +186 -0
  8. package/src/components/AppNav.tsx +27 -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 +452 -0
  13. package/src/components/SyncNowButton.tsx +57 -25
  14. package/src/components/ThemeSlider.tsx +55 -50
  15. package/src/components/TimelineCard.tsx +225 -93
  16. package/src/components/TimelineRouteFrame.tsx +22 -8
  17. package/src/components/TweetMediaGrid.tsx +87 -38
  18. package/src/components/TweetRichText.tsx +15 -11
  19. package/src/components/account-selection.ts +64 -0
  20. package/src/components/useTimelineRouteData.ts +23 -7
  21. package/src/lib/account-sync-job.ts +654 -0
  22. package/src/lib/actions-transport.ts +216 -146
  23. package/src/lib/api-client.ts +128 -53
  24. package/src/lib/archive-finder.ts +78 -63
  25. package/src/lib/archive-import.ts +1364 -1300
  26. package/src/lib/authored-live.ts +261 -204
  27. package/src/lib/avatar-cache.ts +159 -44
  28. package/src/lib/backup.ts +1532 -951
  29. package/src/lib/bird-actions.ts +139 -57
  30. package/src/lib/bird-command.ts +101 -28
  31. package/src/lib/bird.ts +549 -194
  32. package/src/lib/blocklist.ts +40 -23
  33. package/src/lib/blocks-write.ts +129 -80
  34. package/src/lib/blocks.ts +165 -97
  35. package/src/lib/bookmark-sync-job.ts +250 -160
  36. package/src/lib/conversation-surface.ts +79 -48
  37. package/src/lib/db.ts +33 -3
  38. package/src/lib/dms-live.ts +720 -66
  39. package/src/lib/effect-runtime.ts +45 -0
  40. package/src/lib/follow-graph.ts +224 -180
  41. package/src/lib/http-effect.ts +222 -0
  42. package/src/lib/inbox.ts +74 -43
  43. package/src/lib/link-index.ts +88 -76
  44. package/src/lib/link-insights.ts +24 -0
  45. package/src/lib/link-preview-metadata.ts +472 -52
  46. package/src/lib/media-fetch.ts +286 -213
  47. package/src/lib/mention-threads-live.ts +352 -288
  48. package/src/lib/mentions-live.ts +390 -342
  49. package/src/lib/moderation-target.ts +102 -65
  50. package/src/lib/moderation-write.ts +77 -18
  51. package/src/lib/mutes-write.ts +129 -80
  52. package/src/lib/mutes.ts +8 -1
  53. package/src/lib/openai.ts +84 -53
  54. package/src/lib/period-digest.ts +953 -0
  55. package/src/lib/profile-affiliation-hydration.ts +93 -54
  56. package/src/lib/profile-hydration.ts +124 -72
  57. package/src/lib/profile-replies.ts +60 -43
  58. package/src/lib/profile-resolver.ts +402 -294
  59. package/src/lib/queries.ts +969 -199
  60. package/src/lib/research.ts +165 -120
  61. package/src/lib/sqlite.ts +1 -0
  62. package/src/lib/timeline-collections-live.ts +204 -167
  63. package/src/lib/timeline-live.ts +60 -39
  64. package/src/lib/tweet-lookup.ts +30 -19
  65. package/src/lib/types.ts +38 -1
  66. package/src/lib/ui.ts +30 -7
  67. package/src/lib/url-expansion.ts +226 -55
  68. package/src/lib/url-safety.ts +220 -0
  69. package/src/lib/web-sync.ts +216 -148
  70. package/src/lib/whois.ts +166 -147
  71. package/src/lib/x-web.ts +102 -71
  72. package/src/lib/xurl.ts +681 -411
  73. package/src/routeTree.gen.ts +42 -0
  74. package/src/routes/__root.tsx +25 -5
  75. package/src/routes/api/action.tsx +127 -78
  76. package/src/routes/api/avatar.tsx +39 -30
  77. package/src/routes/api/blocks.tsx +26 -23
  78. package/src/routes/api/conversation.tsx +25 -14
  79. package/src/routes/api/inbox.tsx +27 -21
  80. package/src/routes/api/link-insights.tsx +31 -25
  81. package/src/routes/api/link-preview.tsx +25 -21
  82. package/src/routes/api/period-digest.tsx +123 -0
  83. package/src/routes/api/profile-hydrate.tsx +31 -28
  84. package/src/routes/api/query.tsx +79 -55
  85. package/src/routes/api/status.tsx +18 -10
  86. package/src/routes/api/sync.tsx +75 -29
  87. package/src/routes/dms.tsx +95 -28
  88. package/src/routes/inbox.tsx +32 -19
  89. package/src/routes/today.tsx +441 -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;
@@ -108,48 +111,60 @@ function parseArchiveArray(content: string): ArchiveRecord[] {
108
111
  : [];
109
112
  }
110
113
 
111
- async function runUnzip(
114
+ function runUnzipEffect(
112
115
  _archivePath: string,
113
116
  args: string[],
114
117
  maxBuffer = 1024 * 1024 * 256,
115
118
  ) {
116
- const { stdout } = await execFileAsync("unzip", args, {
117
- maxBuffer,
118
- });
119
- return stdout;
119
+ return tryPromise(() =>
120
+ execFileAsync("unzip", args, {
121
+ maxBuffer,
122
+ }),
123
+ ).pipe(Effect.map(({ stdout }) => stdout));
120
124
  }
121
125
 
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);
126
+ function listArchiveEntriesEffect(
127
+ archivePath: string,
128
+ ): Effect.Effect<string[], unknown> {
129
+ return Effect.gen(function* () {
130
+ const stdout = yield* runUnzipEffect(
131
+ archivePath,
132
+ ["-Z1", archivePath],
133
+ 1024 * 1024 * 64,
134
+ );
135
+ return stdout
136
+ .split("\n")
137
+ .map((item) => item.trim())
138
+ .filter((item) => item.length > 0);
139
+ });
132
140
  }
133
141
 
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));
142
+ function listArchiveEntryDetailsEffect(
143
+ archivePath: string,
144
+ ): Effect.Effect<Array<{ path: string; size: number }>, unknown> {
145
+ return Effect.gen(function* () {
146
+ const stdout = yield* runUnzipEffect(
147
+ archivePath,
148
+ ["-Z", "-l", archivePath],
149
+ 1024 * 1024 * 64,
150
+ );
151
+ return stdout
152
+ .split("\n")
153
+ .map((line) => line.trim().split(/\s+/))
154
+ .filter((parts) => parts.length >= 10 && /^[-d]/.test(parts[0] ?? ""))
155
+ .map((parts) => ({
156
+ path: parts.slice(9).join(" "),
157
+ size: Number(parts[3] ?? 0),
158
+ }))
159
+ .filter((entry) => entry.path.length > 0 && Number.isFinite(entry.size));
160
+ });
149
161
  }
150
162
 
151
- async function readArchiveEntry(archivePath: string, entryPath: string) {
152
- return runUnzip(archivePath, ["-p", archivePath, entryPath]);
163
+ function readArchiveEntryEffect(
164
+ archivePath: string,
165
+ entryPath: string,
166
+ ): Effect.Effect<string, unknown> {
167
+ return runUnzipEffect(archivePath, ["-p", archivePath, entryPath]);
153
168
  }
154
169
 
155
170
  function getFirstEntry(entries: string[], pattern: RegExp) {
@@ -216,6 +231,10 @@ function toFiniteNumber(value: unknown) {
216
231
  return Number.isFinite(number) ? number : undefined;
217
232
  }
218
233
 
234
+ function archiveHttpUrl(value: unknown) {
235
+ return safeHttpUrl(typeof value === "string" ? value : String(value ?? ""));
236
+ }
237
+
219
238
  function extractTweetEntities(tweet: Record<string, unknown>) {
220
239
  const entities = asRecord(tweet.entities);
221
240
  const urlEntries = [
@@ -225,10 +244,10 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
225
244
  const seenUrls = new Set<string>();
226
245
  const urls = urlEntries
227
246
  .map((entry) => ({
228
- url: String(entry.url ?? ""),
229
- expandedUrl: String(
230
- entry.expanded_url ?? entry.expandedUrl ?? entry.url ?? "",
231
- ),
247
+ url: archiveHttpUrl(entry.url) ?? "",
248
+ expandedUrl:
249
+ archiveHttpUrl(entry.expanded_url ?? entry.expandedUrl ?? entry.url) ??
250
+ "",
232
251
  displayUrl: String(
233
252
  entry.display_url ??
234
253
  entry.displayUrl ??
@@ -242,17 +261,13 @@ function extractTweetEntities(tweet: Record<string, unknown>) {
242
261
  description:
243
262
  typeof entry.description === "string" ? entry.description : null,
244
263
  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,
264
+ archiveHttpUrl(
265
+ entry.image_url ??
266
+ entry.imageUrl ??
267
+ entry.thumbnail_url ??
268
+ entry.media_url_https ??
269
+ entry.media_url,
270
+ ) ?? undefined,
256
271
  siteName:
257
272
  typeof entry.site_name === "string"
258
273
  ? entry.site_name
@@ -356,12 +371,11 @@ function extractTweetMedia(tweet: Record<string, unknown>) {
356
371
 
357
372
  return sourceMedia
358
373
  .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
- );
374
+ const url =
375
+ archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? entry.url) ??
376
+ "";
377
+ const thumbnailUrl =
378
+ archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? url) ?? url;
365
379
  const videoInfo = asRecord(entry.video_info);
366
380
  const durationMs = toFiniteNumber(videoInfo?.duration_millis);
367
381
  const variants = archiveMp4Variants(entry);
@@ -515,66 +529,78 @@ function needsArchiveMediaCopy(destinationPath: string, size: number) {
515
529
  return statSync(destinationPath).size !== size;
516
530
  }
517
531
 
518
- async function copyArchiveEntryToFile(
532
+ function copyArchiveEntryToFileEffect(
519
533
  archivePath: string,
520
534
  entryPath: string,
521
535
  destinationPath: string,
522
536
  ) {
523
- mkdirSync(path.dirname(destinationPath), { recursive: true });
524
- const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`;
525
- const child = spawn("unzip", ["-p", archivePath, entryPath], {
526
- stdio: ["ignore", "pipe", "pipe"],
527
- });
528
- let stderr = "";
529
- child.stderr.setEncoding("utf8");
530
- child.stderr.on("data", (chunk) => {
531
- stderr += String(chunk);
532
- });
533
- const exit = new Promise<number | null>((resolve, reject) => {
534
- child.on("error", reject);
535
- child.on("close", resolve);
536
- });
537
+ return tryPromise(() => {
538
+ mkdirSync(path.dirname(destinationPath), { recursive: true });
539
+ const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`;
540
+ const child = spawn("unzip", ["-p", archivePath, entryPath], {
541
+ stdio: ["ignore", "pipe", "pipe"],
542
+ });
543
+ let stderr = "";
544
+ child.stderr.setEncoding("utf8");
545
+ child.stderr.on("data", (chunk) => {
546
+ stderr += String(chunk);
547
+ });
548
+ const exit = new Promise<number | null>((resolve, reject) => {
549
+ child.on("error", reject);
550
+ child.on("close", resolve);
551
+ });
537
552
 
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
- }
553
+ return pipeline(child.stdout, createWriteStream(temporaryPath))
554
+ .then(() => exit)
555
+ .then((exitCode) => {
556
+ if (exitCode !== 0) {
557
+ throw new Error(
558
+ `Failed to extract ${entryPath}: ${
559
+ stderr.trim() || `exit ${String(exitCode)}`
560
+ }`,
561
+ );
562
+ }
563
+ renameSync(temporaryPath, destinationPath);
564
+ })
565
+ .catch((error: unknown) => {
566
+ child.kill();
567
+ if (existsSync(temporaryPath)) {
568
+ unlinkSync(temporaryPath);
569
+ }
570
+ throw error;
571
+ });
572
+ });
554
573
  }
555
574
 
556
- async function extractArchiveMediaFiles(
575
+ function extractArchiveMediaFilesEffect(
557
576
  archivePath: string,
558
577
  selectedKinds: Set<ArchiveMediaKind> | null,
559
- ) {
560
- const counts = createArchiveMediaFileCounts();
561
- if (selectedKinds?.size === 0) {
578
+ ): Effect.Effect<ArchiveMediaFileCounts, unknown> {
579
+ return Effect.gen(function* () {
580
+ const counts = createArchiveMediaFileCounts();
581
+ if (selectedKinds?.size === 0) {
582
+ return counts;
583
+ }
584
+ const entries = yield* listArchiveEntryDetailsEffect(archivePath);
585
+ for (const entry of entries) {
586
+ const mediaKind = getArchiveMediaKind(entry.path);
587
+ if (!mediaKind) continue;
588
+ if (selectedKinds && !selectedKinds.has(mediaKind.kind)) continue;
589
+
590
+ counts[mediaKind.kind] += 1;
591
+ const destinationPath = getArchiveMediaDestination(
592
+ entry.path,
593
+ mediaKind.kind,
594
+ );
595
+ if (!needsArchiveMediaCopy(destinationPath, entry.size)) continue;
596
+ yield* copyArchiveEntryToFileEffect(
597
+ archivePath,
598
+ entry.path,
599
+ destinationPath,
600
+ );
601
+ }
562
602
  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;
603
+ });
578
604
  }
579
605
 
580
606
  function getArchiveFollowRows(content: string, key: ArchiveFollowKey) {
@@ -594,8 +620,7 @@ function getArchiveFollowRows(content: string, key: ArchiveFollowKey) {
594
620
  return rows;
595
621
  }
596
622
 
597
- function clearImportedData() {
598
- const db = getNativeDb();
623
+ function clearImportedData(db = getNativeDb()) {
599
624
  db.exec(`
600
625
  delete from ai_scores;
601
626
  delete from tweet_actions;
@@ -632,894 +657,914 @@ function clearMentionSyncState(db = getNativeDb()) {
632
657
  ).run();
633
658
  }
634
659
 
635
- export async function importArchive(
660
+ function importArchiveInternalEffect(
636
661
  archivePath: string,
637
662
  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
- : [];
663
+ ): Effect.Effect<ImportedArchiveSummary, unknown> {
664
+ return Effect.gen(function* () {
665
+ const entries = yield* listArchiveEntriesEffect(archivePath);
666
+ const selection = selectedSlices(options);
667
+ const includeTweets = includesSlice(selection, "tweets");
668
+ const includeLikes = includesSlice(selection, "likes");
669
+ const includeBookmarks = includesSlice(selection, "bookmarks");
670
+ const includeDirectMessages = includesSlice(selection, "directMessages");
671
+ const includeProfiles = includesSlice(selection, "profiles");
672
+ const includeFollowers = includesSlice(selection, "followers");
673
+ const includeFollowing = includesSlice(selection, "following");
674
+ const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
675
+ const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
676
+ const tweetEntries = includeTweets
677
+ ? getMatchingEntries(
678
+ entries,
679
+ /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
680
+ )
681
+ : [];
682
+ const noteTweetEntries = includeTweets
683
+ ? getMatchingEntries(
684
+ entries,
685
+ /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i,
686
+ )
687
+ : [];
688
+ const likeEntries = includeLikes
689
+ ? getMatchingEntries(
690
+ entries,
691
+ /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
692
+ )
693
+ : [];
694
+ const bookmarkEntries = includeBookmarks
695
+ ? getMatchingEntries(
696
+ entries,
697
+ /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
698
+ )
699
+ : [];
700
+ const dmEntries = includeDirectMessages
701
+ ? getMatchingEntries(
702
+ entries,
703
+ /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
704
+ )
705
+ : [];
706
+ const followerEntries = includeFollowers
707
+ ? getMatchingEntries(entries, /(?:^|\/)data\/follower(?:-part\d+)?\.js$/i)
708
+ : [];
709
+ const followingEntries = includeFollowing
710
+ ? getMatchingEntries(
711
+ entries,
712
+ /(?:^|\/)data\/following(?:-part\d+)?\.js$/i,
713
+ )
714
+ : [];
683
715
 
684
- if (!accountEntry) {
685
- throw new Error("Archive missing data/account.js");
686
- }
716
+ if (!accountEntry) {
717
+ return yield* Effect.fail(new Error("Archive missing data/account.js"));
718
+ }
687
719
 
688
- const [accountContent, profileContent] = await Promise.all([
689
- readArchiveEntry(archivePath, accountEntry),
690
- profileEntry
691
- ? readArchiveEntry(archivePath, profileEntry)
692
- : Promise.resolve("[]"),
693
- ]);
720
+ const [accountContent, profileContent] = yield* Effect.all([
721
+ readArchiveEntryEffect(archivePath, accountEntry),
722
+ profileEntry
723
+ ? readArchiveEntryEffect(archivePath, profileEntry)
724
+ : Effect.succeed("[]"),
725
+ ]);
694
726
 
695
- const accountPayload = buildAccountPayload(
696
- parseArchiveArray(accountContent)[0] ?? null,
697
- parseArchiveArray(profileContent)[0] ?? null,
698
- );
727
+ const accountPayload = buildAccountPayload(
728
+ parseArchiveArray(accountContent)[0] ?? null,
729
+ parseArchiveArray(profileContent)[0] ?? null,
730
+ );
699
731
 
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;
732
+ const mentionDirectory = new Map<
733
+ string,
734
+ { handle?: string; displayName?: string }
735
+ >();
736
+ const tweetRows: Array<{
737
+ id: string;
738
+ kind: "home" | "like" | "bookmark";
739
+ authorProfileId: string;
740
+ text: string;
741
+ createdAt: string;
742
+ isReplied: number;
743
+ replyToId: string | null;
744
+ likeCount: number;
745
+ mediaCount: number;
746
+ bookmarked: number;
747
+ liked: number;
748
+ entitiesJson: string;
749
+ mediaJson: string;
750
+ quotedTweetId: string | null;
751
+ }> = [];
752
+ const collectionRows: Array<{
753
+ tweetId: string;
754
+ kind: "likes" | "bookmarks";
755
+ collectedAt: string | null;
756
+ source: string;
757
+ rawJson: string;
758
+ }> = [];
759
+ const tweetRowsById = new Map<string, (typeof tweetRows)[number]>();
760
+
761
+ function addTweetRow(row: (typeof tweetRows)[number]) {
762
+ const existing = tweetRowsById.get(row.id);
763
+ if (existing) {
764
+ existing.bookmarked = Math.max(existing.bookmarked, row.bookmarked);
765
+ existing.liked = Math.max(existing.liked, row.liked);
766
+ if (!existing.text && row.text) existing.text = row.text;
767
+ return;
768
+ }
769
+ tweetRows.push(row);
770
+ tweetRowsById.set(row.id, row);
736
771
  }
737
- tweetRows.push(row);
738
- tweetRowsById.set(row.id, row);
739
- }
740
772
 
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),
755
- });
756
- }
773
+ for (const entry of tweetEntries) {
774
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
775
+ for (const wrapper of parseArchiveArray(content)) {
776
+ const tweet = asRecord(wrapper.tweet);
777
+ if (!tweet) continue;
778
+
779
+ for (const mention of asArray<Record<string, unknown>>(
780
+ asRecord(tweet.entities)?.user_mentions,
781
+ )) {
782
+ const mentionId = String(mention.id_str ?? mention.id ?? "");
783
+ if (!mentionId) continue;
784
+ mentionDirectory.set(mentionId, {
785
+ handle: String(mention.screen_name ?? ""),
786
+ displayName: String(
787
+ mention.name ?? mention.screen_name ?? mentionId,
788
+ ),
789
+ });
790
+ }
757
791
 
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,
792
+ const replyUserId = String(
793
+ tweet.in_reply_to_user_id_str ?? tweet.in_reply_to_user_id ?? "",
794
+ );
795
+ const replyScreenName = String(tweet.in_reply_to_screen_name ?? "");
796
+ if (replyUserId && replyScreenName) {
797
+ mentionDirectory.set(replyUserId, {
798
+ handle: replyScreenName,
799
+ displayName: replyScreenName,
800
+ });
801
+ }
802
+
803
+ addTweetRow({
804
+ id: String(tweet.id_str ?? tweet.id),
805
+ kind: "home",
806
+ authorProfileId: "profile_me",
807
+ text: String(tweet.full_text ?? tweet.text ?? ""),
808
+ createdAt: parseTwitterDate(tweet.created_at),
809
+ isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
810
+ replyToId: tweet.in_reply_to_status_id_str
811
+ ? String(tweet.in_reply_to_status_id_str)
812
+ : null,
813
+ likeCount: toInt(tweet.favorite_count),
814
+ mediaCount: getTweetMediaCount(tweet),
815
+ bookmarked: 0,
816
+ liked: 0,
817
+ entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
818
+ mediaJson: JSON.stringify(extractTweetMedia(tweet)),
819
+ quotedTweetId: tweet.quoted_status_id_str
820
+ ? String(tweet.quoted_status_id_str)
821
+ : null,
766
822
  });
767
823
  }
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,
788
- });
789
824
  }
790
- }
791
825
 
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,
813
- });
826
+ for (const entry of noteTweetEntries) {
827
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
828
+ for (const wrapper of parseArchiveArray(content)) {
829
+ const noteTweet = asRecord(wrapper.noteTweet);
830
+ if (!noteTweet) continue;
831
+ const core = asRecord(noteTweet.core);
832
+ addTweetRow({
833
+ id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
834
+ kind: "home",
835
+ authorProfileId: "profile_me",
836
+ text: String(core?.text ?? ""),
837
+ createdAt: parseTwitterDate(noteTweet.createdAt),
838
+ isReplied: 0,
839
+ replyToId: null,
840
+ likeCount: 0,
841
+ mediaCount: 0,
842
+ bookmarked: 0,
843
+ liked: 0,
844
+ entitiesJson: "{}",
845
+ mediaJson: "[]",
846
+ quotedTweetId: null,
847
+ });
848
+ }
814
849
  }
815
- }
816
- const authoredTweetCount = tweetRows.length;
850
+ const authoredTweetCount = tweetRows.length;
817
851
 
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
- };
852
+ type MessageRow = {
853
+ id: string;
854
+ conversationId: string;
855
+ senderProfileId: string;
856
+ text: string;
857
+ createdAt: string;
858
+ direction: "inbound" | "outbound";
859
+ mediaCount: number;
860
+ };
827
861
 
828
- const profiles = new Map<
829
- string,
830
- {
862
+ const profiles = new Map<
863
+ string,
864
+ {
865
+ id: string;
866
+ handle: string;
867
+ displayName: string;
868
+ bio: string;
869
+ followersCount: number;
870
+ followingCount: number;
871
+ publicMetricsJson: string;
872
+ avatarHue: number;
873
+ avatarUrl: string | null;
874
+ location: string | null;
875
+ url: string | null;
876
+ verifiedType: string | null;
877
+ entitiesJson: string;
878
+ rawJson: string;
879
+ createdAt: string;
880
+ }
881
+ >();
882
+ type ProfileRow =
883
+ typeof profiles extends Map<string, infer Value> ? Value : never;
884
+ const defaultProfileMetadata = {
885
+ publicMetricsJson: "{}",
886
+ location: null,
887
+ url: null,
888
+ verifiedType: null,
889
+ entitiesJson: "{}",
890
+ rawJson: "{}",
891
+ };
892
+ const conversations = new Map<
893
+ string,
894
+ {
895
+ id: string;
896
+ title: string;
897
+ accountId: string;
898
+ participantProfileId: string;
899
+ lastMessageAt: string;
900
+ unreadCount: number;
901
+ needsReply: number;
902
+ }
903
+ >();
904
+ const dmMessages: MessageRow[] = [];
905
+ const followerRows: Array<{ profileId: string; externalUserId: string }> =
906
+ [];
907
+ const followingRows: Array<{ profileId: string; externalUserId: string }> =
908
+ [];
909
+ const followerIds = new Set<string>();
910
+ const followingIds = new Set<string>();
911
+ type ExistingProfileRow = {
831
912
  id: string;
832
913
  handle: string;
833
- displayName: string;
914
+ display_name: string;
834
915
  bio: string;
835
- followersCount: number;
836
- followingCount: number;
837
- publicMetricsJson: string;
838
- avatarHue: number;
839
- avatarUrl: string | null;
916
+ followers_count: number;
917
+ following_count: number;
918
+ public_metrics_json: string;
919
+ avatar_hue: number;
920
+ avatar_url: string | null;
840
921
  location: string | null;
841
922
  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
- `
923
+ verified_type: string | null;
924
+ entities_json: string;
925
+ raw_json: string;
926
+ created_at: string;
927
+ };
928
+ const existingProfiles = new Map(
929
+ (
930
+ getNativeDb()
931
+ .prepare(
932
+ `
898
933
  select id, handle, display_name, bio, followers_count, following_count,
899
934
  public_metrics_json, avatar_hue, avatar_url, location, url,
900
935
  verified_type, entities_json, raw_json, created_at
901
936
  from profiles
902
937
  `,
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]
938
+ )
939
+ .all() as ExistingProfileRow[]
940
+ ).map((profile) => [profile.id, profile]),
974
941
  );
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,
942
+ const existingProfilesByHandle = new Map(
943
+ [...existingProfiles.values()].map((profile) => [
944
+ profile.handle.toLowerCase(),
945
+ profile,
946
+ ]),
947
+ );
948
+ const existingPrimaryAccount = getNativeDb()
949
+ .prepare("select handle, external_user_id from accounts where id = ?")
950
+ .get("acct_primary") as
951
+ | { handle: string; external_user_id: string | null }
952
+ | undefined;
953
+ const profileIdAliases = new Map<string, string>();
954
+
955
+ type ArchiveProfileTier =
956
+ | "archive_follow_stub"
957
+ | "archive_dm_stub"
958
+ | "archive_mention_inferred"
959
+ | "live_or_hydrated";
960
+ const archiveProfileTierRank: Record<ArchiveProfileTier, number> = {
961
+ archive_follow_stub: 0,
962
+ archive_dm_stub: 1,
963
+ archive_mention_inferred: 2,
964
+ live_or_hydrated: 3,
996
965
  };
997
- }
998
966
 
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);
967
+ function classifyExistingProfile(profile: ProfileRow): ArchiveProfileTier {
968
+ const externalUserId = profile.id.startsWith("profile_user_")
969
+ ? profile.id.slice("profile_user_".length)
970
+ : "";
971
+ const fallbackHandle = externalUserId
972
+ ? `id${externalUserId}`
973
+ : profile.id;
974
+ const hasLiveSignals =
975
+ profile.followersCount > 0 ||
976
+ profile.followingCount > 0 ||
977
+ profile.publicMetricsJson.trim() !== "{}" ||
978
+ profile.avatarUrl !== null ||
979
+ profile.location !== null ||
980
+ profile.url !== null ||
981
+ profile.verifiedType !== null ||
982
+ profile.entitiesJson.trim() !== "{}" ||
983
+ profile.rawJson.trim() !== "{}";
984
+
985
+ if (hasLiveSignals) return "live_or_hydrated";
986
+ if (
987
+ profile.handle === fallbackHandle &&
988
+ profile.displayName === "" &&
989
+ profile.bio === ""
990
+ ) {
991
+ return "archive_follow_stub";
992
+ }
993
+ if (profile.bio.startsWith("Imported from archive user ")) {
994
+ return profile.handle === fallbackHandle &&
995
+ profile.displayName === fallbackHandle
996
+ ? "archive_dm_stub"
997
+ : "archive_mention_inferred";
998
+ }
999
+ return profile.handle === fallbackHandle && profile.displayName === ""
1000
+ ? "archive_follow_stub"
1001
+ : "archive_mention_inferred";
1008
1002
  }
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
1003
 
1021
- if (
1022
- current &&
1023
- currentTier &&
1024
- shouldPreserveProfile(currentTier, incomingTier) &&
1025
- (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1004
+ function shouldPreserveProfile(
1005
+ existingTier: ArchiveProfileTier,
1006
+ incomingTier: ArchiveProfileTier,
1026
1007
  ) {
1027
- return;
1008
+ return (
1009
+ archiveProfileTierRank[existingTier] >=
1010
+ archiveProfileTierRank[incomingTier]
1011
+ );
1028
1012
  }
1029
1013
 
1030
- if (
1031
- existingProfile &&
1032
- existingTier &&
1033
- shouldPreserveProfile(existingTier, incomingTier)
1034
- ) {
1035
- profiles.set(targetId, existingProfile);
1036
- return;
1014
+ function existingProfileToProfileRow(
1015
+ profile: ExistingProfileRow,
1016
+ ): ProfileRow {
1017
+ return {
1018
+ id: profile.id,
1019
+ handle: profile.handle,
1020
+ displayName: profile.display_name,
1021
+ bio: profile.bio,
1022
+ followersCount: profile.followers_count,
1023
+ followingCount: profile.following_count,
1024
+ publicMetricsJson: profile.public_metrics_json,
1025
+ avatarHue: profile.avatar_hue,
1026
+ avatarUrl: profile.avatar_url,
1027
+ location: profile.location,
1028
+ url: profile.url,
1029
+ verifiedType: profile.verified_type,
1030
+ entitiesJson: profile.entities_json,
1031
+ rawJson: profile.raw_json,
1032
+ createdAt: profile.created_at,
1033
+ };
1037
1034
  }
1038
1035
 
1039
- profiles.set(targetId, targetIncoming);
1040
- }
1036
+ function mergeArchiveProfile(incoming: ProfileRow) {
1037
+ const existingById = existingProfiles.get(incoming.id);
1038
+ const existingByHandle = selection
1039
+ ? existingProfilesByHandle.get(incoming.handle.toLowerCase())
1040
+ : undefined;
1041
+ const targetExisting = existingById ?? existingByHandle;
1042
+ const targetId = targetExisting?.id ?? incoming.id;
1043
+ if (targetId !== incoming.id) {
1044
+ profileIdAliases.set(incoming.id, targetId);
1045
+ }
1046
+ const targetIncoming =
1047
+ targetId === incoming.id ? incoming : { ...incoming, id: targetId };
1048
+ const incomingTier = classifyExistingProfile(incoming);
1049
+ const current = profiles.get(targetId);
1050
+ const currentTier = current ? classifyExistingProfile(current) : null;
1051
+ const existingProfile = targetExisting
1052
+ ? existingProfileToProfileRow(targetExisting)
1053
+ : null;
1054
+ const existingTier = existingProfile
1055
+ ? classifyExistingProfile(existingProfile)
1056
+ : null;
1041
1057
 
1042
- function resolveProfileId(profileId: string) {
1043
- return profileIdAliases.get(profileId) ?? profileId;
1044
- }
1058
+ if (
1059
+ current &&
1060
+ currentTier &&
1061
+ shouldPreserveProfile(currentTier, incomingTier) &&
1062
+ (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1063
+ ) {
1064
+ return;
1065
+ }
1045
1066
 
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
1067
  if (
1052
- profile.id !== profileId &&
1053
- profile.handle.toLowerCase() === normalizedHandle
1068
+ existingProfile &&
1069
+ existingTier &&
1070
+ shouldPreserveProfile(existingTier, incomingTier)
1054
1071
  ) {
1055
- return true;
1072
+ profiles.set(targetId, existingProfile);
1073
+ return;
1056
1074
  }
1075
+
1076
+ profiles.set(targetId, targetIncoming);
1057
1077
  }
1058
- return false;
1059
- }
1060
1078
 
1061
- function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1062
- if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1063
- return baseHandle;
1079
+ function resolveProfileId(profileId: string) {
1080
+ return profileIdAliases.get(profileId) ?? profileId;
1064
1081
  }
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;
1082
+
1083
+ function isProfileHandleTakenByOtherId(handle: string, profileId: string) {
1084
+ const normalizedHandle = handle.toLowerCase();
1085
+ const existingProfile = existingProfilesByHandle.get(normalizedHandle);
1086
+ if (existingProfile && existingProfile.id !== profileId) return true;
1087
+ for (const profile of profiles.values()) {
1088
+ if (
1089
+ profile.id !== profileId &&
1090
+ profile.handle.toLowerCase() === normalizedHandle
1091
+ ) {
1092
+ return true;
1093
+ }
1071
1094
  }
1072
- index += 1;
1095
+ return false;
1073
1096
  }
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
1097
 
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
- );
1098
+ function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1099
+ if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1100
+ return baseHandle;
1101
+ }
1102
+ let index = 1;
1103
+ while (true) {
1104
+ const suffix = index === 1 ? "archive" : `archive_${index}`;
1105
+ const candidate = `${baseHandle}_${suffix}`;
1106
+ if (!isProfileHandleTakenByOtherId(candidate, profileId)) {
1107
+ return candidate;
1108
+ }
1109
+ index += 1;
1110
+ }
1115
1111
  }
1116
- }
1117
1112
 
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,
1113
+ function addArchiveFollowProfile(
1114
+ profileId: string,
1115
+ externalUserId: string,
1116
+ ) {
1117
+ if (!profileId) return;
1118
+ const fallbackId =
1119
+ externalUserId || profileId.replace(/^profile_user_/, "");
1120
+ mergeArchiveProfile({
1121
+ id: profileId,
1122
+ handle: fallbackId ? `id${fallbackId}` : profileId,
1123
+ displayName: "",
1124
+ bio: "",
1141
1125
  followersCount: 0,
1142
1126
  followingCount: 0,
1143
1127
  ...defaultProfileMetadata,
1144
- avatarHue: 18,
1128
+ avatarHue: 210,
1145
1129
  avatarUrl: null,
1146
1130
  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
- `
1131
+ });
1132
+ }
1133
+
1134
+ function assertSelectedAccountMatchesArchive() {
1135
+ if (!selection || !existingPrimaryAccount) return;
1136
+ const existingExternalUserId = existingPrimaryAccount.external_user_id;
1137
+ if (
1138
+ existingExternalUserId &&
1139
+ existingExternalUserId !== accountPayload.accountId
1140
+ ) {
1141
+ throw new Error(
1142
+ `Existing acct_primary (${existingExternalUserId}) does not match archive account ${accountPayload.accountId}`,
1143
+ );
1144
+ }
1145
+ const existingHandle = existingPrimaryAccount.handle
1146
+ .replace(/^@/, "")
1147
+ .toLowerCase();
1148
+ if (
1149
+ !existingExternalUserId &&
1150
+ existingHandle !== accountPayload.username.toLowerCase()
1151
+ ) {
1152
+ throw new Error(
1153
+ `Existing acct_primary (@${existingHandle}) does not match archive account @${accountPayload.username}`,
1154
+ );
1155
+ }
1156
+ }
1157
+
1158
+ assertSelectedAccountMatchesArchive();
1159
+
1160
+ const existingLocalProfile =
1161
+ selection &&
1162
+ (existingProfiles.get("profile_me") ??
1163
+ [...existingProfiles.values()].find(
1164
+ (profile) =>
1165
+ profile.handle.toLowerCase() ===
1166
+ accountPayload.username.toLowerCase(),
1167
+ ));
1168
+ const archivedLocalProfile = existingLocalProfile
1169
+ ? {
1170
+ ...existingProfileToProfileRow(existingLocalProfile),
1171
+ handle: accountPayload.username,
1172
+ displayName: accountPayload.displayName,
1173
+ bio: accountPayload.bio,
1174
+ createdAt: accountPayload.createdAt,
1175
+ }
1176
+ : {
1177
+ id: "profile_me",
1178
+ handle: accountPayload.username,
1179
+ displayName: accountPayload.displayName,
1180
+ bio: accountPayload.bio,
1181
+ followersCount: 0,
1182
+ followingCount: 0,
1183
+ ...defaultProfileMetadata,
1184
+ avatarHue: 18,
1185
+ avatarUrl: null,
1186
+ createdAt: accountPayload.createdAt,
1187
+ };
1188
+ const localProfile =
1189
+ existingLocalProfile && !includeProfiles
1190
+ ? existingProfileToProfileRow(existingLocalProfile)
1191
+ : archivedLocalProfile;
1192
+ profiles.set(localProfile.id, localProfile);
1193
+
1194
+ const existingDmConversationAccounts = new Map(
1195
+ (
1196
+ getNativeDb()
1197
+ .prepare("select id, account_id from dm_conversations")
1198
+ .all() as Array<{ id: string; account_id: string }>
1199
+ ).map((row) => [row.id, row.account_id]),
1200
+ );
1201
+ const existingOtherDmMessageIds = new Set(
1202
+ (
1203
+ getNativeDb()
1204
+ .prepare(
1205
+ `
1166
1206
  select m.id
1167
1207
  from dm_messages m
1168
1208
  join dm_conversations c on c.id = m.conversation_id
1169
1209
  where c.account_id <> 'acct_primary'
1170
1210
  `,
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;
1211
+ )
1212
+ .all() as Array<{ id: string }>
1213
+ ).map((row) => row.id),
1214
+ );
1215
+ const archiveDmConversationIdAliases = new Map<string, string>();
1216
+ const archiveDmMessageIdAliases = new Map<string, string>();
1217
+
1218
+ function uniquePrimaryArchiveId(
1219
+ baseId: string,
1220
+ isTakenByOtherAccount: (candidate: string) => boolean,
1221
+ isPending: (candidate: string) => boolean,
1222
+ ) {
1223
+ let index = 1;
1224
+ while (true) {
1225
+ const suffix = index === 1 ? "" : `:${index}`;
1226
+ const candidate = `acct_primary:${baseId}${suffix}`;
1227
+ if (!isTakenByOtherAccount(candidate) && !isPending(candidate)) {
1228
+ return candidate;
1229
+ }
1230
+ index += 1;
1189
1231
  }
1190
- index += 1;
1191
1232
  }
1192
- }
1193
1233
 
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
- }
1234
+ function resolveArchiveDmConversationId(conversationId: string) {
1235
+ const existingAlias = archiveDmConversationIdAliases.get(conversationId);
1236
+ if (existingAlias) return existingAlias;
1237
+ if (!selection) {
1238
+ archiveDmConversationIdAliases.set(conversationId, conversationId);
1239
+ return conversationId;
1240
+ }
1201
1241
 
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
- }
1242
+ const takenByOtherAccount = (candidate: string) => {
1243
+ const accountId = existingDmConversationAccounts.get(candidate);
1244
+ return accountId !== undefined && accountId !== "acct_primary";
1245
+ };
1246
+ const resolved = takenByOtherAccount(conversationId)
1247
+ ? uniquePrimaryArchiveId(
1248
+ conversationId,
1249
+ takenByOtherAccount,
1250
+ (candidate) => conversations.has(candidate),
1251
+ )
1252
+ : conversationId;
1253
+ archiveDmConversationIdAliases.set(conversationId, resolved);
1254
+ return resolved;
1255
+ }
1216
1256
 
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
- }
1257
+ function resolveArchiveDmMessageId(
1258
+ messageId: string,
1259
+ conversationIdChanged: boolean,
1260
+ ) {
1261
+ const existingAlias = archiveDmMessageIdAliases.get(messageId);
1262
+ if (existingAlias) return existingAlias;
1263
+ const shouldRemap =
1264
+ selection &&
1265
+ (conversationIdChanged || existingOtherDmMessageIds.has(messageId));
1266
+ const resolved = shouldRemap
1267
+ ? uniquePrimaryArchiveId(
1268
+ messageId,
1269
+ (candidate) => existingOtherDmMessageIds.has(candidate),
1270
+ (candidate) =>
1271
+ dmMessages.some((message) => message.id === candidate),
1272
+ )
1273
+ : messageId;
1274
+ archiveDmMessageIdAliases.set(messageId, resolved);
1275
+ return resolved;
1276
+ }
1236
1277
 
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
- );
1278
+ for (const entry of dmEntries) {
1279
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1280
+ for (const wrapper of parseArchiveArray(content)) {
1281
+ const dmConversation = asRecord(wrapper.dmConversation);
1282
+ if (!dmConversation) continue;
1283
+
1284
+ const rawConversationId = String(dmConversation.conversationId ?? "");
1285
+ if (!rawConversationId) continue;
1286
+ const conversationId =
1287
+ resolveArchiveDmConversationId(rawConversationId);
1288
+ const conversationIdChanged = conversationId !== rawConversationId;
1289
+
1290
+ const conversationName = String(dmConversation.name ?? "").trim();
1291
+ const participantIds = new Set<string>();
1292
+ const rawMessages = asArray<Record<string, unknown>>(
1293
+ dmConversation.messages,
1294
+ );
1253
1295
 
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
- }
1296
+ for (const event of rawMessages) {
1297
+ const messageCreate = asRecord(event.messageCreate);
1298
+ if (messageCreate) {
1299
+ const senderId = String(messageCreate.senderId ?? "");
1300
+ const recipientId = String(messageCreate.recipientId ?? "");
1301
+ if (senderId) participantIds.add(senderId);
1302
+ if (recipientId) participantIds.add(recipientId);
1303
+ }
1262
1304
 
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));
1305
+ const joinConversation = asRecord(event.joinConversation);
1306
+ if (joinConversation) {
1307
+ for (const userId of asArray<string>(
1308
+ joinConversation.participantsSnapshot,
1309
+ )) {
1310
+ participantIds.add(String(userId));
1311
+ }
1269
1312
  }
1270
- }
1271
1313
 
1272
- const participantsJoin = asRecord(event.participantsJoin);
1273
- if (participantsJoin) {
1274
- for (const userId of asArray<string>(participantsJoin.userIds)) {
1275
- participantIds.add(String(userId));
1314
+ const participantsJoin = asRecord(event.participantsJoin);
1315
+ if (participantsJoin) {
1316
+ for (const userId of asArray<string>(participantsJoin.userIds)) {
1317
+ participantIds.add(String(userId));
1318
+ }
1319
+ const initiatingUserId = String(
1320
+ participantsJoin.initiatingUserId ?? "",
1321
+ );
1322
+ if (initiatingUserId) {
1323
+ participantIds.add(initiatingUserId);
1324
+ }
1276
1325
  }
1277
- const initiatingUserId = String(
1278
- participantsJoin.initiatingUserId ?? "",
1279
- );
1280
- if (initiatingUserId) {
1281
- participantIds.add(initiatingUserId);
1326
+
1327
+ const participantsLeave = asRecord(event.participantsLeave);
1328
+ if (participantsLeave) {
1329
+ for (const userId of asArray<string>(participantsLeave.userIds)) {
1330
+ participantIds.add(String(userId));
1331
+ }
1332
+ const initiatingUserId = String(
1333
+ participantsLeave.initiatingUserId ?? "",
1334
+ );
1335
+ if (initiatingUserId) {
1336
+ participantIds.add(initiatingUserId);
1337
+ }
1282
1338
  }
1283
1339
  }
1284
1340
 
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);
1341
+ const externalParticipantIds = [...participantIds].filter(
1342
+ (userId) => userId && userId !== accountPayload.accountId,
1343
+ );
1344
+ const isGroup =
1345
+ conversationName.length > 0 || externalParticipantIds.length > 1;
1346
+ const participantProfileId = isGroup
1347
+ ? `profile_group_${conversationId}`
1348
+ : `profile_user_${externalParticipantIds[0] ?? conversationId}`;
1349
+
1350
+ if (!profiles.has(participantProfileId)) {
1351
+ if (isGroup) {
1352
+ profiles.set(participantProfileId, {
1353
+ id: participantProfileId,
1354
+ handle: `group-${conversationId}`,
1355
+ displayName:
1356
+ conversationName || `Group DM ${externalParticipantIds.length}`,
1357
+ bio: `Group DM with ${externalParticipantIds.length} participants`,
1358
+ followersCount: 0,
1359
+ followingCount: 0,
1360
+ ...defaultProfileMetadata,
1361
+ avatarHue: 220,
1362
+ avatarUrl: null,
1363
+ createdAt: accountPayload.createdAt,
1364
+ });
1365
+ } else {
1366
+ const otherUserId = externalParticipantIds[0] ?? conversationId;
1367
+ const inferred = inferProfileFromDirectory(
1368
+ otherUserId,
1369
+ mentionDirectory,
1370
+ );
1371
+ mergeArchiveProfile({
1372
+ id: participantProfileId,
1373
+ handle: inferred.handle,
1374
+ displayName: inferred.displayName,
1375
+ bio: `Imported from archive user ${otherUserId}`,
1376
+ followersCount: 0,
1377
+ followingCount: 0,
1378
+ ...defaultProfileMetadata,
1379
+ avatarHue: 210,
1380
+ avatarUrl: null,
1381
+ createdAt: accountPayload.createdAt,
1382
+ });
1295
1383
  }
1296
1384
  }
1297
- }
1298
1385
 
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
- }
1386
+ const messageEvents = rawMessages
1387
+ .map((event) => asRecord(event.messageCreate))
1388
+ .filter((event): event is Record<string, unknown> => event !== null)
1389
+ .map((messageCreate) => {
1390
+ const senderId = String(messageCreate.senderId ?? "");
1391
+ const rawMessageId = String(
1392
+ messageCreate.id ?? `${rawConversationId}-${senderId}`,
1393
+ );
1394
+ const senderProfileId =
1395
+ senderId === accountPayload.accountId
1396
+ ? localProfile.id
1397
+ : `profile_user_${senderId}`;
1398
+
1399
+ if (senderId && senderId !== accountPayload.accountId) {
1400
+ const inferred = inferProfileFromDirectory(
1401
+ senderId,
1402
+ mentionDirectory,
1403
+ );
1404
+ if (!profiles.has(senderProfileId)) {
1405
+ mergeArchiveProfile({
1406
+ id: senderProfileId,
1407
+ handle: inferred.handle,
1408
+ displayName: inferred.displayName,
1409
+ bio: `Imported from archive user ${senderId}`,
1410
+ followersCount: 0,
1411
+ followingCount: 0,
1412
+ ...defaultProfileMetadata,
1413
+ avatarHue: 240,
1414
+ avatarUrl: null,
1415
+ createdAt: accountPayload.createdAt,
1416
+ });
1417
+ }
1418
+ }
1343
1419
 
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}`,
1420
+ return {
1421
+ id: resolveArchiveDmMessageId(
1422
+ rawMessageId,
1423
+ conversationIdChanged,
1424
+ ),
1425
+ conversationId,
1426
+ senderProfileId: resolveProfileId(senderProfileId),
1427
+ text: String(messageCreate.text ?? ""),
1428
+ createdAt: parseTwitterDate(messageCreate.createdAt),
1429
+ direction:
1430
+ senderId === accountPayload.accountId ? "outbound" : "inbound",
1431
+ mediaCount: asArray(messageCreate.mediaUrls).length,
1432
+ } satisfies MessageRow;
1433
+ })
1434
+ .sort((left, right) =>
1435
+ compareIsoTimestamp(left.createdAt, right.createdAt),
1351
1436
  );
1352
- const senderProfileId =
1353
- senderId === accountPayload.accountId
1354
- ? localProfile.id
1355
- : `profile_user_${senderId}`;
1356
1437
 
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
- }
1438
+ if (messageEvents.length === 0) {
1439
+ continue;
1440
+ }
1377
1441
 
1378
- return {
1379
- id: resolveArchiveDmMessageId(rawMessageId, conversationIdChanged),
1442
+ const lastMessage = messageEvents.at(-1);
1443
+ if (!lastMessage) continue;
1444
+
1445
+ dmMessages.push(...messageEvents);
1446
+ const resolvedParticipantProfileId =
1447
+ resolveProfileId(participantProfileId);
1448
+ conversations.set(conversationId, {
1449
+ id: conversationId,
1450
+ title:
1451
+ profiles.get(resolvedParticipantProfileId)?.displayName ||
1452
+ conversationName ||
1380
1453
  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;
1454
+ accountId: "acct_primary",
1455
+ participantProfileId: resolvedParticipantProfileId,
1456
+ lastMessageAt: lastMessage.createdAt,
1457
+ unreadCount: 0,
1458
+ needsReply: lastMessage.direction === "inbound" ? 1 : 0,
1459
+ });
1395
1460
  }
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,
1414
- });
1415
1461
  }
1416
- }
1417
1462
 
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),
1431
- });
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,
1447
- });
1463
+ let likeCount = 0;
1464
+ for (const entry of likeEntries) {
1465
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1466
+ const likes = parseArchiveArray(content);
1467
+ for (const like of likes) {
1468
+ const tweet = extractCollectionTweet(like, "like");
1469
+ if (!tweet) continue;
1470
+ collectionRows.push({
1471
+ tweetId: tweet.id,
1472
+ kind: "likes",
1473
+ collectedAt: tweet.createdAt,
1474
+ source: "archive",
1475
+ rawJson: JSON.stringify(like),
1476
+ });
1477
+ addTweetRow({
1478
+ id: tweet.id,
1479
+ kind: "like",
1480
+ authorProfileId: "profile_unknown",
1481
+ text: tweet.text,
1482
+ createdAt: tweet.createdAt,
1483
+ isReplied: 0,
1484
+ replyToId: null,
1485
+ likeCount: tweet.likeCount,
1486
+ mediaCount: 0,
1487
+ bookmarked: 0,
1488
+ liked: 1,
1489
+ entitiesJson: "{}",
1490
+ mediaJson: "[]",
1491
+ quotedTweetId: null,
1492
+ });
1493
+ }
1494
+ likeCount += likes.length;
1448
1495
  }
1449
- likeCount += likes.length;
1450
- }
1451
1496
 
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),
1465
- });
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,
1481
- });
1497
+ let bookmarkCount = 0;
1498
+ for (const entry of bookmarkEntries) {
1499
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1500
+ const bookmarks = parseArchiveArray(content);
1501
+ for (const bookmark of bookmarks) {
1502
+ const tweet = extractCollectionTweet(bookmark, "bookmark");
1503
+ if (!tweet) continue;
1504
+ collectionRows.push({
1505
+ tweetId: tweet.id,
1506
+ kind: "bookmarks",
1507
+ collectedAt: tweet.createdAt,
1508
+ source: "archive",
1509
+ rawJson: JSON.stringify(bookmark),
1510
+ });
1511
+ addTweetRow({
1512
+ id: tweet.id,
1513
+ kind: "bookmark",
1514
+ authorProfileId: "profile_unknown",
1515
+ text: tweet.text,
1516
+ createdAt: tweet.createdAt,
1517
+ isReplied: 0,
1518
+ replyToId: null,
1519
+ likeCount: tweet.likeCount,
1520
+ mediaCount: 0,
1521
+ bookmarked: 1,
1522
+ liked: 0,
1523
+ entitiesJson: "{}",
1524
+ mediaJson: "[]",
1525
+ quotedTweetId: null,
1526
+ });
1527
+ }
1528
+ bookmarkCount += bookmarks.length;
1482
1529
  }
1483
- bookmarkCount += bookmarks.length;
1484
- }
1485
1530
 
1486
- const mediaFileCounts = await extractArchiveMediaFiles(
1487
- archivePath,
1488
- selectedArchiveMediaKinds(selection),
1489
- );
1531
+ const mediaFileCounts = yield* extractArchiveMediaFilesEffect(
1532
+ archivePath,
1533
+ selectedArchiveMediaKinds(selection),
1534
+ );
1490
1535
 
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);
1536
+ for (const entry of followerEntries) {
1537
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1538
+ for (const row of getArchiveFollowRows(content, "follower")) {
1539
+ if (followerIds.has(row.externalUserId)) continue;
1540
+ followerIds.add(row.externalUserId);
1541
+ followerRows.push(row);
1542
+ }
1497
1543
  }
1498
- }
1499
1544
 
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);
1545
+ for (const entry of followingEntries) {
1546
+ const content = yield* readArchiveEntryEffect(archivePath, entry);
1547
+ for (const row of getArchiveFollowRows(content, "following")) {
1548
+ if (followingIds.has(row.externalUserId)) continue;
1549
+ followingIds.add(row.externalUserId);
1550
+ followingRows.push(row);
1551
+ }
1506
1552
  }
1507
- }
1508
1553
 
1509
- for (const row of [...followerRows, ...followingRows]) {
1510
- addArchiveFollowProfile(row.profileId, row.externalUserId);
1511
- }
1554
+ for (const row of [...followerRows, ...followingRows]) {
1555
+ addArchiveFollowProfile(row.profileId, row.externalUserId);
1556
+ }
1512
1557
 
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
- `
1558
+ const clearedFollowDirections = new Set<ArchiveFollowDirection>();
1559
+ if (includeFollowers && followerEntries.length === 0) {
1560
+ clearedFollowDirections.add("followers");
1561
+ }
1562
+ if (includeFollowing && followingEntries.length === 0) {
1563
+ clearedFollowDirections.add("following");
1564
+ }
1565
+ const retainedFollowProfiles = getNativeDb()
1566
+ .prepare(
1567
+ `
1523
1568
  select direction, profile_id, external_user_id, source, null as snapshot_id, null as snapshot_source
1524
1569
  from follow_edges
1525
1570
  union
@@ -1527,57 +1572,54 @@ export async function importArchive(
1527
1572
  from follow_events ev
1528
1573
  left join follow_snapshots snap on snap.id = ev.snapshot_id
1529
1574
  `,
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
- }
1575
+ )
1576
+ .all() as Array<{
1577
+ direction: ArchiveFollowDirection;
1578
+ profile_id: string;
1579
+ external_user_id: string;
1580
+ source: string | null;
1581
+ snapshot_id: string | null;
1582
+ snapshot_source: string | null;
1583
+ }>;
1584
+ for (const row of retainedFollowProfiles) {
1585
+ const isClearedArchiveRow =
1586
+ clearedFollowDirections.has(row.direction) &&
1587
+ (row.source === "archive" ||
1588
+ row.snapshot_source === "archive" ||
1589
+ row.snapshot_id ===
1590
+ `follow_snapshot_archive_acct_primary_${row.direction}`);
1591
+ if (isClearedArchiveRow) continue;
1592
+ addArchiveFollowProfile(row.profile_id, row.external_user_id);
1593
+ }
1573
1594
 
1574
- if (!selection) {
1575
- clearImportedData();
1576
- clearMentionSyncState();
1577
- }
1595
+ if (
1596
+ tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")
1597
+ ) {
1598
+ const unknownProfile = {
1599
+ id: "profile_unknown",
1600
+ handle: selection
1601
+ ? uniqueArchiveProfileHandle("unknown", "profile_unknown")
1602
+ : "unknown",
1603
+ displayName: "Unknown",
1604
+ bio: "Imported from archive collection metadata",
1605
+ followersCount: 0,
1606
+ followingCount: 0,
1607
+ ...defaultProfileMetadata,
1608
+ avatarHue: 210,
1609
+ avatarUrl: null,
1610
+ createdAt: accountPayload.createdAt,
1611
+ };
1612
+ const existingUnknownProfile = existingProfiles.get("profile_unknown");
1613
+ profiles.set(
1614
+ "profile_unknown",
1615
+ existingUnknownProfile
1616
+ ? existingProfileToProfileRow(existingUnknownProfile)
1617
+ : unknownProfile,
1618
+ );
1619
+ }
1578
1620
 
1579
- const db = getNativeDb();
1580
- const insertAccount = db.prepare(`
1621
+ const db = getNativeDb();
1622
+ const insertAccount = db.prepare(`
1581
1623
  insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1582
1624
  values (?, ?, ?, ?, ?, 1, ?)
1583
1625
  on conflict(id) do update set
@@ -1588,11 +1630,11 @@ export async function importArchive(
1588
1630
  is_default = 1,
1589
1631
  created_at = excluded.created_at
1590
1632
  `);
1591
- const insertAccountIfMissing = db.prepare(`
1633
+ const insertAccountIfMissing = db.prepare(`
1592
1634
  insert or ignore into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1593
1635
  values (?, ?, ?, ?, ?, 1, ?)
1594
1636
  `);
1595
- const insertProfile = db.prepare(`
1637
+ const insertProfile = db.prepare(`
1596
1638
  insert into profiles (
1597
1639
  id, handle, display_name, bio, followers_count, following_count,
1598
1640
  public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
@@ -1615,7 +1657,7 @@ export async function importArchive(
1615
1657
  raw_json = excluded.raw_json,
1616
1658
  created_at = excluded.created_at
1617
1659
  `);
1618
- const insertProfileIfMissing = db.prepare(`
1660
+ const insertProfileIfMissing = db.prepare(`
1619
1661
  insert or ignore into profiles (
1620
1662
  id, handle, display_name, bio, followers_count, following_count,
1621
1663
  public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
@@ -1623,7 +1665,7 @@ export async function importArchive(
1623
1665
  )
1624
1666
  values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1625
1667
  `);
1626
- const insertTweet = db.prepare(`
1668
+ const insertTweet = db.prepare(`
1627
1669
  insert into tweets (
1628
1670
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
1629
1671
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
@@ -1667,14 +1709,16 @@ export async function importArchive(
1667
1709
  media_json = case when excluded.media_json <> '[]' then excluded.media_json else tweets.media_json end,
1668
1710
  quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
1669
1711
  `);
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(`
1712
+ const deleteTweetFts = db.prepare(
1713
+ "delete from tweets_fts where tweet_id = ?",
1714
+ );
1715
+ const insertTweetFts = db.prepare(
1716
+ "insert into tweets_fts (tweet_id, text) values (?, ?)",
1717
+ );
1718
+ const selectTweetFtsText = db.prepare(
1719
+ "select text from tweets where id = ?",
1720
+ );
1721
+ const insertTimelineEdge = db.prepare(`
1678
1722
  insert into tweet_account_edges (
1679
1723
  account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
1680
1724
  raw_json, updated_at
@@ -1684,7 +1728,7 @@ export async function importArchive(
1684
1728
  last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1685
1729
  updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1686
1730
  `);
1687
- const insertCollection = db.prepare(`
1731
+ const insertCollection = db.prepare(`
1688
1732
  insert into tweet_collections (
1689
1733
  account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
1690
1734
  ) values (?, ?, ?, ?, ?, ?, ?)
@@ -1700,20 +1744,20 @@ export async function importArchive(
1700
1744
  end,
1701
1745
  updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1702
1746
  `);
1703
- const insertConversation = db.prepare(`
1747
+ const insertConversation = db.prepare(`
1704
1748
  insert into dm_conversations (
1705
1749
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1706
1750
  ) values (?, ?, ?, ?, ?, ?, ?)
1707
1751
  `);
1708
- const insertMessage = db.prepare(`
1752
+ const insertMessage = db.prepare(`
1709
1753
  insert into dm_messages (
1710
1754
  id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1711
1755
  ) values (?, ?, ?, ?, ?, ?, ?, ?)
1712
1756
  `);
1713
- const insertDmFts = db.prepare(
1714
- "insert into dm_fts (message_id, text) values (?, ?)",
1715
- );
1716
- const insertFollowSnapshot = db.prepare(`
1757
+ const insertDmFts = db.prepare(
1758
+ "insert into dm_fts (message_id, text) values (?, ?)",
1759
+ );
1760
+ const insertFollowSnapshot = db.prepare(`
1717
1761
  insert into follow_snapshots (
1718
1762
  id, account_id, direction, source, status, page_count, result_count,
1719
1763
  started_at, completed_at, raw_meta_json
@@ -1729,21 +1773,21 @@ export async function importArchive(
1729
1773
  completed_at = excluded.completed_at,
1730
1774
  raw_meta_json = excluded.raw_meta_json
1731
1775
  `);
1732
- const insertFollowSnapshotMember = db.prepare(`
1776
+ const insertFollowSnapshotMember = db.prepare(`
1733
1777
  insert into follow_snapshot_members (
1734
1778
  snapshot_id, profile_id, external_user_id, position
1735
1779
  ) values (?, ?, ?, ?)
1736
1780
  `);
1737
- const selectFollowSnapshotMembers = db.prepare(`
1781
+ const selectFollowSnapshotMembers = db.prepare(`
1738
1782
  select profile_id, external_user_id
1739
1783
  from follow_snapshot_members
1740
1784
  where snapshot_id = ?
1741
1785
  order by position, profile_id
1742
1786
  `);
1743
- const deleteFollowSnapshotMembers = db.prepare(
1744
- "delete from follow_snapshot_members where snapshot_id = ?",
1745
- );
1746
- const deleteArchiveFollowEvents = db.prepare(`
1787
+ const deleteFollowSnapshotMembers = db.prepare(
1788
+ "delete from follow_snapshot_members where snapshot_id = ?",
1789
+ );
1790
+ const deleteArchiveFollowEvents = db.prepare(`
1747
1791
  delete from follow_events
1748
1792
  where account_id = ? and direction = ? and (
1749
1793
  snapshot_id = ? or snapshot_id in (
@@ -1752,27 +1796,27 @@ export async function importArchive(
1752
1796
  )
1753
1797
  )
1754
1798
  `);
1755
- const deleteArchiveFollowSnapshotMembers = db.prepare(`
1799
+ const deleteArchiveFollowSnapshotMembers = db.prepare(`
1756
1800
  delete from follow_snapshot_members
1757
1801
  where snapshot_id in (
1758
1802
  select id from follow_snapshots
1759
1803
  where account_id = ? and direction = ? and source = 'archive'
1760
1804
  )
1761
1805
  `);
1762
- const deleteArchiveFollowSnapshots = db.prepare(`
1806
+ const deleteArchiveFollowSnapshots = db.prepare(`
1763
1807
  delete from follow_snapshots
1764
1808
  where account_id = ? and direction = ? and source = 'archive'
1765
1809
  `);
1766
- const deleteArchiveFollowEdges = db.prepare(`
1810
+ const deleteArchiveFollowEdges = db.prepare(`
1767
1811
  delete from follow_edges
1768
1812
  where account_id = ? and direction = ? and source = 'archive'
1769
1813
  `);
1770
- const selectFollowEdges = db.prepare(`
1814
+ const selectFollowEdges = db.prepare(`
1771
1815
  select profile_id, external_user_id, current
1772
1816
  from follow_edges
1773
1817
  where account_id = ? and direction = ?
1774
1818
  `);
1775
- const insertFollowEdge = db.prepare(`
1819
+ const insertFollowEdge = db.prepare(`
1776
1820
  insert into follow_edges (
1777
1821
  account_id, direction, profile_id, external_user_id, source, current,
1778
1822
  first_seen_at, last_seen_at, ended_at, updated_at
@@ -1788,25 +1832,25 @@ export async function importArchive(
1788
1832
  ended_at = null,
1789
1833
  updated_at = excluded.updated_at
1790
1834
  `);
1791
- const endFollowEdge = db.prepare(`
1835
+ const endFollowEdge = db.prepare(`
1792
1836
  update follow_edges
1793
1837
  set current = 0, ended_at = ?, updated_at = ?
1794
1838
  where account_id = ? and direction = ? and profile_id = ?
1795
1839
  `);
1796
- const insertFollowEvent = db.prepare(`
1840
+ const insertFollowEvent = db.prepare(`
1797
1841
  insert into follow_events (
1798
1842
  id, account_id, direction, profile_id, external_user_id, kind, event_at, snapshot_id
1799
1843
  ) values (?, ?, ?, ?, ?, ?, ?, ?)
1800
1844
  `);
1801
- const clearSelectedLikes = db.prepare(`
1845
+ const clearSelectedLikes = db.prepare(`
1802
1846
  delete from tweet_collections
1803
1847
  where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1804
1848
  `);
1805
- const clearSelectedBookmarks = db.prepare(`
1849
+ const clearSelectedBookmarks = db.prepare(`
1806
1850
  delete from tweet_collections
1807
1851
  where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1808
1852
  `);
1809
- const clearTweetLikedFlag = db.prepare(`
1853
+ const clearTweetLikedFlag = db.prepare(`
1810
1854
  update tweets
1811
1855
  set liked = 0
1812
1856
  where account_id = ?
@@ -1816,7 +1860,7 @@ export async function importArchive(
1816
1860
  where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1817
1861
  )
1818
1862
  `);
1819
- const clearTweetBookmarkedFlag = db.prepare(`
1863
+ const clearTweetBookmarkedFlag = db.prepare(`
1820
1864
  update tweets
1821
1865
  set bookmarked = 0
1822
1866
  where account_id = ?
@@ -1826,7 +1870,7 @@ export async function importArchive(
1826
1870
  where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1827
1871
  )
1828
1872
  `);
1829
- const clearSelectedArchiveTweetEdges = db.prepare(`
1873
+ const clearSelectedArchiveTweetEdges = db.prepare(`
1830
1874
  delete from tweet_account_edges
1831
1875
  where account_id = ?
1832
1876
  and kind in ('home', 'authored')
@@ -1844,12 +1888,12 @@ export async function importArchive(
1844
1888
  )
1845
1889
  )
1846
1890
  `);
1847
- const deleteOrphanTweetLinkOccurrences = db.prepare(`
1891
+ const deleteOrphanTweetLinkOccurrences = db.prepare(`
1848
1892
  delete from link_occurrences
1849
1893
  where source_kind = 'tweet'
1850
1894
  and source_id not in (select id from tweets)
1851
1895
  `);
1852
- const deleteOrphanArchiveCollectionTweets = db.prepare(`
1896
+ const deleteOrphanArchiveCollectionTweets = db.prepare(`
1853
1897
  delete from tweets
1854
1898
  where account_id = ?
1855
1899
  and kind in ('like', 'bookmark')
@@ -1870,7 +1914,7 @@ export async function importArchive(
1870
1914
  or referencing_tweet.quoted_tweet_id = tweets.id
1871
1915
  )
1872
1916
  `);
1873
- const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
1917
+ const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
1874
1918
  update tweets
1875
1919
  set kind = case
1876
1920
  when exists (
@@ -1911,7 +1955,7 @@ export async function importArchive(
1911
1955
  where account_id = ?
1912
1956
  )
1913
1957
  `);
1914
- const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
1958
+ const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
1915
1959
  update tweets
1916
1960
  set kind = 'archive_stale'
1917
1961
  where account_id = ?
@@ -1988,7 +2032,7 @@ export async function importArchive(
1988
2032
  )
1989
2033
  )
1990
2034
  `);
1991
- const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
2035
+ const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
1992
2036
  delete from tweets
1993
2037
  where account_id = ?
1994
2038
  and id in (
@@ -2061,11 +2105,11 @@ export async function importArchive(
2061
2105
  )
2062
2106
  )
2063
2107
  `);
2064
- const deleteOrphanTweetFts = db.prepare(`
2108
+ const deleteOrphanTweetFts = db.prepare(`
2065
2109
  delete from tweets_fts
2066
2110
  where tweet_id not in (select id from tweets)
2067
2111
  `);
2068
- const clearDmFts = db.prepare(`
2112
+ const clearDmFts = db.prepare(`
2069
2113
  delete from dm_fts
2070
2114
  where message_id in (
2071
2115
  select m.id
@@ -2074,7 +2118,7 @@ export async function importArchive(
2074
2118
  where c.account_id = ?
2075
2119
  )
2076
2120
  `);
2077
- const clearDmLinkOccurrences = db.prepare(`
2121
+ const clearDmLinkOccurrences = db.prepare(`
2078
2122
  delete from link_occurrences
2079
2123
  where source_kind = 'dm'
2080
2124
  and source_id in (
@@ -2084,362 +2128,382 @@ export async function importArchive(
2084
2128
  where c.account_id = ?
2085
2129
  )
2086
2130
  `);
2087
- const clearDmMessages = db.prepare(`
2131
+ const clearDmMessages = db.prepare(`
2088
2132
  delete from dm_messages
2089
2133
  where conversation_id in (
2090
2134
  select id from dm_conversations where account_id = ?
2091
2135
  )
2092
2136
  `);
2093
- const clearDmConversations = db.prepare(
2094
- "delete from dm_conversations where account_id = ?",
2095
- );
2137
+ const clearDmConversations = db.prepare(
2138
+ "delete from dm_conversations where account_id = ?",
2139
+ );
2096
2140
 
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<{
2141
+ function importFollowRows(
2142
+ direction: ArchiveFollowDirection,
2143
+ rows: Array<{ profileId: string; externalUserId: string }>,
2144
+ entryCount: number,
2145
+ now: string,
2146
+ ) {
2147
+ const snapshotId = `follow_snapshot_archive_acct_primary_${direction}`;
2148
+ const existingEdges = new Map(
2149
+ (
2150
+ selectFollowEdges.all("acct_primary", direction) as Array<{
2151
+ profile_id: string;
2152
+ external_user_id: string;
2153
+ current: number;
2154
+ }>
2155
+ ).map((row) => [row.profile_id, row]),
2156
+ );
2157
+ const existingMemberKey = (
2158
+ selectFollowSnapshotMembers.all(snapshotId) as Array<{
2107
2159
  profile_id: string;
2108
2160
  external_user_id: string;
2109
- current: number;
2110
2161
  }>
2111
- ).map((row) => [row.profile_id, row]),
2112
- );
2113
- const existingMemberKey = (
2114
- selectFollowSnapshotMembers.all(snapshotId) as Array<{
2115
- profile_id: string;
2116
- external_user_id: string;
2117
- }>
2118
- )
2119
- .map(
2120
- (row, index) =>
2121
- `${String(index)}:${row.profile_id}:${row.external_user_id}`,
2122
- )
2123
- .join("\n");
2124
- const nextMemberKey = rows
2125
- .map(
2126
- (row, index) =>
2127
- `${String(index)}:${row.profileId}:${row.externalUserId}`,
2128
2162
  )
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
- }
2163
+ .map(
2164
+ (row, index) =>
2165
+ `${String(index)}:${row.profile_id}:${row.external_user_id}`,
2166
+ )
2167
+ .join("\n");
2168
+ const nextMemberKey = rows
2169
+ .map(
2170
+ (row, index) =>
2171
+ `${String(index)}:${row.profileId}:${row.externalUserId}`,
2172
+ )
2173
+ .join("\n");
2174
+ const membersChanged = existingMemberKey !== nextMemberKey;
2175
+ const currentProfileIds = new Set<string>();
2158
2176
 
2159
- const previous = existingEdges.get(profileId);
2160
- insertFollowEdge.run(
2177
+ insertFollowSnapshot.run(
2178
+ snapshotId,
2161
2179
  "acct_primary",
2162
2180
  direction,
2163
- profileId,
2164
- row.externalUserId,
2165
- now,
2181
+ entryCount,
2182
+ rows.length,
2166
2183
  now,
2167
2184
  now,
2185
+ JSON.stringify({ archivePath, result_count: rows.length }),
2168
2186
  );
2169
- if (!previous || previous.current === 0) {
2187
+
2188
+ if (membersChanged) {
2189
+ deleteFollowSnapshotMembers.run(snapshotId);
2190
+ }
2191
+ rows.forEach((row, index) => {
2192
+ const profileId = resolveProfileId(row.profileId);
2193
+ currentProfileIds.add(profileId);
2194
+ if (membersChanged) {
2195
+ insertFollowSnapshotMember.run(
2196
+ snapshotId,
2197
+ profileId,
2198
+ row.externalUserId,
2199
+ index,
2200
+ );
2201
+ }
2202
+
2203
+ const previous = existingEdges.get(profileId);
2204
+ insertFollowEdge.run(
2205
+ "acct_primary",
2206
+ direction,
2207
+ profileId,
2208
+ row.externalUserId,
2209
+ now,
2210
+ now,
2211
+ now,
2212
+ );
2213
+ if (!previous || previous.current === 0) {
2214
+ insertFollowEvent.run(
2215
+ `follow_event_${randomUUID()}`,
2216
+ "acct_primary",
2217
+ direction,
2218
+ profileId,
2219
+ row.externalUserId,
2220
+ "started",
2221
+ now,
2222
+ snapshotId,
2223
+ );
2224
+ }
2225
+ });
2226
+
2227
+ for (const [profileId, previous] of existingEdges) {
2228
+ if (previous.current === 0 || currentProfileIds.has(profileId)) {
2229
+ continue;
2230
+ }
2231
+ endFollowEdge.run(now, now, "acct_primary", direction, profileId);
2170
2232
  insertFollowEvent.run(
2171
2233
  `follow_event_${randomUUID()}`,
2172
2234
  "acct_primary",
2173
2235
  direction,
2174
2236
  profileId,
2175
- row.externalUserId,
2176
- "started",
2237
+ previous.external_user_id,
2238
+ "ended",
2177
2239
  now,
2178
2240
  snapshotId,
2179
2241
  );
2180
2242
  }
2181
- });
2243
+ }
2182
2244
 
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()}`,
2245
+ function clearArchiveFollowRows(direction: ArchiveFollowDirection) {
2246
+ deleteArchiveFollowEvents.run(
2247
+ "acct_primary",
2248
+ direction,
2249
+ `follow_snapshot_archive_acct_primary_${direction}`,
2190
2250
  "acct_primary",
2191
2251
  direction,
2192
- profileId,
2193
- previous.external_user_id,
2194
- "ended",
2195
- now,
2196
- snapshotId,
2197
2252
  );
2253
+ deleteArchiveFollowSnapshotMembers.run("acct_primary", direction);
2254
+ deleteArchiveFollowSnapshots.run("acct_primary", direction);
2255
+ deleteArchiveFollowEdges.run("acct_primary", direction);
2198
2256
  }
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
2257
 
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();
2258
+ db.transaction(() => {
2259
+ if (!selection) {
2260
+ clearImportedData(db);
2261
+ clearMentionSyncState(db);
2272
2262
  }
2273
- if (includeDirectMessages) {
2274
- clearDmLinkOccurrences.run("acct_primary");
2275
- clearDmFts.run("acct_primary");
2276
- clearDmMessages.run("acct_primary");
2277
- clearDmConversations.run("acct_primary");
2278
- }
2279
- }
2280
-
2281
- const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2282
- writeAccount.run(
2283
- "acct_primary",
2284
- accountPayload.displayName,
2285
- `@${accountPayload.username}`,
2286
- accountPayload.accountId,
2287
- "archive",
2288
- accountPayload.createdAt,
2289
- );
2290
2263
 
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
- }
2264
+ if (selection) {
2265
+ if (includeTweets) {
2266
+ clearAuthoredSyncCursors(db, "acct_primary");
2267
+ demoteSelectedArchiveTweetsWithCollections.run(
2268
+ "acct_primary",
2269
+ "acct_primary",
2270
+ "acct_primary",
2271
+ "acct_primary",
2272
+ "acct_primary",
2273
+ localProfile.id,
2274
+ "acct_primary",
2275
+ );
2276
+ preserveSelectedArchiveTweetsReferencedElsewhere.run(
2277
+ "acct_primary",
2278
+ "acct_primary",
2279
+ "acct_primary",
2280
+ localProfile.id,
2281
+ "acct_primary",
2282
+ "acct_primary",
2283
+ "acct_primary",
2284
+ localProfile.id,
2285
+ "acct_primary",
2286
+ "acct_primary",
2287
+ localProfile.id,
2288
+ );
2289
+ deleteSelectedArchiveTweetsWithoutCollections.run(
2290
+ "acct_primary",
2291
+ "acct_primary",
2292
+ "acct_primary",
2293
+ localProfile.id,
2294
+ "acct_primary",
2295
+ "acct_primary",
2296
+ localProfile.id,
2297
+ "acct_primary",
2298
+ "acct_primary",
2299
+ localProfile.id,
2300
+ );
2301
+ deleteOrphanTweetFts.run();
2302
+ deleteOrphanTweetLinkOccurrences.run();
2303
+ clearSelectedArchiveTweetEdges.run(
2304
+ "acct_primary",
2305
+ "acct_primary",
2306
+ localProfile.id,
2307
+ );
2308
+ }
2309
+ if (includeLikes) {
2310
+ clearTweetLikedFlag.run("acct_primary", "acct_primary");
2311
+ clearSelectedLikes.run("acct_primary");
2312
+ }
2313
+ if (includeBookmarks) {
2314
+ clearTweetBookmarkedFlag.run("acct_primary", "acct_primary");
2315
+ clearSelectedBookmarks.run("acct_primary");
2316
+ }
2317
+ if (includeLikes || includeBookmarks) {
2318
+ deleteOrphanArchiveCollectionTweets.run("acct_primary");
2319
+ deleteOrphanTweetFts.run();
2320
+ deleteOrphanTweetLinkOccurrences.run();
2321
+ }
2322
+ if (includeDirectMessages) {
2323
+ clearDmLinkOccurrences.run("acct_primary");
2324
+ clearDmFts.run("acct_primary");
2325
+ clearDmMessages.run("acct_primary");
2326
+ clearDmConversations.run("acct_primary");
2327
+ }
2328
+ }
2312
2329
 
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,
2330
+ const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2331
+ writeAccount.run(
2320
2332
  "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,
2333
+ accountPayload.displayName,
2334
+ `@${accountPayload.username}`,
2335
+ accountPayload.accountId,
2336
+ "archive",
2337
+ accountPayload.createdAt,
2334
2338
  );
2335
- deleteTweetFts.run(tweet.id);
2336
- if (tweet.kind === "home") {
2337
- insertTimelineEdge.run(
2338
- "acct_primary",
2339
+
2340
+ const writeProfile =
2341
+ !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
2342
+ for (const profile of profiles.values()) {
2343
+ writeProfile.run(
2344
+ profile.id,
2345
+ profile.handle,
2346
+ profile.displayName,
2347
+ profile.bio,
2348
+ profile.followersCount,
2349
+ profile.followingCount,
2350
+ profile.publicMetricsJson,
2351
+ profile.avatarHue,
2352
+ profile.avatarUrl,
2353
+ profile.location,
2354
+ profile.url,
2355
+ profile.verifiedType,
2356
+ profile.entitiesJson,
2357
+ profile.rawJson,
2358
+ profile.createdAt,
2359
+ );
2360
+ }
2361
+
2362
+ for (const tweet of tweetRows) {
2363
+ const authorProfileId =
2364
+ tweet.authorProfileId === "profile_me"
2365
+ ? localProfile.id
2366
+ : resolveProfileId(tweet.authorProfileId);
2367
+ insertTweet.run(
2339
2368
  tweet.id,
2369
+ "acct_primary",
2370
+ authorProfileId,
2340
2371
  tweet.kind,
2372
+ tweet.text,
2341
2373
  tweet.createdAt,
2342
- tweet.createdAt,
2343
- new Date().toISOString(),
2374
+ tweet.isReplied,
2375
+ tweet.replyToId,
2376
+ tweet.likeCount,
2377
+ tweet.mediaCount,
2378
+ tweet.bookmarked,
2379
+ tweet.liked,
2380
+ tweet.entitiesJson,
2381
+ tweet.mediaJson,
2382
+ tweet.quotedTweetId,
2344
2383
  );
2384
+ deleteTweetFts.run(tweet.id);
2385
+ if (tweet.kind === "home") {
2386
+ insertTimelineEdge.run(
2387
+ "acct_primary",
2388
+ tweet.id,
2389
+ tweet.kind,
2390
+ tweet.createdAt,
2391
+ tweet.createdAt,
2392
+ new Date().toISOString(),
2393
+ );
2394
+ }
2395
+ if (authorProfileId === localProfile.id) {
2396
+ insertTimelineEdge.run(
2397
+ "acct_primary",
2398
+ tweet.id,
2399
+ "authored",
2400
+ tweet.createdAt,
2401
+ tweet.createdAt,
2402
+ new Date().toISOString(),
2403
+ );
2404
+ }
2405
+ const storedTweet = selectTweetFtsText.get(tweet.id) as
2406
+ | { text: string }
2407
+ | undefined;
2408
+ insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2345
2409
  }
2346
- if (authorProfileId === localProfile.id) {
2347
- insertTimelineEdge.run(
2410
+
2411
+ const importedAt = new Date().toISOString();
2412
+ for (const collection of collectionRows) {
2413
+ insertCollection.run(
2348
2414
  "acct_primary",
2349
- tweet.id,
2350
- "authored",
2351
- tweet.createdAt,
2352
- tweet.createdAt,
2353
- new Date().toISOString(),
2415
+ collection.tweetId,
2416
+ collection.kind,
2417
+ collection.collectedAt,
2418
+ collection.source,
2419
+ collection.rawJson,
2420
+ importedAt,
2354
2421
  );
2355
2422
  }
2356
- const storedTweet = selectTweetFtsText.get(tweet.id) as
2357
- | { text: string }
2358
- | undefined;
2359
- insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2360
- }
2361
2423
 
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
- }
2424
+ for (const conversation of conversations.values()) {
2425
+ insertConversation.run(
2426
+ conversation.id,
2427
+ conversation.accountId,
2428
+ conversation.participantProfileId,
2429
+ conversation.title,
2430
+ conversation.lastMessageAt,
2431
+ conversation.unreadCount,
2432
+ conversation.needsReply,
2433
+ );
2434
+ }
2374
2435
 
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
- }
2436
+ for (const message of dmMessages) {
2437
+ insertMessage.run(
2438
+ message.id,
2439
+ message.conversationId,
2440
+ message.senderProfileId,
2441
+ message.text,
2442
+ message.createdAt,
2443
+ message.direction,
2444
+ message.direction === "outbound" ? 1 : 0,
2445
+ message.mediaCount,
2446
+ );
2447
+ insertDmFts.run(message.id, message.text);
2448
+ }
2386
2449
 
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
- }
2450
+ if (includeFollowers && followerEntries.length > 0) {
2451
+ importFollowRows(
2452
+ "followers",
2453
+ followerRows,
2454
+ followerEntries.length,
2455
+ importedAt,
2456
+ );
2457
+ } else if (includeFollowers) {
2458
+ clearArchiveFollowRows("followers");
2459
+ }
2460
+ if (includeFollowing && followingEntries.length > 0) {
2461
+ importFollowRows(
2462
+ "following",
2463
+ followingRows,
2464
+ followingEntries.length,
2465
+ importedAt,
2466
+ );
2467
+ } else if (includeFollowing) {
2468
+ clearArchiveFollowRows("following");
2469
+ }
2470
+ })();
2400
2471
 
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
- })();
2472
+ return {
2473
+ ok: true,
2474
+ archivePath,
2475
+ account: {
2476
+ id: accountPayload.accountId,
2477
+ handle: accountPayload.username,
2478
+ displayName: accountPayload.displayName,
2479
+ },
2480
+ counts: {
2481
+ tweets: authoredTweetCount,
2482
+ likes: likeCount,
2483
+ bookmarks: bookmarkCount,
2484
+ dmConversations: conversations.size,
2485
+ dmMessages: dmMessages.length,
2486
+ profiles: profiles.size,
2487
+ mediaFiles: mediaFileCounts,
2488
+ followers: followerRows.length,
2489
+ following: followingRows.length,
2490
+ },
2491
+ };
2492
+ });
2493
+ }
2422
2494
 
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
- };
2495
+ export function importArchiveEffect(
2496
+ archivePath: string,
2497
+ options: ImportArchiveOptions = {},
2498
+ ): Effect.Effect<ImportedArchiveSummary, unknown> {
2499
+ return importArchiveInternalEffect(archivePath, options);
2500
+ }
2501
+
2502
+ export function importArchive(
2503
+ archivePath: string,
2504
+ options: ImportArchiveOptions = {},
2505
+ ): Promise<ImportedArchiveSummary> {
2506
+ return runEffectPromise(importArchiveEffect(archivePath, options));
2443
2507
  }
2444
2508
 
2445
2509
  export const __test__ = {