birdclaw 0.8.3 → 0.8.4

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 (284) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -8
  3. package/bin/birdclaw.mjs +4 -66
  4. package/dist/cli/birdclaw.js +28165 -0
  5. package/dist/client/assets/AvatarChip-DLoqM5xD.js +1 -0
  6. package/dist/client/assets/ProfilePreview-BejAuYsJ.js +1 -0
  7. package/dist/client/assets/SavedTimelineView-BIS7HKpp.js +1 -0
  8. package/dist/client/assets/SmartTimestamp-BR0-9se2.js +1 -0
  9. package/dist/client/assets/TimelineRouteFrame-CyGPj0eM.js +1 -0
  10. package/dist/client/assets/blocks-7UDKQp8H.js +1 -0
  11. package/dist/client/assets/bookmarks-CbBkMvXk.js +1 -0
  12. package/dist/client/assets/chunk-C0phanGV.js +1 -0
  13. package/dist/client/assets/circle-check-C_Vq3ElD.js +1 -0
  14. package/dist/client/assets/client-stream-contracts-ys3XhWG4.js +5 -0
  15. package/dist/client/assets/createLucideIcon-JV8isFFR.js +1 -0
  16. package/dist/client/assets/data-sources-Cm2bpNvg.js +1 -0
  17. package/dist/client/assets/discuss-DTjaOugw.js +1 -0
  18. package/dist/client/assets/dms-CGd6Eogq.js +1 -0
  19. package/dist/client/assets/inbox-BHlGuOy2.js +1 -0
  20. package/dist/client/assets/index-CEIG7S6s.js +31 -0
  21. package/dist/client/assets/index-bpFrQ5yG.css +1 -0
  22. package/dist/client/assets/likes-cLrcAQem.js +1 -0
  23. package/dist/client/assets/links-CM9QO1LC.js +1 -0
  24. package/dist/client/assets/mapbox-BxTlqXt9.js +2 -0
  25. package/dist/client/assets/mapbox-gl-dgf_hX3a.js +3215 -0
  26. package/dist/client/assets/mentions-CxKCgzIf.js +1 -0
  27. package/dist/client/assets/network-map-B5a3whqj.js +2 -0
  28. package/dist/client/assets/profile-analyze-CRtNEwlb.js +1 -0
  29. package/dist/client/assets/profile-hydration-client-YEtJsNTv.js +1 -0
  30. package/dist/client/assets/profiles._handle-C82_HoKX.js +1 -0
  31. package/dist/client/assets/query-client-fYu2rzRC.js +17 -0
  32. package/dist/client/assets/rate-limits-ScaEwdVK.js +1 -0
  33. package/dist/client/assets/react-dom-DHl6Ug7F.js +1 -0
  34. package/dist/client/assets/react-nBylq1U5.js +1 -0
  35. package/dist/client/assets/repeat-2-xCwHM9hD.js +1 -0
  36. package/dist/client/assets/routes-q7ZaW4En.js +1 -0
  37. package/dist/client/assets/schemas-I9r6F6ya.js +64 -0
  38. package/dist/client/assets/search-Dq5kYsxA.js +1 -0
  39. package/dist/client/assets/sparkles-CQVlvHsZ.js +1 -0
  40. package/dist/client/assets/styles-B8s3tt3r.css +2 -0
  41. package/dist/client/assets/today-CeG1O7Py.js +1 -0
  42. package/dist/client/assets/triangle-alert-CBL7s2pf.js +1 -0
  43. package/dist/client/assets/useTimelineRouteData-1HsqgvUi.js +1 -0
  44. package/dist/client/assets/users-CHbKlqUr.js +1 -0
  45. package/dist/server/assets/AvatarChip-zUDAEmjL.js +105 -0
  46. package/dist/server/assets/FeedState-ntW0Q2GB.js +214 -0
  47. package/dist/server/assets/ProfileAnalysisStream-BFvhVdhn.js +198 -0
  48. package/dist/server/assets/ProfilePreview-C6gAor_j.js +752 -0
  49. package/dist/server/assets/SavedTimelineView-BG1jnBp1.js +64 -0
  50. package/dist/server/assets/SmartTimestamp-DfwE1NGf.js +45 -0
  51. package/dist/server/assets/SyncNowButton-BOn_IBXS.js +76 -0
  52. package/dist/server/assets/TimelineRouteFrame-CU7ilM7U.js +85 -0
  53. package/dist/server/assets/TweetRichText-DhCVv1-6.js +74 -0
  54. package/dist/server/assets/_tanstack-start-manifest_v-C-ZRnG_n.js +148 -0
  55. package/dist/server/assets/account-selection-CTdFGWRk.js +44 -0
  56. package/dist/server/assets/blocks-C1mgGTB3.js +299 -0
  57. package/dist/server/assets/blocks-DSgQghqP.js +17 -0
  58. package/dist/server/assets/bookmarks-BJN8tykY.js +14 -0
  59. package/dist/server/assets/client-stream-contracts-C2xbWVjm.js +769 -0
  60. package/dist/server/assets/data-sources-BnA1Q94p.js +180 -0
  61. package/dist/server/assets/discuss-CJ1dau1j.js +15 -0
  62. package/dist/server/assets/discuss-QTo08NCN.js +338 -0
  63. package/dist/server/assets/dms-BuLj4bp8.js +368 -0
  64. package/dist/server/assets/dms-DAyQrVM2.js +240 -0
  65. package/dist/server/assets/empty-plugin-adapters-DzrEkdBF.js +5 -0
  66. package/dist/server/assets/inbox-CkGOaW1q.js +214 -0
  67. package/dist/server/assets/inbox-DpWd1EzC.js +146 -0
  68. package/dist/server/assets/likes-DBOAtsJU.js +14 -0
  69. package/dist/server/assets/links-DAhSjynu.js +240 -0
  70. package/dist/server/assets/links-DmH5lxHO.js +468 -0
  71. package/dist/server/assets/mentions-BH2MzKtB.js +26 -0
  72. package/dist/server/assets/network-map-D3CzRcC4.js +224 -0
  73. package/dist/server/assets/network-map-D5DoLOwR.js +582 -0
  74. package/dist/server/assets/profile-analyze-BP65PED0.js +90 -0
  75. package/dist/server/assets/profile-analyze-C1NoM0tV.js +9 -0
  76. package/dist/server/assets/profile-hydration-client-GJ4kwJS8.js +133 -0
  77. package/dist/server/assets/profiles._handle-DjwlN_ac.js +184 -0
  78. package/dist/server/assets/profiles._handle-DsLNj2Ca.js +12 -0
  79. package/dist/server/assets/query-client-BdauHBgd.js +923 -0
  80. package/dist/server/assets/rate-limits-VAr2qzYD.js +262 -0
  81. package/dist/server/assets/route-search-BUlQ5lGM.js +117 -0
  82. package/dist/server/assets/router-DqkwSPiM.js +17026 -0
  83. package/dist/server/assets/routes-A9c9ZhwN.js +26 -0
  84. package/dist/server/assets/start-FaDOTmto.js +4 -0
  85. package/dist/server/assets/today-B5hVM57Z.js +17 -0
  86. package/dist/server/assets/today-fvGiZ7ud.js +303 -0
  87. package/dist/server/assets/ui-qbmcMSSX.js +137 -0
  88. package/{src/components/useDebouncedValue.ts → dist/server/assets/useDebouncedValue-DOnA6Xcy.js} +4 -4
  89. package/dist/server/assets/useTimelineRouteData-27cxynxa.js +1101 -0
  90. package/dist/server/server.js +5520 -0
  91. package/package.json +26 -34
  92. package/playwright.config.ts +0 -35
  93. package/scripts/browser-perf.mjs +0 -427
  94. package/scripts/build-docs-site.mjs +0 -967
  95. package/scripts/docs-site-assets.mjs +0 -311
  96. package/scripts/run-vitest.mjs +0 -21
  97. package/scripts/sanitize-node-options.mjs +0 -23
  98. package/scripts/start-test-server.mjs +0 -42
  99. package/src/cli/command-context.ts +0 -17
  100. package/src/cli/register-analysis.ts +0 -500
  101. package/src/cli/register-compose.ts +0 -40
  102. package/src/cli/register-graph.ts +0 -132
  103. package/src/cli/register-inbox.ts +0 -41
  104. package/src/cli/register-storage.ts +0 -106
  105. package/src/cli-moderation.ts +0 -210
  106. package/src/cli.ts +0 -1616
  107. package/src/components/AccountSwitcher.tsx +0 -178
  108. package/src/components/AppNav.tsx +0 -118
  109. package/src/components/AvatarChip.tsx +0 -58
  110. package/src/components/AvatarPreload.ts +0 -149
  111. package/src/components/BrandMark.tsx +0 -67
  112. package/src/components/ConversationThread.tsx +0 -131
  113. package/src/components/DmWorkspace.tsx +0 -286
  114. package/src/components/EmbeddedTweetCard.tsx +0 -55
  115. package/src/components/FeedState.tsx +0 -147
  116. package/src/components/FloatingPreview.tsx +0 -382
  117. package/src/components/InboxCard.tsx +0 -152
  118. package/src/components/LinkPreviewCard.tsx +0 -292
  119. package/src/components/MarkdownCitations.tsx +0 -680
  120. package/src/components/MarkdownViewer.tsx +0 -111
  121. package/src/components/ProfileAnalysisClient.ts +0 -191
  122. package/src/components/ProfileAnalysisStream.tsx +0 -259
  123. package/src/components/ProfilePreview.tsx +0 -131
  124. package/src/components/SavedTimelineView.tsx +0 -155
  125. package/src/components/SmartTimestamp.tsx +0 -72
  126. package/src/components/SyncNowButton.tsx +0 -140
  127. package/src/components/ThemeSlider.tsx +0 -80
  128. package/src/components/TimelineCard.tsx +0 -533
  129. package/src/components/TimelineRouteFrame.tsx +0 -186
  130. package/src/components/TweetArticleCard.tsx +0 -66
  131. package/src/components/TweetMediaGrid.tsx +0 -185
  132. package/src/components/TweetRichText.tsx +0 -159
  133. package/src/components/account-selection.ts +0 -64
  134. package/src/components/links-controller.ts +0 -162
  135. package/src/components/links-model.ts +0 -198
  136. package/src/components/network-map-controller.ts +0 -84
  137. package/src/components/network-map-model.ts +0 -255
  138. package/src/components/useTimelineRouteData.ts +0 -193
  139. package/src/lib/account-sync-job.ts +0 -666
  140. package/src/lib/actions-transport.ts +0 -300
  141. package/src/lib/analysis-runtime.ts +0 -238
  142. package/src/lib/api-client.ts +0 -220
  143. package/src/lib/api-contracts.ts +0 -328
  144. package/src/lib/archive-finder.ts +0 -185
  145. package/src/lib/archive-import-plan.ts +0 -102
  146. package/src/lib/archive-import.ts +0 -2695
  147. package/src/lib/authored-live.ts +0 -1027
  148. package/src/lib/avatar-cache.ts +0 -349
  149. package/src/lib/backup.ts +0 -2652
  150. package/src/lib/bird-actions.ts +0 -294
  151. package/src/lib/bird-command.ts +0 -130
  152. package/src/lib/bird.ts +0 -1140
  153. package/src/lib/blocklist.ts +0 -136
  154. package/src/lib/blocks-write.ts +0 -171
  155. package/src/lib/blocks.ts +0 -387
  156. package/src/lib/bookmark-sync-job.ts +0 -536
  157. package/src/lib/config.ts +0 -224
  158. package/src/lib/conversation-surface.ts +0 -205
  159. package/src/lib/data-sources.ts +0 -219
  160. package/src/lib/database-metrics.ts +0 -88
  161. package/src/lib/database-migrations.ts +0 -34
  162. package/src/lib/database-schema.ts +0 -312
  163. package/src/lib/database-writer.ts +0 -69
  164. package/src/lib/db.ts +0 -889
  165. package/src/lib/dm-read-model.ts +0 -533
  166. package/src/lib/dms-live.ts +0 -870
  167. package/src/lib/effect-runtime.ts +0 -45
  168. package/src/lib/follow-graph.ts +0 -1087
  169. package/src/lib/geocoding.ts +0 -296
  170. package/src/lib/http-effect.ts +0 -222
  171. package/src/lib/identity-search-index.ts +0 -369
  172. package/src/lib/import-repository.ts +0 -138
  173. package/src/lib/inbox.ts +0 -242
  174. package/src/lib/link-index.ts +0 -641
  175. package/src/lib/link-insights.ts +0 -860
  176. package/src/lib/link-preview-metadata.ts +0 -754
  177. package/src/lib/live-sync-engine.ts +0 -209
  178. package/src/lib/live-transport-gateway.ts +0 -128
  179. package/src/lib/location.ts +0 -137
  180. package/src/lib/media-fetch.ts +0 -860
  181. package/src/lib/media-includes.ts +0 -165
  182. package/src/lib/mention-threads-live.ts +0 -830
  183. package/src/lib/mentions-export.ts +0 -147
  184. package/src/lib/mentions-live.ts +0 -1114
  185. package/src/lib/moderation-target.ts +0 -227
  186. package/src/lib/moderation-write.ts +0 -131
  187. package/src/lib/mutes-write.ts +0 -171
  188. package/src/lib/mutes.ts +0 -84
  189. package/src/lib/ndjson-stream.ts +0 -106
  190. package/src/lib/network-map.ts +0 -382
  191. package/src/lib/openai-response-runtime.ts +0 -251
  192. package/src/lib/openai.ts +0 -117
  193. package/src/lib/paginated-sync.ts +0 -93
  194. package/src/lib/period-digest.ts +0 -1364
  195. package/src/lib/present.ts +0 -118
  196. package/src/lib/profile-affiliation-hydration.ts +0 -228
  197. package/src/lib/profile-affiliations.ts +0 -262
  198. package/src/lib/profile-analysis.ts +0 -1198
  199. package/src/lib/profile-bio-entities.ts +0 -318
  200. package/src/lib/profile-history.ts +0 -164
  201. package/src/lib/profile-hydration.ts +0 -180
  202. package/src/lib/profile-replies.ts +0 -77
  203. package/src/lib/profile-resolver.ts +0 -650
  204. package/src/lib/queries.ts +0 -6
  205. package/src/lib/query-actions.ts +0 -437
  206. package/src/lib/query-client.tsx +0 -47
  207. package/src/lib/query-read-model-shared.ts +0 -52
  208. package/src/lib/query-read-models.ts +0 -5
  209. package/src/lib/query-resource.ts +0 -41
  210. package/src/lib/query-status.ts +0 -164
  211. package/src/lib/research.ts +0 -652
  212. package/src/lib/runtime-services.ts +0 -20
  213. package/src/lib/search-discussion.ts +0 -812
  214. package/src/lib/seed.ts +0 -613
  215. package/src/lib/server-runtime-services.ts +0 -30
  216. package/src/lib/sqlite.ts +0 -183
  217. package/src/lib/streaming-ingestion.ts +0 -240
  218. package/src/lib/sync-cache.ts +0 -70
  219. package/src/lib/sync-plan.ts +0 -175
  220. package/src/lib/theme-transition.ts +0 -113
  221. package/src/lib/theme.tsx +0 -165
  222. package/src/lib/timeline-collections-live.ts +0 -407
  223. package/src/lib/timeline-live.ts +0 -302
  224. package/src/lib/timeline-read-model.ts +0 -1191
  225. package/src/lib/tweet-account-edges.ts +0 -48
  226. package/src/lib/tweet-lookup.ts +0 -46
  227. package/src/lib/tweet-render.ts +0 -430
  228. package/src/lib/tweet-repository.ts +0 -156
  229. package/src/lib/tweet-search-live.ts +0 -462
  230. package/src/lib/types.ts +0 -723
  231. package/src/lib/ui.ts +0 -529
  232. package/src/lib/url-expansion-store.ts +0 -110
  233. package/src/lib/url-expansion.ts +0 -343
  234. package/src/lib/url-safety.ts +0 -220
  235. package/src/lib/web-sync.ts +0 -533
  236. package/src/lib/whois.ts +0 -1080
  237. package/src/lib/x-profile.ts +0 -365
  238. package/src/lib/x-web.ts +0 -199
  239. package/src/lib/xurl-rate-limits.ts +0 -267
  240. package/src/lib/xurl.ts +0 -1677
  241. package/src/routeTree.gen.ts +0 -723
  242. package/src/router.tsx +0 -20
  243. package/src/routes/__root.tsx +0 -82
  244. package/src/routes/api/action.tsx +0 -147
  245. package/src/routes/api/avatar.tsx +0 -50
  246. package/src/routes/api/blocks.tsx +0 -35
  247. package/src/routes/api/conversation.tsx +0 -45
  248. package/src/routes/api/data-sources.tsx +0 -24
  249. package/src/routes/api/inbox.tsx +0 -43
  250. package/src/routes/api/link-insights.tsx +0 -85
  251. package/src/routes/api/link-preview.tsx +0 -47
  252. package/src/routes/api/network-map.tsx +0 -55
  253. package/src/routes/api/period-digest.tsx +0 -101
  254. package/src/routes/api/profile-analysis.tsx +0 -97
  255. package/src/routes/api/profile-hydrate.tsx +0 -54
  256. package/src/routes/api/query.tsx +0 -121
  257. package/src/routes/api/search-discussion.tsx +0 -113
  258. package/src/routes/api/status.tsx +0 -30
  259. package/src/routes/api/sync.tsx +0 -108
  260. package/src/routes/api/xurl-rate-limits.tsx +0 -24
  261. package/src/routes/blocks.tsx +0 -343
  262. package/src/routes/bookmarks.tsx +0 -18
  263. package/src/routes/data-sources.tsx +0 -249
  264. package/src/routes/discuss.tsx +0 -419
  265. package/src/routes/dms.tsx +0 -415
  266. package/src/routes/inbox.tsx +0 -232
  267. package/src/routes/index.tsx +0 -32
  268. package/src/routes/likes.tsx +0 -18
  269. package/src/routes/links.tsx +0 -631
  270. package/src/routes/mentions.tsx +0 -32
  271. package/src/routes/network-map.tsx +0 -732
  272. package/src/routes/profile-analyze.tsx +0 -112
  273. package/src/routes/profiles.$handle.tsx +0 -228
  274. package/src/routes/rate-limits.tsx +0 -305
  275. package/src/routes/today.tsx +0 -511
  276. package/src/styles.css +0 -257
  277. package/tsconfig.json +0 -29
  278. package/vite.config.ts +0 -29
  279. /package/{public → dist/client}/birdclaw-mark.png +0 -0
  280. /package/{public → dist/client}/favicon.ico +0 -0
  281. /package/{public → dist/client}/logo192.png +0 -0
  282. /package/{public → dist/client}/logo512.png +0 -0
  283. /package/{public → dist/client}/manifest.json +0 -0
  284. /package/{public → dist/client}/robots.txt +0 -0
@@ -1,2695 +0,0 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import {
4
- createWriteStream,
5
- existsSync,
6
- mkdirSync,
7
- renameSync,
8
- statSync,
9
- unlinkSync,
10
- } from "node:fs";
11
- import path from "node:path";
12
- import { pipeline } from "node:stream/promises";
13
- import { promisify } from "node:util";
14
- import { Effect } from "effect";
15
- import {
16
- ArchiveImportPlan,
17
- type ArchiveMessageRow,
18
- type ArchiveProfileRow,
19
- } from "./archive-import-plan";
20
- import { getBirdclawPaths } from "./config";
21
- import { getNativeDb } from "./db";
22
- import { databaseWriteEffect } from "./database-writer";
23
- import { runEffectPromise, tryPromise } from "./effect-runtime";
24
- import { getImportRepository } from "./import-repository";
25
- import {
26
- ingestSourcesInBatchesEffect,
27
- streamAssignedJsonArray,
28
- } from "./streaming-ingestion";
29
- import { safeHttpUrl } from "./url-safety";
30
-
31
- const execFileAsync = promisify(execFile);
32
- const ARCHIVE_JSON_PAYLOAD = /=\s*(\[[\s\S]*\]|\{[\s\S]*\})/s;
33
-
34
- interface ArchiveAccountPayload {
35
- accountId: string;
36
- username: string;
37
- displayName: string;
38
- createdAt: string;
39
- bio: string;
40
- }
41
-
42
- interface ImportedArchiveSummary {
43
- ok: true;
44
- archivePath: string;
45
- account: {
46
- id: string;
47
- handle: string;
48
- displayName: string;
49
- };
50
- counts: {
51
- tweets: number;
52
- likes: number;
53
- bookmarks: number;
54
- dmConversations: number;
55
- dmMessages: number;
56
- profiles: number;
57
- mediaFiles: ArchiveMediaFileCounts;
58
- followers: number;
59
- following: number;
60
- };
61
- }
62
-
63
- export const ARCHIVE_IMPORT_SLICES = [
64
- "tweets",
65
- "likes",
66
- "bookmarks",
67
- "directMessages",
68
- "profiles",
69
- "followers",
70
- "following",
71
- ] as const;
72
-
73
- export type ArchiveImportSlice = (typeof ARCHIVE_IMPORT_SLICES)[number];
74
-
75
- export type ImportProgressSlice =
76
- | "tweets"
77
- | "noteTweets"
78
- | "directMessages"
79
- | "likes"
80
- | "bookmarks"
81
- | "media"
82
- | "followers"
83
- | "following";
84
-
85
- export type ImportWritePhase =
86
- | "profiles"
87
- | "tweets"
88
- | "collections"
89
- | "dmMessages";
90
-
91
- export type ImportProgressEvent =
92
- | { kind: "scanned"; entryCount: number }
93
- | { kind: "slice-start"; slice: ImportProgressSlice; files: number }
94
- | {
95
- kind: "slice-file";
96
- slice: ImportProgressSlice;
97
- processed: number;
98
- files: number;
99
- }
100
- | { kind: "slice-done"; slice: ImportProgressSlice; count: number }
101
- | { kind: "writing" }
102
- | { kind: "write-start"; phase: ImportWritePhase; total: number }
103
- | {
104
- kind: "write-progress";
105
- phase: ImportWritePhase;
106
- processed: number;
107
- total: number;
108
- }
109
- | { kind: "done" };
110
-
111
- export interface ImportArchiveOptions {
112
- select?: ArchiveImportSlice[];
113
- onProgress?: (event: ImportProgressEvent) => void;
114
- }
115
-
116
- type ArchiveRecord = Record<string, unknown>;
117
- type ArchiveMediaKind =
118
- | "tweets"
119
- | "dms"
120
- | "community"
121
- | "profile"
122
- | "deleted"
123
- | "moments"
124
- | "dmGroup";
125
- type ArchiveMediaFileCounts = Record<ArchiveMediaKind, number>;
126
-
127
- const ARCHIVE_MEDIA_DIRECTORIES: Array<{
128
- directory: string;
129
- kind: ArchiveMediaKind;
130
- }> = [
131
- { directory: "tweets_media", kind: "tweets" },
132
- { directory: "direct_messages_media", kind: "dms" },
133
- { directory: "community_tweet_media", kind: "community" },
134
- { directory: "deleted_tweets_media", kind: "deleted" },
135
- { directory: "profile_media", kind: "profile" },
136
- { directory: "moments_tweets_media", kind: "moments" },
137
- { directory: "direct_messages_group_media", kind: "dmGroup" },
138
- ];
139
- type ArchiveFollowDirection = "followers" | "following";
140
- type ArchiveFollowKey = "follower" | "following";
141
-
142
- function normalizeArchivePath(value: string) {
143
- return value.replaceAll("\\", "/");
144
- }
145
-
146
- function extractArchiveJson(content: string): unknown {
147
- const match = ARCHIVE_JSON_PAYLOAD.exec(content);
148
- if (!match) {
149
- return [];
150
- }
151
-
152
- return JSON.parse(match[1]);
153
- }
154
-
155
- function parseArchiveArray(content: string): ArchiveRecord[] {
156
- const parsed = extractArchiveJson(content);
157
- return Array.isArray(parsed)
158
- ? parsed.filter((item): item is ArchiveRecord => Boolean(item))
159
- : [];
160
- }
161
-
162
- function runUnzipEffect(
163
- _archivePath: string,
164
- args: string[],
165
- maxBuffer = 1024 * 1024 * 256,
166
- ) {
167
- return tryPromise(() =>
168
- execFileAsync("unzip", args, {
169
- maxBuffer,
170
- }),
171
- ).pipe(Effect.map(({ stdout }) => stdout));
172
- }
173
-
174
- function listArchiveEntriesEffect(
175
- archivePath: string,
176
- ): Effect.Effect<string[], unknown> {
177
- return Effect.gen(function* () {
178
- const stdout = yield* runUnzipEffect(
179
- archivePath,
180
- ["-Z1", archivePath],
181
- 1024 * 1024 * 64,
182
- );
183
- return stdout
184
- .split("\n")
185
- .map((item) => item.trim())
186
- .filter((item) => item.length > 0);
187
- });
188
- }
189
-
190
- function listArchiveEntryDetailsEffect(
191
- archivePath: string,
192
- ): Effect.Effect<Array<{ path: string; size: number }>, unknown> {
193
- return Effect.gen(function* () {
194
- const stdout = yield* runUnzipEffect(
195
- archivePath,
196
- ["-Z", "-l", archivePath],
197
- 1024 * 1024 * 64,
198
- );
199
- return stdout
200
- .split("\n")
201
- .map((line) => line.trim().split(/\s+/))
202
- .filter((parts) => parts.length >= 10 && /^[-d]/.test(parts[0] ?? ""))
203
- .map((parts) => ({
204
- path: parts.slice(9).join(" "),
205
- size: Number(parts[3] ?? 0),
206
- }))
207
- .filter((entry) => entry.path.length > 0 && Number.isFinite(entry.size));
208
- });
209
- }
210
-
211
- function readArchiveEntryEffect(
212
- archivePath: string,
213
- entryPath: string,
214
- ): Effect.Effect<string, unknown> {
215
- return runUnzipEffect(archivePath, ["-p", archivePath, entryPath]);
216
- }
217
-
218
- async function* streamArchiveArrayRecords(
219
- archivePath: string,
220
- entryPath: string,
221
- ) {
222
- const child = spawn("unzip", ["-p", archivePath, entryPath], {
223
- stdio: ["ignore", "pipe", "pipe"],
224
- });
225
- let stderr = "";
226
- child.stderr.setEncoding("utf8");
227
- child.stderr.on("data", (chunk) => {
228
- stderr += String(chunk);
229
- });
230
- const exit = new Promise<number | null>((resolve, reject) => {
231
- child.on("error", reject);
232
- child.on("close", resolve);
233
- });
234
-
235
- try {
236
- yield* streamAssignedJsonArray(child.stdout);
237
- const exitCode = await exit;
238
- if (exitCode !== 0) {
239
- throw new Error(
240
- `Failed to extract ${entryPath}: ${
241
- stderr.trim() || `exit ${String(exitCode)}`
242
- }`,
243
- );
244
- }
245
- } finally {
246
- if (!child.killed) child.kill();
247
- }
248
- }
249
-
250
- function processArchiveEntryRecordsEffect(
251
- archivePath: string,
252
- entryPath: string,
253
- processRecord: (record: ArchiveRecord) => void,
254
- ) {
255
- return ingestSourcesInBatchesEffect({
256
- sources: [
257
- {
258
- id: entryPath,
259
- stream: () => streamArchiveArrayRecords(archivePath, entryPath),
260
- },
261
- ],
262
- processBatch: (batch) => {
263
- for (const record of batch) processRecord(record);
264
- },
265
- });
266
- }
267
-
268
- function getFirstEntry(entries: string[], pattern: RegExp) {
269
- return entries.find((entry) => pattern.test(normalizeArchivePath(entry)));
270
- }
271
-
272
- function getMatchingEntries(entries: string[], pattern: RegExp) {
273
- return entries.filter((entry) => pattern.test(normalizeArchivePath(entry)));
274
- }
275
-
276
- function selectedSlices(options: ImportArchiveOptions) {
277
- return options.select && options.select.length > 0
278
- ? new Set<ArchiveImportSlice>(options.select)
279
- : null;
280
- }
281
-
282
- function includesSlice(
283
- selection: Set<ArchiveImportSlice> | null,
284
- slice: ArchiveImportSlice,
285
- ) {
286
- return selection === null || selection.has(slice);
287
- }
288
-
289
- function parseTwitterDate(value: unknown) {
290
- if (typeof value !== "string" || value.length === 0) {
291
- return new Date(0).toISOString();
292
- }
293
-
294
- const parsed = new Date(value);
295
- return Number.isNaN(parsed.getTime())
296
- ? new Date(0).toISOString()
297
- : parsed.toISOString();
298
- }
299
-
300
- function compareIsoTimestamp(left: string, right: string) {
301
- return left < right ? -1 : left > right ? 1 : 0;
302
- }
303
-
304
- function asRecord(value: unknown): Record<string, unknown> | null {
305
- return value && typeof value === "object" && !Array.isArray(value)
306
- ? (value as Record<string, unknown>)
307
- : null;
308
- }
309
-
310
- function asArray<T>(value: unknown): T[] {
311
- return Array.isArray(value) ? (value as T[]) : [];
312
- }
313
-
314
- function toInt(value: unknown) {
315
- const parsed = Number(value);
316
- return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
317
- }
318
-
319
- function getTweetMediaCount(tweet: Record<string, unknown>) {
320
- const entities = asRecord(tweet.entities);
321
- const extendedEntities = asRecord(tweet.extended_entities);
322
- const entitiesMedia = asArray(entities?.media);
323
- const extendedMedia = asArray(extendedEntities?.media);
324
- return Math.max(entitiesMedia.length, extendedMedia.length);
325
- }
326
-
327
- function toFiniteNumber(value: unknown) {
328
- const number = Number(value);
329
- return Number.isFinite(number) ? number : undefined;
330
- }
331
-
332
- function archiveHttpUrl(value: unknown) {
333
- return safeHttpUrl(typeof value === "string" ? value : String(value ?? ""));
334
- }
335
-
336
- function extractTweetEntities(tweet: Record<string, unknown>) {
337
- const entities = asRecord(tweet.entities);
338
- const urlEntries = [
339
- ...asArray<Record<string, unknown>>(entities?.urls),
340
- ...asArray<Record<string, unknown>>(entities?.media),
341
- ];
342
- const seenUrls = new Set<string>();
343
- const urls = urlEntries
344
- .map((entry) => ({
345
- url: archiveHttpUrl(entry.url) ?? "",
346
- expandedUrl:
347
- archiveHttpUrl(entry.expanded_url ?? entry.expandedUrl ?? entry.url) ??
348
- "",
349
- displayUrl: String(
350
- entry.display_url ??
351
- entry.displayUrl ??
352
- entry.expanded_url ??
353
- entry.url ??
354
- "",
355
- ),
356
- start: Number(asArray<number>(entry.indices)[0] ?? 0),
357
- end: Number(asArray<number>(entry.indices)[1] ?? 0),
358
- title: typeof entry.title === "string" ? entry.title : undefined,
359
- description:
360
- typeof entry.description === "string" ? entry.description : null,
361
- imageUrl:
362
- archiveHttpUrl(
363
- entry.image_url ??
364
- entry.imageUrl ??
365
- entry.thumbnail_url ??
366
- entry.media_url_https ??
367
- entry.media_url,
368
- ) ?? undefined,
369
- siteName:
370
- typeof entry.site_name === "string"
371
- ? entry.site_name
372
- : typeof entry.siteName === "string"
373
- ? entry.siteName
374
- : undefined,
375
- }))
376
- .filter((entry) => entry.url.length > 0 || entry.expandedUrl.length > 0)
377
- .filter((entry) => {
378
- const key = `${entry.start}:${entry.end}:${entry.url}:${entry.expandedUrl}`;
379
- if (seenUrls.has(key)) return false;
380
- seenUrls.add(key);
381
- return true;
382
- });
383
- const mentions = asArray<Record<string, unknown>>(entities?.user_mentions)
384
- .map((entry) => ({
385
- username: String(entry.screen_name ?? ""),
386
- id: String(entry.id_str ?? entry.id ?? ""),
387
- start: Number(asArray<number>(entry.indices)[0] ?? 0),
388
- end: Number(asArray<number>(entry.indices)[1] ?? 0),
389
- }))
390
- .filter((entry) => entry.username.length > 0);
391
- const hashtags = asArray<Record<string, unknown>>(entities?.hashtags)
392
- .map((entry) => ({
393
- tag: String(entry.text ?? ""),
394
- start: Number(asArray<number>(entry.indices)[0] ?? 0),
395
- end: Number(asArray<number>(entry.indices)[1] ?? 0),
396
- }))
397
- .filter((entry) => entry.tag.length > 0);
398
-
399
- return {
400
- ...(urls.length > 0 ? { urls } : {}),
401
- ...(mentions.length > 0 ? { mentions } : {}),
402
- ...(hashtags.length > 0 ? { hashtags } : {}),
403
- };
404
- }
405
-
406
- function archiveMediaType(value: unknown) {
407
- const type = String(value ?? "image");
408
- return type === "photo"
409
- ? "image"
410
- : type === "video" || type === "animated_gif"
411
- ? type === "animated_gif"
412
- ? "gif"
413
- : "video"
414
- : "unknown";
415
- }
416
-
417
- function archiveMediaSize(entry: Record<string, unknown>) {
418
- const sizes = asRecord(entry.sizes);
419
- const large = asRecord(sizes?.large);
420
- const largeWidth = toFiniteNumber(large?.w ?? large?.width);
421
- const largeHeight = toFiniteNumber(large?.h ?? large?.height);
422
- if (largeWidth !== undefined && largeHeight !== undefined) {
423
- return { width: largeWidth, height: largeHeight };
424
- }
425
-
426
- return Object.values(sizes ?? {})
427
- .map((size) => asRecord(size))
428
- .map((size) => ({
429
- width: toFiniteNumber(size?.w ?? size?.width),
430
- height: toFiniteNumber(size?.h ?? size?.height),
431
- }))
432
- .filter(
433
- (size): size is { width: number; height: number } =>
434
- size.width !== undefined && size.height !== undefined,
435
- )
436
- .sort(
437
- (left, right) => right.width * right.height - left.width * left.height,
438
- )[0];
439
- }
440
-
441
- function archiveMp4Variants(entry: Record<string, unknown>) {
442
- const videoInfo = asRecord(entry.video_info);
443
- return asArray<Record<string, unknown>>(videoInfo?.variants)
444
- .filter(
445
- (variant) =>
446
- variant.content_type === "video/mp4" && typeof variant.url === "string",
447
- )
448
- .map((variant) => {
449
- const bitRate = toFiniteNumber(variant.bitrate ?? variant.bit_rate);
450
- return {
451
- url: String(variant.url),
452
- contentType: String(variant.content_type),
453
- ...(bitRate !== undefined ? { bitRate } : {}),
454
- };
455
- })
456
- .sort(
457
- (left, right) => Number(right.bitRate ?? 0) - Number(left.bitRate ?? 0),
458
- );
459
- }
460
-
461
- function extractTweetMedia(tweet: Record<string, unknown>) {
462
- const extendedEntities = asRecord(tweet.extended_entities);
463
- const entities = asRecord(tweet.entities);
464
- const sourceMedia = [
465
- ...asArray<Record<string, unknown>>(extendedEntities?.media),
466
- ...asArray<Record<string, unknown>>(entities?.media),
467
- ];
468
- const seen = new Set<string>();
469
-
470
- return sourceMedia
471
- .map((entry) => {
472
- const url =
473
- archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? entry.url) ??
474
- "";
475
- const thumbnailUrl =
476
- archiveHttpUrl(entry.media_url_https ?? entry.media_url ?? url) ?? url;
477
- const videoInfo = asRecord(entry.video_info);
478
- const durationMs = toFiniteNumber(videoInfo?.duration_millis);
479
- const variants = archiveMp4Variants(entry);
480
- return {
481
- url,
482
- type: archiveMediaType(entry.type),
483
- altText:
484
- typeof entry.ext_alt_text === "string"
485
- ? entry.ext_alt_text
486
- : undefined,
487
- thumbnailUrl,
488
- ...archiveMediaSize(entry),
489
- ...(durationMs !== undefined ? { durationMs } : {}),
490
- ...(variants.length > 0 ? { variants } : {}),
491
- };
492
- })
493
- .filter((entry) => {
494
- if (!entry.url || seen.has(entry.url)) {
495
- return false;
496
- }
497
- seen.add(entry.url);
498
- return true;
499
- });
500
- }
501
-
502
- function extractCollectionTweet(
503
- wrapper: ArchiveRecord,
504
- key: "like" | "bookmark",
505
- ) {
506
- const entry = asRecord(wrapper[key]) ?? asRecord(wrapper.tweet);
507
- if (!entry) return null;
508
-
509
- const id = String(
510
- entry.tweetId ?? entry.tweet_id ?? entry.id_str ?? entry.id ?? "",
511
- );
512
- if (!id) return null;
513
-
514
- return {
515
- id,
516
- text: String(
517
- entry.fullText ??
518
- entry.full_text ??
519
- entry.text ??
520
- entry.expandedUrl ??
521
- entry.expanded_url ??
522
- "",
523
- ),
524
- createdAt: parseTwitterDate(
525
- entry.likedAt ??
526
- entry.bookmarkedAt ??
527
- entry.createdAt ??
528
- entry.created_at ??
529
- new Date(0).toISOString(),
530
- ),
531
- likeCount: toInt(entry.favorite_count ?? entry.like_count),
532
- };
533
- }
534
-
535
- function buildAccountPayload(
536
- accountRecord: Record<string, unknown> | null,
537
- profileRecord: Record<string, unknown> | null,
538
- ): ArchiveAccountPayload {
539
- const account = asRecord(accountRecord?.account);
540
- const profile = asRecord(profileRecord?.profile);
541
- const description = asRecord(profile?.description);
542
-
543
- return {
544
- accountId: String(account?.accountId ?? "unknown"),
545
- username: String(account?.username ?? "unknown"),
546
- displayName: String(
547
- account?.accountDisplayName ??
548
- account?.name ??
549
- account?.username ??
550
- "Unknown",
551
- ),
552
- createdAt: parseTwitterDate(account?.createdAt),
553
- bio: String(description?.bio ?? ""),
554
- };
555
- }
556
-
557
- function inferProfileFromDirectory(
558
- userId: string,
559
- directory: Map<string, { handle?: string; displayName?: string }>,
560
- ) {
561
- const match = directory.get(userId);
562
- const handle = match?.handle?.replace(/^@/, "") || `id${userId}`;
563
- const displayName = match?.displayName || handle;
564
- return { handle, displayName };
565
- }
566
-
567
- function createArchiveMediaFileCounts(): ArchiveMediaFileCounts {
568
- return {
569
- tweets: 0,
570
- dms: 0,
571
- community: 0,
572
- profile: 0,
573
- deleted: 0,
574
- moments: 0,
575
- dmGroup: 0,
576
- };
577
- }
578
-
579
- function selectedArchiveMediaKinds(selection: Set<ArchiveImportSlice> | null) {
580
- if (!selection) return null;
581
- const kinds = new Set<ArchiveMediaKind>();
582
- if (selection.has("tweets")) {
583
- for (const kind of ["tweets", "community", "deleted", "moments"] as const) {
584
- kinds.add(kind);
585
- }
586
- }
587
- if (selection.has("directMessages")) {
588
- for (const kind of ["dms", "dmGroup"] as const) {
589
- kinds.add(kind);
590
- }
591
- }
592
- if (selection.has("profiles")) {
593
- kinds.add("profile");
594
- }
595
- return kinds;
596
- }
597
-
598
- function getArchiveMediaKind(entryPath: string) {
599
- const normalized = normalizeArchivePath(entryPath);
600
- if (normalized.endsWith("/")) return undefined;
601
- return ARCHIVE_MEDIA_DIRECTORIES.find(({ directory }) =>
602
- new RegExp(`(?:^|/)data/${directory}/[^/]+$`).test(normalized),
603
- );
604
- }
605
-
606
- function getArchiveMediaOwnerId(entryPath: string) {
607
- const fileName = path.posix.basename(normalizeArchivePath(entryPath));
608
- const separator = fileName.indexOf("-");
609
- return separator > 0 ? fileName.slice(0, separator) : "unknown";
610
- }
611
-
612
- function getArchiveMediaDestination(entryPath: string, kind: ArchiveMediaKind) {
613
- const { mediaOriginalsDir } = getBirdclawPaths();
614
- const normalized = normalizeArchivePath(entryPath);
615
- const fileName = path.posix.basename(normalized);
616
- return path.join(
617
- mediaOriginalsDir,
618
- "archive",
619
- kind,
620
- getArchiveMediaOwnerId(normalized),
621
- fileName,
622
- );
623
- }
624
-
625
- function needsArchiveMediaCopy(destinationPath: string, size: number) {
626
- if (!existsSync(destinationPath)) return true;
627
- return statSync(destinationPath).size !== size;
628
- }
629
-
630
- function copyArchiveEntryToFileEffect(
631
- archivePath: string,
632
- entryPath: string,
633
- destinationPath: string,
634
- ) {
635
- return tryPromise(() => {
636
- mkdirSync(path.dirname(destinationPath), { recursive: true });
637
- const temporaryPath = `${destinationPath}.${process.pid}.${randomUUID()}.tmp`;
638
- const child = spawn("unzip", ["-p", archivePath, entryPath], {
639
- stdio: ["ignore", "pipe", "pipe"],
640
- });
641
- let stderr = "";
642
- child.stderr.setEncoding("utf8");
643
- child.stderr.on("data", (chunk) => {
644
- stderr += String(chunk);
645
- });
646
- const exit = new Promise<number | null>((resolve, reject) => {
647
- child.on("error", reject);
648
- child.on("close", resolve);
649
- });
650
-
651
- return pipeline(child.stdout, createWriteStream(temporaryPath))
652
- .then(() => exit)
653
- .then((exitCode) => {
654
- if (exitCode !== 0) {
655
- throw new Error(
656
- `Failed to extract ${entryPath}: ${
657
- stderr.trim() || `exit ${String(exitCode)}`
658
- }`,
659
- );
660
- }
661
- renameSync(temporaryPath, destinationPath);
662
- })
663
- .catch((error: unknown) => {
664
- child.kill();
665
- if (existsSync(temporaryPath)) {
666
- unlinkSync(temporaryPath);
667
- }
668
- throw error;
669
- });
670
- });
671
- }
672
-
673
- function extractArchiveMediaFilesEffect(
674
- archivePath: string,
675
- selectedKinds: Set<ArchiveMediaKind> | null,
676
- ): Effect.Effect<ArchiveMediaFileCounts, unknown> {
677
- return Effect.gen(function* () {
678
- const counts = createArchiveMediaFileCounts();
679
- if (selectedKinds?.size === 0) {
680
- return counts;
681
- }
682
- const entries = yield* listArchiveEntryDetailsEffect(archivePath);
683
- for (const entry of entries) {
684
- const mediaKind = getArchiveMediaKind(entry.path);
685
- if (!mediaKind) continue;
686
- if (selectedKinds && !selectedKinds.has(mediaKind.kind)) continue;
687
-
688
- counts[mediaKind.kind] += 1;
689
- const destinationPath = getArchiveMediaDestination(
690
- entry.path,
691
- mediaKind.kind,
692
- );
693
- if (!needsArchiveMediaCopy(destinationPath, entry.size)) continue;
694
- yield* copyArchiveEntryToFileEffect(
695
- archivePath,
696
- entry.path,
697
- destinationPath,
698
- );
699
- }
700
- return counts;
701
- });
702
- }
703
-
704
- function getArchiveFollowRow(wrapper: ArchiveRecord, key: ArchiveFollowKey) {
705
- const item = asRecord(wrapper[key]);
706
- const externalUserId = String(item?.accountId ?? "");
707
- if (!externalUserId) return undefined;
708
- return {
709
- profileId: `profile_user_${externalUserId}`,
710
- externalUserId,
711
- };
712
- }
713
-
714
- function importArchiveInternalEffect(
715
- archivePath: string,
716
- options: ImportArchiveOptions = {},
717
- ): Effect.Effect<ImportedArchiveSummary, unknown> {
718
- return Effect.gen(function* () {
719
- const onProgress = options.onProgress ?? (() => {});
720
- const entries = yield* listArchiveEntriesEffect(archivePath);
721
- onProgress({ kind: "scanned", entryCount: entries.length });
722
- const selection = selectedSlices(options);
723
- const includeTweets = includesSlice(selection, "tweets");
724
- const includeLikes = includesSlice(selection, "likes");
725
- const includeBookmarks = includesSlice(selection, "bookmarks");
726
- const includeDirectMessages = includesSlice(selection, "directMessages");
727
- const includeProfiles = includesSlice(selection, "profiles");
728
- const includeFollowers = includesSlice(selection, "followers");
729
- const includeFollowing = includesSlice(selection, "following");
730
- const accountEntry = getFirstEntry(entries, /(?:^|\/)data\/account\.js$/i);
731
- const profileEntry = getFirstEntry(entries, /(?:^|\/)data\/profile\.js$/i);
732
- const tweetEntries = includeTweets
733
- ? getMatchingEntries(
734
- entries,
735
- /(?:^|\/)data\/(?:tweets|community-tweet)(?:-part\d+)?\.js$/i,
736
- )
737
- : [];
738
- const noteTweetEntries = includeTweets
739
- ? getMatchingEntries(
740
- entries,
741
- /(?:^|\/)data\/note-tweet(?:-part\d+)?\.js$/i,
742
- )
743
- : [];
744
- const likeEntries = includeLikes
745
- ? getMatchingEntries(
746
- entries,
747
- /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
748
- )
749
- : [];
750
- const bookmarkEntries = includeBookmarks
751
- ? getMatchingEntries(
752
- entries,
753
- /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
754
- )
755
- : [];
756
- const dmEntries = includeDirectMessages
757
- ? getMatchingEntries(
758
- entries,
759
- /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
760
- )
761
- : [];
762
- const followerEntries = includeFollowers
763
- ? getMatchingEntries(entries, /(?:^|\/)data\/follower(?:-part\d+)?\.js$/i)
764
- : [];
765
- const followingEntries = includeFollowing
766
- ? getMatchingEntries(
767
- entries,
768
- /(?:^|\/)data\/following(?:-part\d+)?\.js$/i,
769
- )
770
- : [];
771
-
772
- if (!accountEntry) {
773
- return yield* Effect.fail(new Error("Archive missing data/account.js"));
774
- }
775
-
776
- const [accountContent, profileContent] = yield* Effect.all([
777
- readArchiveEntryEffect(archivePath, accountEntry),
778
- profileEntry
779
- ? readArchiveEntryEffect(archivePath, profileEntry)
780
- : Effect.succeed("[]"),
781
- ]);
782
-
783
- const accountPayload = buildAccountPayload(
784
- parseArchiveArray(accountContent)[0] ?? null,
785
- parseArchiveArray(profileContent)[0] ?? null,
786
- );
787
- const db = getNativeDb();
788
- const repository = getImportRepository(db);
789
- const plan = new ArchiveImportPlan();
790
- const {
791
- mentionDirectory,
792
- tweets: tweetRows,
793
- collections: collectionRows,
794
- profiles,
795
- conversations,
796
- dmMessages,
797
- followers: followerRows,
798
- following: followingRows,
799
- followerIds,
800
- followingIds,
801
- } = plan;
802
-
803
- if (tweetEntries.length > 0) {
804
- onProgress({
805
- kind: "slice-start",
806
- slice: "tweets",
807
- files: tweetEntries.length,
808
- });
809
- }
810
- for (const [tweetFileIndex, entry] of tweetEntries.entries()) {
811
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (wrapper) => {
812
- const tweet = asRecord(wrapper.tweet);
813
- if (!tweet) return;
814
-
815
- for (const mention of asArray<Record<string, unknown>>(
816
- asRecord(tweet.entities)?.user_mentions,
817
- )) {
818
- const mentionId = String(mention.id_str ?? mention.id ?? "");
819
- if (!mentionId) continue;
820
- mentionDirectory.set(mentionId, {
821
- handle: String(mention.screen_name ?? ""),
822
- displayName: String(
823
- mention.name ?? mention.screen_name ?? mentionId,
824
- ),
825
- });
826
- }
827
-
828
- const replyUserId = String(
829
- tweet.in_reply_to_user_id_str ?? tweet.in_reply_to_user_id ?? "",
830
- );
831
- const replyScreenName = String(tweet.in_reply_to_screen_name ?? "");
832
- if (replyUserId && replyScreenName) {
833
- mentionDirectory.set(replyUserId, {
834
- handle: replyScreenName,
835
- displayName: replyScreenName,
836
- });
837
- }
838
-
839
- plan.addTweet({
840
- id: String(tweet.id_str ?? tweet.id),
841
- kind: "home",
842
- authorProfileId: "profile_me",
843
- text: String(tweet.full_text ?? tweet.text ?? ""),
844
- createdAt: parseTwitterDate(tweet.created_at),
845
- isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
846
- replyToId: tweet.in_reply_to_status_id_str
847
- ? String(tweet.in_reply_to_status_id_str)
848
- : null,
849
- likeCount: toInt(tweet.favorite_count),
850
- mediaCount: getTweetMediaCount(tweet),
851
- bookmarked: 0,
852
- liked: 0,
853
- entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
854
- mediaJson: JSON.stringify(extractTweetMedia(tweet)),
855
- quotedTweetId: tweet.quoted_status_id_str
856
- ? String(tweet.quoted_status_id_str)
857
- : null,
858
- });
859
- });
860
- onProgress({
861
- kind: "slice-file",
862
- slice: "tweets",
863
- processed: tweetFileIndex + 1,
864
- files: tweetEntries.length,
865
- });
866
- }
867
- if (tweetEntries.length > 0) {
868
- onProgress({
869
- kind: "slice-done",
870
- slice: "tweets",
871
- count: tweetRows.length,
872
- });
873
- }
874
-
875
- if (noteTweetEntries.length > 0) {
876
- onProgress({
877
- kind: "slice-start",
878
- slice: "noteTweets",
879
- files: noteTweetEntries.length,
880
- });
881
- }
882
- const tweetRowsBeforeNotes = tweetRows.length;
883
- for (const [noteFileIndex, entry] of noteTweetEntries.entries()) {
884
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (wrapper) => {
885
- const noteTweet = asRecord(wrapper.noteTweet);
886
- if (!noteTweet) return;
887
- const core = asRecord(noteTweet.core);
888
- plan.addTweet({
889
- id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
890
- kind: "home",
891
- authorProfileId: "profile_me",
892
- text: String(core?.text ?? ""),
893
- createdAt: parseTwitterDate(noteTweet.createdAt),
894
- isReplied: 0,
895
- replyToId: null,
896
- likeCount: 0,
897
- mediaCount: 0,
898
- bookmarked: 0,
899
- liked: 0,
900
- entitiesJson: "{}",
901
- mediaJson: "[]",
902
- quotedTweetId: null,
903
- });
904
- });
905
- onProgress({
906
- kind: "slice-file",
907
- slice: "noteTweets",
908
- processed: noteFileIndex + 1,
909
- files: noteTweetEntries.length,
910
- });
911
- }
912
- if (noteTweetEntries.length > 0) {
913
- onProgress({
914
- kind: "slice-done",
915
- slice: "noteTweets",
916
- count: tweetRows.length - tweetRowsBeforeNotes,
917
- });
918
- }
919
- const authoredTweetCount = tweetRows.length;
920
-
921
- type ProfileRow = ArchiveProfileRow;
922
- const defaultProfileMetadata = {
923
- publicMetricsJson: "{}",
924
- location: null,
925
- url: null,
926
- verifiedType: null,
927
- entitiesJson: "{}",
928
- rawJson: "{}",
929
- };
930
- type ExistingProfileRow = {
931
- id: string;
932
- handle: string;
933
- display_name: string;
934
- bio: string;
935
- followers_count: number;
936
- following_count: number;
937
- public_metrics_json: string;
938
- avatar_hue: number;
939
- avatar_url: string | null;
940
- location: string | null;
941
- url: string | null;
942
- verified_type: string | null;
943
- entities_json: string;
944
- raw_json: string;
945
- created_at: string;
946
- };
947
- const existingProfiles = new Map(
948
- repository
949
- .readRows<ExistingProfileRow>(`
950
- select id, handle, display_name, bio, followers_count, following_count,
951
- public_metrics_json, avatar_hue, avatar_url, location, url,
952
- verified_type, entities_json, raw_json, created_at
953
- from profiles
954
- `)
955
- .map((profile) => [profile.id, profile]),
956
- );
957
- const existingProfilesByHandle = new Map(
958
- [...existingProfiles.values()].map((profile) => [
959
- profile.handle.toLowerCase(),
960
- profile,
961
- ]),
962
- );
963
- const existingPrimaryAccount = repository.readRow<{
964
- handle: string;
965
- external_user_id: string | null;
966
- }>(
967
- "select handle, external_user_id from accounts where id = ?",
968
- "acct_primary",
969
- );
970
- const profileIdAliases = new Map<string, string>();
971
-
972
- type ArchiveProfileTier =
973
- | "archive_follow_stub"
974
- | "archive_dm_stub"
975
- | "archive_mention_inferred"
976
- | "live_or_hydrated";
977
- const archiveProfileTierRank: Record<ArchiveProfileTier, number> = {
978
- archive_follow_stub: 0,
979
- archive_dm_stub: 1,
980
- archive_mention_inferred: 2,
981
- live_or_hydrated: 3,
982
- };
983
-
984
- function classifyExistingProfile(profile: ProfileRow): ArchiveProfileTier {
985
- const externalUserId = profile.id.startsWith("profile_user_")
986
- ? profile.id.slice("profile_user_".length)
987
- : "";
988
- const fallbackHandle = externalUserId
989
- ? `id${externalUserId}`
990
- : profile.id;
991
- const hasLiveSignals =
992
- profile.followersCount > 0 ||
993
- profile.followingCount > 0 ||
994
- profile.publicMetricsJson.trim() !== "{}" ||
995
- profile.avatarUrl !== null ||
996
- profile.location !== null ||
997
- profile.url !== null ||
998
- profile.verifiedType !== null ||
999
- profile.entitiesJson.trim() !== "{}" ||
1000
- profile.rawJson.trim() !== "{}";
1001
-
1002
- if (hasLiveSignals) return "live_or_hydrated";
1003
- if (
1004
- profile.handle === fallbackHandle &&
1005
- profile.displayName === "" &&
1006
- profile.bio === ""
1007
- ) {
1008
- return "archive_follow_stub";
1009
- }
1010
- if (profile.bio.startsWith("Imported from archive user ")) {
1011
- return profile.handle === fallbackHandle &&
1012
- profile.displayName === fallbackHandle
1013
- ? "archive_dm_stub"
1014
- : "archive_mention_inferred";
1015
- }
1016
- return profile.handle === fallbackHandle && profile.displayName === ""
1017
- ? "archive_follow_stub"
1018
- : "archive_mention_inferred";
1019
- }
1020
-
1021
- function shouldPreserveProfile(
1022
- existingTier: ArchiveProfileTier,
1023
- incomingTier: ArchiveProfileTier,
1024
- ) {
1025
- return (
1026
- archiveProfileTierRank[existingTier] >=
1027
- archiveProfileTierRank[incomingTier]
1028
- );
1029
- }
1030
-
1031
- function existingProfileToProfileRow(
1032
- profile: ExistingProfileRow,
1033
- ): ProfileRow {
1034
- return {
1035
- id: profile.id,
1036
- handle: profile.handle,
1037
- displayName: profile.display_name,
1038
- bio: profile.bio,
1039
- followersCount: profile.followers_count,
1040
- followingCount: profile.following_count,
1041
- publicMetricsJson: profile.public_metrics_json,
1042
- avatarHue: profile.avatar_hue,
1043
- avatarUrl: profile.avatar_url,
1044
- location: profile.location,
1045
- url: profile.url,
1046
- verifiedType: profile.verified_type,
1047
- entitiesJson: profile.entities_json,
1048
- rawJson: profile.raw_json,
1049
- createdAt: profile.created_at,
1050
- };
1051
- }
1052
-
1053
- function mergeArchiveProfile(incoming: ProfileRow) {
1054
- const existingById = existingProfiles.get(incoming.id);
1055
- const existingByHandle = selection
1056
- ? existingProfilesByHandle.get(incoming.handle.toLowerCase())
1057
- : undefined;
1058
- const targetExisting = existingById ?? existingByHandle;
1059
- const targetId = targetExisting?.id ?? incoming.id;
1060
- if (targetId !== incoming.id) {
1061
- profileIdAliases.set(incoming.id, targetId);
1062
- }
1063
- const targetIncoming =
1064
- targetId === incoming.id ? incoming : { ...incoming, id: targetId };
1065
- const incomingTier = classifyExistingProfile(incoming);
1066
- const current = profiles.get(targetId);
1067
- const currentTier = current ? classifyExistingProfile(current) : null;
1068
- const existingProfile = targetExisting
1069
- ? existingProfileToProfileRow(targetExisting)
1070
- : null;
1071
- const existingTier = existingProfile
1072
- ? classifyExistingProfile(existingProfile)
1073
- : null;
1074
-
1075
- if (
1076
- current &&
1077
- currentTier &&
1078
- shouldPreserveProfile(currentTier, incomingTier) &&
1079
- (!existingTier || shouldPreserveProfile(currentTier, existingTier))
1080
- ) {
1081
- return;
1082
- }
1083
-
1084
- if (
1085
- existingProfile &&
1086
- existingTier &&
1087
- shouldPreserveProfile(existingTier, incomingTier)
1088
- ) {
1089
- profiles.set(targetId, existingProfile);
1090
- return;
1091
- }
1092
-
1093
- profiles.set(targetId, targetIncoming);
1094
- }
1095
-
1096
- function resolveProfileId(profileId: string) {
1097
- return profileIdAliases.get(profileId) ?? profileId;
1098
- }
1099
-
1100
- function isProfileHandleTakenByOtherId(handle: string, profileId: string) {
1101
- const normalizedHandle = handle.toLowerCase();
1102
- const existingProfile = existingProfilesByHandle.get(normalizedHandle);
1103
- if (existingProfile && existingProfile.id !== profileId) return true;
1104
- for (const profile of profiles.values()) {
1105
- if (
1106
- profile.id !== profileId &&
1107
- profile.handle.toLowerCase() === normalizedHandle
1108
- ) {
1109
- return true;
1110
- }
1111
- }
1112
- return false;
1113
- }
1114
-
1115
- function uniqueArchiveProfileHandle(baseHandle: string, profileId: string) {
1116
- if (!isProfileHandleTakenByOtherId(baseHandle, profileId)) {
1117
- return baseHandle;
1118
- }
1119
- let index = 1;
1120
- while (true) {
1121
- const suffix = index === 1 ? "archive" : `archive_${index}`;
1122
- const candidate = `${baseHandle}_${suffix}`;
1123
- if (!isProfileHandleTakenByOtherId(candidate, profileId)) {
1124
- return candidate;
1125
- }
1126
- index += 1;
1127
- }
1128
- }
1129
-
1130
- function addArchiveFollowProfile(
1131
- profileId: string,
1132
- externalUserId: string,
1133
- ) {
1134
- if (!profileId) return;
1135
- const fallbackId =
1136
- externalUserId || profileId.replace(/^profile_user_/, "");
1137
- mergeArchiveProfile({
1138
- id: profileId,
1139
- handle: fallbackId ? `id${fallbackId}` : profileId,
1140
- displayName: "",
1141
- bio: "",
1142
- followersCount: 0,
1143
- followingCount: 0,
1144
- ...defaultProfileMetadata,
1145
- avatarHue: 210,
1146
- avatarUrl: null,
1147
- createdAt: accountPayload.createdAt,
1148
- });
1149
- }
1150
-
1151
- function assertSelectedAccountMatchesArchive() {
1152
- if (!selection || !existingPrimaryAccount) return;
1153
- const existingExternalUserId = existingPrimaryAccount.external_user_id;
1154
- if (
1155
- existingExternalUserId &&
1156
- existingExternalUserId !== accountPayload.accountId
1157
- ) {
1158
- throw new Error(
1159
- `Existing acct_primary (${existingExternalUserId}) does not match archive account ${accountPayload.accountId}`,
1160
- );
1161
- }
1162
- const existingHandle = existingPrimaryAccount.handle
1163
- .replace(/^@/, "")
1164
- .toLowerCase();
1165
- if (
1166
- !existingExternalUserId &&
1167
- existingHandle !== accountPayload.username.toLowerCase()
1168
- ) {
1169
- throw new Error(
1170
- `Existing acct_primary (@${existingHandle}) does not match archive account @${accountPayload.username}`,
1171
- );
1172
- }
1173
- }
1174
-
1175
- assertSelectedAccountMatchesArchive();
1176
-
1177
- const existingLocalProfile =
1178
- selection &&
1179
- (existingProfiles.get("profile_me") ??
1180
- [...existingProfiles.values()].find(
1181
- (profile) =>
1182
- profile.handle.toLowerCase() ===
1183
- accountPayload.username.toLowerCase(),
1184
- ));
1185
- const archivedLocalProfile = existingLocalProfile
1186
- ? {
1187
- ...existingProfileToProfileRow(existingLocalProfile),
1188
- handle: accountPayload.username,
1189
- displayName: accountPayload.displayName,
1190
- bio: accountPayload.bio,
1191
- createdAt: accountPayload.createdAt,
1192
- }
1193
- : {
1194
- id: "profile_me",
1195
- handle: accountPayload.username,
1196
- displayName: accountPayload.displayName,
1197
- bio: accountPayload.bio,
1198
- followersCount: 0,
1199
- followingCount: 0,
1200
- ...defaultProfileMetadata,
1201
- avatarHue: 18,
1202
- avatarUrl: null,
1203
- createdAt: accountPayload.createdAt,
1204
- };
1205
- const localProfile =
1206
- existingLocalProfile && !includeProfiles
1207
- ? existingProfileToProfileRow(existingLocalProfile)
1208
- : archivedLocalProfile;
1209
- profiles.set(localProfile.id, localProfile);
1210
-
1211
- const existingDmConversationAccounts = new Map(
1212
- (
1213
- getNativeDb()
1214
- .prepare("select id, account_id from dm_conversations")
1215
- .all() as Array<{ id: string; account_id: string }>
1216
- ).map((row) => [row.id, row.account_id]),
1217
- );
1218
- const existingOtherDmMessageIds = new Set(
1219
- (
1220
- getNativeDb()
1221
- .prepare(
1222
- `
1223
- select m.id
1224
- from dm_messages m
1225
- join dm_conversations c on c.id = m.conversation_id
1226
- where c.account_id <> 'acct_primary'
1227
- `,
1228
- )
1229
- .all() as Array<{ id: string }>
1230
- ).map((row) => row.id),
1231
- );
1232
- const archiveDmConversationIdAliases = new Map<string, string>();
1233
- const archiveDmMessageIdAliases = new Map<string, string>();
1234
-
1235
- function uniquePrimaryArchiveId(
1236
- baseId: string,
1237
- isTakenByOtherAccount: (candidate: string) => boolean,
1238
- isPending: (candidate: string) => boolean,
1239
- ) {
1240
- let index = 1;
1241
- while (true) {
1242
- const suffix = index === 1 ? "" : `:${index}`;
1243
- const candidate = `acct_primary:${baseId}${suffix}`;
1244
- if (!isTakenByOtherAccount(candidate) && !isPending(candidate)) {
1245
- return candidate;
1246
- }
1247
- index += 1;
1248
- }
1249
- }
1250
-
1251
- function resolveArchiveDmConversationId(conversationId: string) {
1252
- const existingAlias = archiveDmConversationIdAliases.get(conversationId);
1253
- if (existingAlias) return existingAlias;
1254
- if (!selection) {
1255
- archiveDmConversationIdAliases.set(conversationId, conversationId);
1256
- return conversationId;
1257
- }
1258
-
1259
- const takenByOtherAccount = (candidate: string) => {
1260
- const accountId = existingDmConversationAccounts.get(candidate);
1261
- return accountId !== undefined && accountId !== "acct_primary";
1262
- };
1263
- const resolved = takenByOtherAccount(conversationId)
1264
- ? uniquePrimaryArchiveId(
1265
- conversationId,
1266
- takenByOtherAccount,
1267
- (candidate) => conversations.has(candidate),
1268
- )
1269
- : conversationId;
1270
- archiveDmConversationIdAliases.set(conversationId, resolved);
1271
- return resolved;
1272
- }
1273
-
1274
- function resolveArchiveDmMessageId(
1275
- messageId: string,
1276
- conversationIdChanged: boolean,
1277
- ) {
1278
- const existingAlias = archiveDmMessageIdAliases.get(messageId);
1279
- if (existingAlias) return existingAlias;
1280
- const shouldRemap =
1281
- selection &&
1282
- (conversationIdChanged || existingOtherDmMessageIds.has(messageId));
1283
- const resolved = shouldRemap
1284
- ? uniquePrimaryArchiveId(
1285
- messageId,
1286
- (candidate) => existingOtherDmMessageIds.has(candidate),
1287
- (candidate) =>
1288
- dmMessages.some((message) => message.id === candidate),
1289
- )
1290
- : messageId;
1291
- archiveDmMessageIdAliases.set(messageId, resolved);
1292
- return resolved;
1293
- }
1294
-
1295
- if (dmEntries.length > 0) {
1296
- onProgress({
1297
- kind: "slice-start",
1298
- slice: "directMessages",
1299
- files: dmEntries.length,
1300
- });
1301
- }
1302
- for (const [dmFileIndex, entry] of dmEntries.entries()) {
1303
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (wrapper) => {
1304
- const dmConversation = asRecord(wrapper.dmConversation);
1305
- if (!dmConversation) return;
1306
-
1307
- const rawConversationId = String(dmConversation.conversationId ?? "");
1308
- if (!rawConversationId) return;
1309
- const conversationId =
1310
- resolveArchiveDmConversationId(rawConversationId);
1311
- const conversationIdChanged = conversationId !== rawConversationId;
1312
-
1313
- const conversationName = String(dmConversation.name ?? "").trim();
1314
- const participantIds = new Set<string>();
1315
- const rawMessages = asArray<Record<string, unknown>>(
1316
- dmConversation.messages,
1317
- );
1318
-
1319
- for (const event of rawMessages) {
1320
- const messageCreate = asRecord(event.messageCreate);
1321
- if (messageCreate) {
1322
- const senderId = String(messageCreate.senderId ?? "");
1323
- const recipientId = String(messageCreate.recipientId ?? "");
1324
- if (senderId) participantIds.add(senderId);
1325
- if (recipientId) participantIds.add(recipientId);
1326
- }
1327
-
1328
- const joinConversation = asRecord(event.joinConversation);
1329
- if (joinConversation) {
1330
- for (const userId of asArray<string>(
1331
- joinConversation.participantsSnapshot,
1332
- )) {
1333
- participantIds.add(String(userId));
1334
- }
1335
- }
1336
-
1337
- const participantsJoin = asRecord(event.participantsJoin);
1338
- if (participantsJoin) {
1339
- for (const userId of asArray<string>(participantsJoin.userIds)) {
1340
- participantIds.add(String(userId));
1341
- }
1342
- const initiatingUserId = String(
1343
- participantsJoin.initiatingUserId ?? "",
1344
- );
1345
- if (initiatingUserId) {
1346
- participantIds.add(initiatingUserId);
1347
- }
1348
- }
1349
-
1350
- const participantsLeave = asRecord(event.participantsLeave);
1351
- if (participantsLeave) {
1352
- for (const userId of asArray<string>(participantsLeave.userIds)) {
1353
- participantIds.add(String(userId));
1354
- }
1355
- const initiatingUserId = String(
1356
- participantsLeave.initiatingUserId ?? "",
1357
- );
1358
- if (initiatingUserId) {
1359
- participantIds.add(initiatingUserId);
1360
- }
1361
- }
1362
- }
1363
-
1364
- const externalParticipantIds = [...participantIds].filter(
1365
- (userId) => userId && userId !== accountPayload.accountId,
1366
- );
1367
- const isGroup =
1368
- conversationName.length > 0 || externalParticipantIds.length > 1;
1369
- const participantProfileId = isGroup
1370
- ? `profile_group_${conversationId}`
1371
- : `profile_user_${externalParticipantIds[0] ?? conversationId}`;
1372
-
1373
- if (!profiles.has(participantProfileId)) {
1374
- if (isGroup) {
1375
- profiles.set(participantProfileId, {
1376
- id: participantProfileId,
1377
- handle: `group-${conversationId}`,
1378
- displayName:
1379
- conversationName || `Group DM ${externalParticipantIds.length}`,
1380
- bio: `Group DM with ${externalParticipantIds.length} participants`,
1381
- followersCount: 0,
1382
- followingCount: 0,
1383
- ...defaultProfileMetadata,
1384
- avatarHue: 220,
1385
- avatarUrl: null,
1386
- createdAt: accountPayload.createdAt,
1387
- });
1388
- } else {
1389
- const otherUserId = externalParticipantIds[0] ?? conversationId;
1390
- const inferred = inferProfileFromDirectory(
1391
- otherUserId,
1392
- mentionDirectory,
1393
- );
1394
- mergeArchiveProfile({
1395
- id: participantProfileId,
1396
- handle: inferred.handle,
1397
- displayName: inferred.displayName,
1398
- bio: `Imported from archive user ${otherUserId}`,
1399
- followersCount: 0,
1400
- followingCount: 0,
1401
- ...defaultProfileMetadata,
1402
- avatarHue: 210,
1403
- avatarUrl: null,
1404
- createdAt: accountPayload.createdAt,
1405
- });
1406
- }
1407
- }
1408
-
1409
- const messageEvents = rawMessages
1410
- .map((event) => asRecord(event.messageCreate))
1411
- .filter((event): event is Record<string, unknown> => event !== null)
1412
- .map((messageCreate) => {
1413
- const senderId = String(messageCreate.senderId ?? "");
1414
- const rawMessageId = String(
1415
- messageCreate.id ?? `${rawConversationId}-${senderId}`,
1416
- );
1417
- const senderProfileId =
1418
- senderId === accountPayload.accountId
1419
- ? localProfile.id
1420
- : `profile_user_${senderId}`;
1421
-
1422
- if (senderId && senderId !== accountPayload.accountId) {
1423
- const inferred = inferProfileFromDirectory(
1424
- senderId,
1425
- mentionDirectory,
1426
- );
1427
- if (!profiles.has(senderProfileId)) {
1428
- mergeArchiveProfile({
1429
- id: senderProfileId,
1430
- handle: inferred.handle,
1431
- displayName: inferred.displayName,
1432
- bio: `Imported from archive user ${senderId}`,
1433
- followersCount: 0,
1434
- followingCount: 0,
1435
- ...defaultProfileMetadata,
1436
- avatarHue: 240,
1437
- avatarUrl: null,
1438
- createdAt: accountPayload.createdAt,
1439
- });
1440
- }
1441
- }
1442
-
1443
- return {
1444
- id: resolveArchiveDmMessageId(
1445
- rawMessageId,
1446
- conversationIdChanged,
1447
- ),
1448
- conversationId,
1449
- senderProfileId: resolveProfileId(senderProfileId),
1450
- text: String(messageCreate.text ?? ""),
1451
- createdAt: parseTwitterDate(messageCreate.createdAt),
1452
- direction:
1453
- senderId === accountPayload.accountId ? "outbound" : "inbound",
1454
- mediaCount: asArray(messageCreate.mediaUrls).length,
1455
- } satisfies ArchiveMessageRow;
1456
- })
1457
- .sort((left, right) =>
1458
- compareIsoTimestamp(left.createdAt, right.createdAt),
1459
- );
1460
-
1461
- if (messageEvents.length === 0) {
1462
- return;
1463
- }
1464
-
1465
- const lastMessage = messageEvents.at(-1);
1466
- if (!lastMessage) return;
1467
-
1468
- dmMessages.push(...messageEvents);
1469
- const resolvedParticipantProfileId =
1470
- resolveProfileId(participantProfileId);
1471
- conversations.set(conversationId, {
1472
- id: conversationId,
1473
- title:
1474
- profiles.get(resolvedParticipantProfileId)?.displayName ||
1475
- conversationName ||
1476
- conversationId,
1477
- accountId: "acct_primary",
1478
- participantProfileId: resolvedParticipantProfileId,
1479
- lastMessageAt: lastMessage.createdAt,
1480
- unreadCount: 0,
1481
- needsReply: lastMessage.direction === "inbound" ? 1 : 0,
1482
- });
1483
- });
1484
- onProgress({
1485
- kind: "slice-file",
1486
- slice: "directMessages",
1487
- processed: dmFileIndex + 1,
1488
- files: dmEntries.length,
1489
- });
1490
- }
1491
- if (dmEntries.length > 0) {
1492
- onProgress({
1493
- kind: "slice-done",
1494
- slice: "directMessages",
1495
- count: dmMessages.length,
1496
- });
1497
- }
1498
-
1499
- if (likeEntries.length > 0) {
1500
- onProgress({
1501
- kind: "slice-start",
1502
- slice: "likes",
1503
- files: likeEntries.length,
1504
- });
1505
- }
1506
- let likeCount = 0;
1507
- for (const [likeFileIndex, entry] of likeEntries.entries()) {
1508
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (like) => {
1509
- likeCount += 1;
1510
- const tweet = extractCollectionTweet(like, "like");
1511
- if (!tweet) return;
1512
- collectionRows.push({
1513
- tweetId: tweet.id,
1514
- kind: "likes",
1515
- collectedAt: tweet.createdAt,
1516
- source: "archive",
1517
- rawJson: JSON.stringify(like),
1518
- });
1519
- plan.addTweet({
1520
- id: tweet.id,
1521
- kind: "like",
1522
- authorProfileId: "profile_unknown",
1523
- text: tweet.text,
1524
- createdAt: tweet.createdAt,
1525
- isReplied: 0,
1526
- replyToId: null,
1527
- likeCount: tweet.likeCount,
1528
- mediaCount: 0,
1529
- bookmarked: 0,
1530
- liked: 1,
1531
- entitiesJson: "{}",
1532
- mediaJson: "[]",
1533
- quotedTweetId: null,
1534
- });
1535
- });
1536
- onProgress({
1537
- kind: "slice-file",
1538
- slice: "likes",
1539
- processed: likeFileIndex + 1,
1540
- files: likeEntries.length,
1541
- });
1542
- }
1543
- if (likeEntries.length > 0) {
1544
- onProgress({ kind: "slice-done", slice: "likes", count: likeCount });
1545
- }
1546
-
1547
- if (bookmarkEntries.length > 0) {
1548
- onProgress({
1549
- kind: "slice-start",
1550
- slice: "bookmarks",
1551
- files: bookmarkEntries.length,
1552
- });
1553
- }
1554
- let bookmarkCount = 0;
1555
- for (const [bookmarkFileIndex, entry] of bookmarkEntries.entries()) {
1556
- yield* processArchiveEntryRecordsEffect(
1557
- archivePath,
1558
- entry,
1559
- (bookmark) => {
1560
- bookmarkCount += 1;
1561
- const tweet = extractCollectionTweet(bookmark, "bookmark");
1562
- if (!tweet) return;
1563
- collectionRows.push({
1564
- tweetId: tweet.id,
1565
- kind: "bookmarks",
1566
- collectedAt: tweet.createdAt,
1567
- source: "archive",
1568
- rawJson: JSON.stringify(bookmark),
1569
- });
1570
- plan.addTweet({
1571
- id: tweet.id,
1572
- kind: "bookmark",
1573
- authorProfileId: "profile_unknown",
1574
- text: tweet.text,
1575
- createdAt: tweet.createdAt,
1576
- isReplied: 0,
1577
- replyToId: null,
1578
- likeCount: tweet.likeCount,
1579
- mediaCount: 0,
1580
- bookmarked: 1,
1581
- liked: 0,
1582
- entitiesJson: "{}",
1583
- mediaJson: "[]",
1584
- quotedTweetId: null,
1585
- });
1586
- },
1587
- );
1588
- onProgress({
1589
- kind: "slice-file",
1590
- slice: "bookmarks",
1591
- processed: bookmarkFileIndex + 1,
1592
- files: bookmarkEntries.length,
1593
- });
1594
- }
1595
- if (bookmarkEntries.length > 0) {
1596
- onProgress({
1597
- kind: "slice-done",
1598
- slice: "bookmarks",
1599
- count: bookmarkCount,
1600
- });
1601
- }
1602
-
1603
- onProgress({ kind: "slice-start", slice: "media", files: 0 });
1604
- const mediaFileCounts = yield* extractArchiveMediaFilesEffect(
1605
- archivePath,
1606
- selectedArchiveMediaKinds(selection),
1607
- );
1608
- onProgress({
1609
- kind: "slice-done",
1610
- slice: "media",
1611
- count: Object.values(mediaFileCounts).reduce(
1612
- (total, value) => total + value,
1613
- 0,
1614
- ),
1615
- });
1616
-
1617
- if (followerEntries.length > 0) {
1618
- onProgress({
1619
- kind: "slice-start",
1620
- slice: "followers",
1621
- files: followerEntries.length,
1622
- });
1623
- }
1624
- for (const [followerFileIndex, entry] of followerEntries.entries()) {
1625
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (wrapper) => {
1626
- const row = getArchiveFollowRow(wrapper, "follower");
1627
- if (!row || followerIds.has(row.externalUserId)) return;
1628
- followerIds.add(row.externalUserId);
1629
- followerRows.push(row);
1630
- });
1631
- onProgress({
1632
- kind: "slice-file",
1633
- slice: "followers",
1634
- processed: followerFileIndex + 1,
1635
- files: followerEntries.length,
1636
- });
1637
- }
1638
- if (followerEntries.length > 0) {
1639
- onProgress({
1640
- kind: "slice-done",
1641
- slice: "followers",
1642
- count: followerRows.length,
1643
- });
1644
- }
1645
-
1646
- if (followingEntries.length > 0) {
1647
- onProgress({
1648
- kind: "slice-start",
1649
- slice: "following",
1650
- files: followingEntries.length,
1651
- });
1652
- }
1653
- for (const [followingFileIndex, entry] of followingEntries.entries()) {
1654
- yield* processArchiveEntryRecordsEffect(archivePath, entry, (wrapper) => {
1655
- const row = getArchiveFollowRow(wrapper, "following");
1656
- if (!row || followingIds.has(row.externalUserId)) return;
1657
- followingIds.add(row.externalUserId);
1658
- followingRows.push(row);
1659
- });
1660
- onProgress({
1661
- kind: "slice-file",
1662
- slice: "following",
1663
- processed: followingFileIndex + 1,
1664
- files: followingEntries.length,
1665
- });
1666
- }
1667
- if (followingEntries.length > 0) {
1668
- onProgress({
1669
- kind: "slice-done",
1670
- slice: "following",
1671
- count: followingRows.length,
1672
- });
1673
- }
1674
-
1675
- for (const row of [...followerRows, ...followingRows]) {
1676
- addArchiveFollowProfile(row.profileId, row.externalUserId);
1677
- }
1678
-
1679
- const clearedFollowDirections = new Set<ArchiveFollowDirection>();
1680
- if (includeFollowers && followerEntries.length === 0) {
1681
- clearedFollowDirections.add("followers");
1682
- }
1683
- if (includeFollowing && followingEntries.length === 0) {
1684
- clearedFollowDirections.add("following");
1685
- }
1686
- const retainedFollowProfiles = repository.readRows<{
1687
- direction: ArchiveFollowDirection;
1688
- profile_id: string;
1689
- external_user_id: string;
1690
- source: string | null;
1691
- snapshot_id: string | null;
1692
- snapshot_source: string | null;
1693
- }>(`
1694
- select direction, profile_id, external_user_id, source, null as snapshot_id, null as snapshot_source
1695
- from follow_edges
1696
- union
1697
- select ev.direction, ev.profile_id, ev.external_user_id, null as source, ev.snapshot_id, snap.source as snapshot_source
1698
- from follow_events ev
1699
- left join follow_snapshots snap on snap.id = ev.snapshot_id
1700
- `);
1701
- for (const row of retainedFollowProfiles) {
1702
- const isClearedArchiveRow =
1703
- clearedFollowDirections.has(row.direction) &&
1704
- (row.source === "archive" ||
1705
- row.snapshot_source === "archive" ||
1706
- row.snapshot_id ===
1707
- `follow_snapshot_archive_acct_primary_${row.direction}`);
1708
- if (isClearedArchiveRow) continue;
1709
- addArchiveFollowProfile(row.profile_id, row.external_user_id);
1710
- }
1711
-
1712
- if (
1713
- tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")
1714
- ) {
1715
- const unknownProfile = {
1716
- id: "profile_unknown",
1717
- handle: selection
1718
- ? uniqueArchiveProfileHandle("unknown", "profile_unknown")
1719
- : "unknown",
1720
- displayName: "Unknown",
1721
- bio: "Imported from archive collection metadata",
1722
- followersCount: 0,
1723
- followingCount: 0,
1724
- ...defaultProfileMetadata,
1725
- avatarHue: 210,
1726
- avatarUrl: null,
1727
- createdAt: accountPayload.createdAt,
1728
- };
1729
- const existingUnknownProfile = existingProfiles.get("profile_unknown");
1730
- profiles.set(
1731
- "profile_unknown",
1732
- existingUnknownProfile
1733
- ? existingProfileToProfileRow(existingUnknownProfile)
1734
- : unknownProfile,
1735
- );
1736
- }
1737
-
1738
- const insertAccount = db.prepare(`
1739
- insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1740
- values (?, ?, ?, ?, ?, 1, ?)
1741
- on conflict(id) do update set
1742
- name = excluded.name,
1743
- handle = excluded.handle,
1744
- external_user_id = excluded.external_user_id,
1745
- transport = excluded.transport,
1746
- is_default = 1,
1747
- created_at = excluded.created_at
1748
- `);
1749
- const insertAccountIfMissing = db.prepare(`
1750
- insert or ignore into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
1751
- values (?, ?, ?, ?, ?, 1, ?)
1752
- `);
1753
- const insertProfile = db.prepare(`
1754
- insert into profiles (
1755
- id, handle, display_name, bio, followers_count, following_count,
1756
- public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
1757
- entities_json, raw_json, created_at
1758
- )
1759
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1760
- on conflict(id) do update set
1761
- handle = excluded.handle,
1762
- display_name = excluded.display_name,
1763
- bio = excluded.bio,
1764
- followers_count = excluded.followers_count,
1765
- following_count = excluded.following_count,
1766
- public_metrics_json = excluded.public_metrics_json,
1767
- avatar_hue = excluded.avatar_hue,
1768
- avatar_url = excluded.avatar_url,
1769
- location = excluded.location,
1770
- url = excluded.url,
1771
- verified_type = excluded.verified_type,
1772
- entities_json = excluded.entities_json,
1773
- raw_json = excluded.raw_json,
1774
- created_at = excluded.created_at
1775
- `);
1776
- const insertProfileIfMissing = db.prepare(`
1777
- insert or ignore into profiles (
1778
- id, handle, display_name, bio, followers_count, following_count,
1779
- public_metrics_json, avatar_hue, avatar_url, location, url, verified_type,
1780
- entities_json, raw_json, created_at
1781
- )
1782
- values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1783
- `);
1784
- const insertTweet = db.prepare(`
1785
- insert into tweets (
1786
- id, account_id, author_profile_id, kind, text, created_at, is_replied,
1787
- reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
1788
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1789
- on conflict(id) do update set
1790
- account_id = tweets.account_id,
1791
- author_profile_id = case
1792
- when tweets.author_profile_id = 'profile_unknown' then excluded.author_profile_id
1793
- else tweets.author_profile_id
1794
- end,
1795
- kind = case
1796
- when tweets.kind in ('home', 'mention', 'authored') and excluded.kind in ('like', 'bookmark')
1797
- then tweets.kind
1798
- else excluded.kind
1799
- end,
1800
- text = case
1801
- when excluded.kind in ('like', 'bookmark')
1802
- and tweets.text <> ''
1803
- then tweets.text
1804
- when excluded.text <> '' then excluded.text
1805
- else tweets.text
1806
- end,
1807
- created_at = case
1808
- when excluded.kind in ('like', 'bookmark')
1809
- then tweets.created_at
1810
- else excluded.created_at
1811
- end,
1812
- is_replied = max(tweets.is_replied, excluded.is_replied),
1813
- reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
1814
- like_count = max(tweets.like_count, excluded.like_count),
1815
- media_count = max(tweets.media_count, excluded.media_count),
1816
- bookmarked = case
1817
- when tweets.account_id = excluded.account_id then max(tweets.bookmarked, excluded.bookmarked)
1818
- else tweets.bookmarked
1819
- end,
1820
- liked = case
1821
- when tweets.account_id = excluded.account_id then max(tweets.liked, excluded.liked)
1822
- else tweets.liked
1823
- end,
1824
- entities_json = case when excluded.entities_json <> '{}' then excluded.entities_json else tweets.entities_json end,
1825
- media_json = case when excluded.media_json <> '[]' then excluded.media_json else tweets.media_json end,
1826
- quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id)
1827
- `);
1828
- const deleteTweetFts = db.prepare(
1829
- "delete from tweets_fts where tweet_id = ?",
1830
- );
1831
- const insertTweetFts = db.prepare(
1832
- "insert into tweets_fts (tweet_id, text) values (?, ?)",
1833
- );
1834
- const selectTweetFtsText = db.prepare(
1835
- "select text from tweets where id = ?",
1836
- );
1837
- const insertTimelineEdge = db.prepare(`
1838
- insert into tweet_account_edges (
1839
- account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count, source,
1840
- raw_json, updated_at
1841
- ) values (?, ?, ?, ?, ?, 1, 'archive', '{}', ?)
1842
- on conflict(account_id, tweet_id, kind) do update set
1843
- first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
1844
- last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
1845
- updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
1846
- `);
1847
- const insertCollection = db.prepare(`
1848
- insert into tweet_collections (
1849
- account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
1850
- ) values (?, ?, ?, ?, ?, ?, ?)
1851
- on conflict(account_id, tweet_id, kind) do update set
1852
- collected_at = coalesce(excluded.collected_at, tweet_collections.collected_at),
1853
- source = case
1854
- when tweet_collections.source = 'archive' then excluded.source
1855
- else tweet_collections.source
1856
- end,
1857
- raw_json = case
1858
- when tweet_collections.source = 'archive' then excluded.raw_json
1859
- else tweet_collections.raw_json
1860
- end,
1861
- updated_at = max(tweet_collections.updated_at, excluded.updated_at)
1862
- `);
1863
- const insertConversation = db.prepare(`
1864
- insert into dm_conversations (
1865
- id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
1866
- ) values (?, ?, ?, ?, ?, ?, ?)
1867
- `);
1868
- const insertMessage = db.prepare(`
1869
- insert into dm_messages (
1870
- id, conversation_id, sender_profile_id, text, created_at, direction, is_replied, media_count
1871
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1872
- `);
1873
- const insertDmFts = db.prepare(
1874
- "insert into dm_fts (message_id, text) values (?, ?)",
1875
- );
1876
- const insertFollowSnapshot = db.prepare(`
1877
- insert into follow_snapshots (
1878
- id, account_id, direction, source, status, page_count, result_count,
1879
- started_at, completed_at, raw_meta_json
1880
- ) values (?, ?, ?, 'archive', 'complete', ?, ?, ?, ?, ?)
1881
- on conflict(id) do update set
1882
- account_id = excluded.account_id,
1883
- direction = excluded.direction,
1884
- source = excluded.source,
1885
- status = excluded.status,
1886
- page_count = excluded.page_count,
1887
- result_count = excluded.result_count,
1888
- started_at = excluded.started_at,
1889
- completed_at = excluded.completed_at,
1890
- raw_meta_json = excluded.raw_meta_json
1891
- `);
1892
- const insertFollowSnapshotMember = db.prepare(`
1893
- insert into follow_snapshot_members (
1894
- snapshot_id, profile_id, external_user_id, position
1895
- ) values (?, ?, ?, ?)
1896
- `);
1897
- const selectFollowSnapshotMembers = db.prepare(`
1898
- select profile_id, external_user_id
1899
- from follow_snapshot_members
1900
- where snapshot_id = ?
1901
- order by position, profile_id
1902
- `);
1903
- const deleteFollowSnapshotMembers = db.prepare(
1904
- "delete from follow_snapshot_members where snapshot_id = ?",
1905
- );
1906
- const deleteArchiveFollowEvents = db.prepare(`
1907
- delete from follow_events
1908
- where account_id = ? and direction = ? and (
1909
- snapshot_id = ? or snapshot_id in (
1910
- select id from follow_snapshots
1911
- where account_id = ? and direction = ? and source = 'archive'
1912
- )
1913
- )
1914
- `);
1915
- const deleteArchiveFollowSnapshotMembers = db.prepare(`
1916
- delete from follow_snapshot_members
1917
- where snapshot_id in (
1918
- select id from follow_snapshots
1919
- where account_id = ? and direction = ? and source = 'archive'
1920
- )
1921
- `);
1922
- const deleteArchiveFollowSnapshots = db.prepare(`
1923
- delete from follow_snapshots
1924
- where account_id = ? and direction = ? and source = 'archive'
1925
- `);
1926
- const deleteArchiveFollowEdges = db.prepare(`
1927
- delete from follow_edges
1928
- where account_id = ? and direction = ? and source = 'archive'
1929
- `);
1930
- const selectFollowEdges = db.prepare(`
1931
- select profile_id, external_user_id, current
1932
- from follow_edges
1933
- where account_id = ? and direction = ?
1934
- `);
1935
- const insertFollowEdge = db.prepare(`
1936
- insert into follow_edges (
1937
- account_id, direction, profile_id, external_user_id, source, current,
1938
- first_seen_at, last_seen_at, ended_at, updated_at
1939
- ) values (?, ?, ?, ?, 'archive', 1, ?, ?, null, ?)
1940
- on conflict(account_id, direction, profile_id) do update set
1941
- external_user_id = excluded.external_user_id,
1942
- source = case
1943
- when follow_edges.source = 'archive' then excluded.source
1944
- else follow_edges.source
1945
- end,
1946
- current = 1,
1947
- last_seen_at = excluded.last_seen_at,
1948
- ended_at = null,
1949
- updated_at = excluded.updated_at
1950
- `);
1951
- const endFollowEdge = db.prepare(`
1952
- update follow_edges
1953
- set current = 0, ended_at = ?, updated_at = ?
1954
- where account_id = ? and direction = ? and profile_id = ?
1955
- `);
1956
- const insertFollowEvent = db.prepare(`
1957
- insert into follow_events (
1958
- id, account_id, direction, profile_id, external_user_id, kind, event_at, snapshot_id
1959
- ) values (?, ?, ?, ?, ?, ?, ?, ?)
1960
- `);
1961
- const clearSelectedLikes = db.prepare(`
1962
- delete from tweet_collections
1963
- where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1964
- `);
1965
- const clearSelectedBookmarks = db.prepare(`
1966
- delete from tweet_collections
1967
- where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1968
- `);
1969
- const clearTweetLikedFlag = db.prepare(`
1970
- update tweets
1971
- set liked = 0
1972
- where account_id = ?
1973
- and id in (
1974
- select tweet_id
1975
- from tweet_collections
1976
- where account_id = ? and kind = 'likes' and source in ('archive', 'legacy')
1977
- )
1978
- `);
1979
- const clearTweetBookmarkedFlag = db.prepare(`
1980
- update tweets
1981
- set bookmarked = 0
1982
- where account_id = ?
1983
- and id in (
1984
- select tweet_id
1985
- from tweet_collections
1986
- where account_id = ? and kind = 'bookmarks' and source in ('archive', 'legacy')
1987
- )
1988
- `);
1989
- const clearSelectedArchiveTweetEdges = db.prepare(`
1990
- delete from tweet_account_edges
1991
- where account_id = ?
1992
- and kind in ('home', 'authored')
1993
- and (
1994
- source = 'archive'
1995
- or (
1996
- source = 'legacy'
1997
- and exists (
1998
- select 1
1999
- from tweets
2000
- where tweets.id = tweet_account_edges.tweet_id
2001
- and tweets.account_id = ?
2002
- and tweets.author_profile_id = ?
2003
- )
2004
- )
2005
- )
2006
- `);
2007
- const deleteOrphanTweetLinkOccurrences = db.prepare(`
2008
- delete from link_occurrences
2009
- where source_kind = 'tweet'
2010
- and source_id not in (select id from tweets)
2011
- `);
2012
- const deleteOrphanArchiveCollectionTweets = db.prepare(`
2013
- delete from tweets
2014
- where account_id = ?
2015
- and kind in ('like', 'bookmark')
2016
- and not exists (
2017
- select 1
2018
- from tweet_collections collection
2019
- where collection.tweet_id = tweets.id
2020
- )
2021
- and not exists (
2022
- select 1
2023
- from tweet_account_edges edge
2024
- where edge.tweet_id = tweets.id
2025
- )
2026
- and not exists (
2027
- select 1
2028
- from tweets referencing_tweet
2029
- where referencing_tweet.reply_to_id = tweets.id
2030
- or referencing_tweet.quoted_tweet_id = tweets.id
2031
- )
2032
- `);
2033
- const demoteSelectedArchiveTweetsWithCollections = db.prepare(`
2034
- update tweets
2035
- set kind = case
2036
- when exists (
2037
- select 1
2038
- from tweet_collections collection
2039
- where collection.account_id = ?
2040
- and collection.tweet_id = tweets.id
2041
- and collection.kind = 'likes'
2042
- ) then 'like'
2043
- when exists (
2044
- select 1
2045
- from tweet_collections collection
2046
- where collection.account_id = ?
2047
- and collection.tweet_id = tweets.id
2048
- and collection.kind = 'bookmarks'
2049
- ) then 'bookmark'
2050
- else kind
2051
- end
2052
- where account_id = ?
2053
- and id in (
2054
- select tweet_id
2055
- from tweet_account_edges edge
2056
- join tweets edge_tweet on edge_tweet.id = edge.tweet_id
2057
- where edge.account_id = ?
2058
- and edge.kind in ('home', 'authored')
2059
- and (
2060
- edge.source = 'archive'
2061
- or (
2062
- edge.source = 'legacy'
2063
- and edge_tweet.account_id = ?
2064
- and edge_tweet.author_profile_id = ?
2065
- )
2066
- )
2067
- )
2068
- and id in (
2069
- select tweet_id
2070
- from tweet_collections
2071
- where account_id = ?
2072
- )
2073
- `);
2074
- const preserveSelectedArchiveTweetsReferencedElsewhere = db.prepare(`
2075
- update tweets
2076
- set kind = 'archive_stale'
2077
- where account_id = ?
2078
- and id in (
2079
- select tweet_id
2080
- from tweet_account_edges edge
2081
- join tweets edge_tweet on edge_tweet.id = edge.tweet_id
2082
- where edge.account_id = ?
2083
- and edge.kind in ('home', 'authored')
2084
- and (
2085
- edge.source = 'archive'
2086
- or (
2087
- edge.source = 'legacy'
2088
- and edge_tweet.account_id = ?
2089
- and edge_tweet.author_profile_id = ?
2090
- )
2091
- )
2092
- )
2093
- and id not in (
2094
- select tweet_id
2095
- from tweet_collections
2096
- where account_id = ?
2097
- )
2098
- and exists (
2099
- select 1
2100
- from tweet_account_edges edge
2101
- where edge.tweet_id = tweets.id
2102
- and not (
2103
- edge.account_id = ?
2104
- and edge.kind in ('home', 'authored')
2105
- and (
2106
- edge.source = 'archive'
2107
- or (
2108
- edge.source = 'legacy'
2109
- and tweets.account_id = ?
2110
- and tweets.author_profile_id = ?
2111
- )
2112
- )
2113
- )
2114
- union all
2115
- select 1
2116
- from tweet_collections collection
2117
- where collection.tweet_id = tweets.id
2118
- union all
2119
- select 1
2120
- from tweets referencing_tweet
2121
- where (
2122
- referencing_tweet.reply_to_id = tweets.id
2123
- or referencing_tweet.quoted_tweet_id = tweets.id
2124
- )
2125
- and (
2126
- exists (
2127
- select 1
2128
- from tweet_collections collection
2129
- where collection.tweet_id = referencing_tweet.id
2130
- )
2131
- or exists (
2132
- select 1
2133
- from tweet_account_edges edge
2134
- where edge.tweet_id = referencing_tweet.id
2135
- and not (
2136
- edge.account_id = ?
2137
- and edge.kind in ('home', 'authored')
2138
- and (
2139
- edge.source = 'archive'
2140
- or (
2141
- edge.source = 'legacy'
2142
- and referencing_tweet.account_id = ?
2143
- and referencing_tweet.author_profile_id = ?
2144
- )
2145
- )
2146
- )
2147
- )
2148
- )
2149
- )
2150
- `);
2151
- const deleteSelectedArchiveTweetsWithoutCollections = db.prepare(`
2152
- delete from tweets
2153
- where account_id = ?
2154
- and id in (
2155
- select tweet_id
2156
- from tweet_account_edges edge
2157
- join tweets edge_tweet on edge_tweet.id = edge.tweet_id
2158
- where edge.account_id = ?
2159
- and edge.kind in ('home', 'authored')
2160
- and (
2161
- edge.source = 'archive'
2162
- or (
2163
- edge.source = 'legacy'
2164
- and edge_tweet.account_id = ?
2165
- and edge_tweet.author_profile_id = ?
2166
- )
2167
- )
2168
- )
2169
- and not exists (
2170
- select 1
2171
- from tweet_collections collection
2172
- where collection.tweet_id = tweets.id
2173
- )
2174
- and not exists (
2175
- select 1
2176
- from tweet_account_edges edge
2177
- where edge.tweet_id = tweets.id
2178
- and not (
2179
- edge.account_id = ?
2180
- and edge.kind in ('home', 'authored')
2181
- and (
2182
- edge.source = 'archive'
2183
- or (
2184
- edge.source = 'legacy'
2185
- and tweets.account_id = ?
2186
- and tweets.author_profile_id = ?
2187
- )
2188
- )
2189
- )
2190
- )
2191
- and not exists (
2192
- select 1
2193
- from tweets referencing_tweet
2194
- where (
2195
- referencing_tweet.reply_to_id = tweets.id
2196
- or referencing_tweet.quoted_tweet_id = tweets.id
2197
- )
2198
- and (
2199
- exists (
2200
- select 1
2201
- from tweet_collections collection
2202
- where collection.tweet_id = referencing_tweet.id
2203
- )
2204
- or exists (
2205
- select 1
2206
- from tweet_account_edges edge
2207
- where edge.tweet_id = referencing_tweet.id
2208
- and not (
2209
- edge.account_id = ?
2210
- and edge.kind in ('home', 'authored')
2211
- and (
2212
- edge.source = 'archive'
2213
- or (
2214
- edge.source = 'legacy'
2215
- and referencing_tweet.account_id = ?
2216
- and referencing_tweet.author_profile_id = ?
2217
- )
2218
- )
2219
- )
2220
- )
2221
- )
2222
- )
2223
- `);
2224
- const deleteOrphanTweetFts = db.prepare(`
2225
- delete from tweets_fts
2226
- where tweet_id not in (select id from tweets)
2227
- `);
2228
- const clearDmFts = db.prepare(`
2229
- delete from dm_fts
2230
- where message_id in (
2231
- select m.id
2232
- from dm_messages m
2233
- join dm_conversations c on c.id = m.conversation_id
2234
- where c.account_id = ?
2235
- )
2236
- `);
2237
- const clearDmLinkOccurrences = db.prepare(`
2238
- delete from link_occurrences
2239
- where source_kind = 'dm'
2240
- and source_id in (
2241
- select m.id
2242
- from dm_messages m
2243
- join dm_conversations c on c.id = m.conversation_id
2244
- where c.account_id = ?
2245
- )
2246
- `);
2247
- const clearDmMessages = db.prepare(`
2248
- delete from dm_messages
2249
- where conversation_id in (
2250
- select id from dm_conversations where account_id = ?
2251
- )
2252
- `);
2253
- const clearDmConversations = db.prepare(
2254
- "delete from dm_conversations where account_id = ?",
2255
- );
2256
-
2257
- function importFollowRows(
2258
- direction: ArchiveFollowDirection,
2259
- rows: Array<{ profileId: string; externalUserId: string }>,
2260
- entryCount: number,
2261
- now: string,
2262
- ) {
2263
- const snapshotId = `follow_snapshot_archive_acct_primary_${direction}`;
2264
- const existingEdges = new Map(
2265
- (
2266
- selectFollowEdges.all("acct_primary", direction) as Array<{
2267
- profile_id: string;
2268
- external_user_id: string;
2269
- current: number;
2270
- }>
2271
- ).map((row) => [row.profile_id, row]),
2272
- );
2273
- const existingMemberKey = (
2274
- selectFollowSnapshotMembers.all(snapshotId) as Array<{
2275
- profile_id: string;
2276
- external_user_id: string;
2277
- }>
2278
- )
2279
- .map(
2280
- (row, index) =>
2281
- `${String(index)}:${row.profile_id}:${row.external_user_id}`,
2282
- )
2283
- .join("\n");
2284
- const nextMemberKey = rows
2285
- .map(
2286
- (row, index) =>
2287
- `${String(index)}:${row.profileId}:${row.externalUserId}`,
2288
- )
2289
- .join("\n");
2290
- const membersChanged = existingMemberKey !== nextMemberKey;
2291
- const currentProfileIds = new Set<string>();
2292
-
2293
- insertFollowSnapshot.run(
2294
- snapshotId,
2295
- "acct_primary",
2296
- direction,
2297
- entryCount,
2298
- rows.length,
2299
- now,
2300
- now,
2301
- JSON.stringify({ archivePath, result_count: rows.length }),
2302
- );
2303
-
2304
- if (membersChanged) {
2305
- deleteFollowSnapshotMembers.run(snapshotId);
2306
- }
2307
- rows.forEach((row, index) => {
2308
- const profileId = resolveProfileId(row.profileId);
2309
- currentProfileIds.add(profileId);
2310
- if (membersChanged) {
2311
- insertFollowSnapshotMember.run(
2312
- snapshotId,
2313
- profileId,
2314
- row.externalUserId,
2315
- index,
2316
- );
2317
- }
2318
-
2319
- const previous = existingEdges.get(profileId);
2320
- insertFollowEdge.run(
2321
- "acct_primary",
2322
- direction,
2323
- profileId,
2324
- row.externalUserId,
2325
- now,
2326
- now,
2327
- now,
2328
- );
2329
- if (!previous || previous.current === 0) {
2330
- insertFollowEvent.run(
2331
- `follow_event_${randomUUID()}`,
2332
- "acct_primary",
2333
- direction,
2334
- profileId,
2335
- row.externalUserId,
2336
- "started",
2337
- now,
2338
- snapshotId,
2339
- );
2340
- }
2341
- });
2342
-
2343
- for (const [profileId, previous] of existingEdges) {
2344
- if (previous.current === 0 || currentProfileIds.has(profileId)) {
2345
- continue;
2346
- }
2347
- endFollowEdge.run(now, now, "acct_primary", direction, profileId);
2348
- insertFollowEvent.run(
2349
- `follow_event_${randomUUID()}`,
2350
- "acct_primary",
2351
- direction,
2352
- profileId,
2353
- previous.external_user_id,
2354
- "ended",
2355
- now,
2356
- snapshotId,
2357
- );
2358
- }
2359
- }
2360
-
2361
- function clearArchiveFollowRows(direction: ArchiveFollowDirection) {
2362
- deleteArchiveFollowEvents.run(
2363
- "acct_primary",
2364
- direction,
2365
- `follow_snapshot_archive_acct_primary_${direction}`,
2366
- "acct_primary",
2367
- direction,
2368
- );
2369
- deleteArchiveFollowSnapshotMembers.run("acct_primary", direction);
2370
- deleteArchiveFollowSnapshots.run("acct_primary", direction);
2371
- deleteArchiveFollowEdges.run("acct_primary", direction);
2372
- }
2373
-
2374
- onProgress({ kind: "writing" });
2375
- const WRITE_PROGRESS_INTERVAL = 1000;
2376
- function tickWrite(
2377
- phase: ImportWritePhase,
2378
- processed: number,
2379
- total: number,
2380
- ) {
2381
- if (processed === total || processed % WRITE_PROGRESS_INTERVAL === 0) {
2382
- onProgress({ kind: "write-progress", phase, processed, total });
2383
- }
2384
- }
2385
- yield* databaseWriteEffect(() => {
2386
- if (!selection) {
2387
- repository.clearArchiveImport();
2388
- repository.clearMentionSyncState();
2389
- }
2390
-
2391
- if (selection) {
2392
- if (includeTweets) {
2393
- repository.clearAuthoredSyncCursors("acct_primary");
2394
- demoteSelectedArchiveTweetsWithCollections.run(
2395
- "acct_primary",
2396
- "acct_primary",
2397
- "acct_primary",
2398
- "acct_primary",
2399
- "acct_primary",
2400
- localProfile.id,
2401
- "acct_primary",
2402
- );
2403
- preserveSelectedArchiveTweetsReferencedElsewhere.run(
2404
- "acct_primary",
2405
- "acct_primary",
2406
- "acct_primary",
2407
- localProfile.id,
2408
- "acct_primary",
2409
- "acct_primary",
2410
- "acct_primary",
2411
- localProfile.id,
2412
- "acct_primary",
2413
- "acct_primary",
2414
- localProfile.id,
2415
- );
2416
- deleteSelectedArchiveTweetsWithoutCollections.run(
2417
- "acct_primary",
2418
- "acct_primary",
2419
- "acct_primary",
2420
- localProfile.id,
2421
- "acct_primary",
2422
- "acct_primary",
2423
- localProfile.id,
2424
- "acct_primary",
2425
- "acct_primary",
2426
- localProfile.id,
2427
- );
2428
- deleteOrphanTweetFts.run();
2429
- deleteOrphanTweetLinkOccurrences.run();
2430
- clearSelectedArchiveTweetEdges.run(
2431
- "acct_primary",
2432
- "acct_primary",
2433
- localProfile.id,
2434
- );
2435
- }
2436
- if (includeLikes) {
2437
- clearTweetLikedFlag.run("acct_primary", "acct_primary");
2438
- clearSelectedLikes.run("acct_primary");
2439
- }
2440
- if (includeBookmarks) {
2441
- clearTweetBookmarkedFlag.run("acct_primary", "acct_primary");
2442
- clearSelectedBookmarks.run("acct_primary");
2443
- }
2444
- if (includeLikes || includeBookmarks) {
2445
- deleteOrphanArchiveCollectionTweets.run("acct_primary");
2446
- deleteOrphanTweetFts.run();
2447
- deleteOrphanTweetLinkOccurrences.run();
2448
- }
2449
- if (includeDirectMessages) {
2450
- clearDmLinkOccurrences.run("acct_primary");
2451
- clearDmFts.run("acct_primary");
2452
- clearDmMessages.run("acct_primary");
2453
- clearDmConversations.run("acct_primary");
2454
- }
2455
- }
2456
-
2457
- const writeAccount = selection ? insertAccountIfMissing : insertAccount;
2458
- writeAccount.run(
2459
- "acct_primary",
2460
- accountPayload.displayName,
2461
- `@${accountPayload.username}`,
2462
- accountPayload.accountId,
2463
- "archive",
2464
- accountPayload.createdAt,
2465
- );
2466
-
2467
- const writeProfile =
2468
- !selection || includeProfiles ? insertProfile : insertProfileIfMissing;
2469
- const profilesTotal = profiles.size;
2470
- if (profilesTotal > 0) {
2471
- onProgress({
2472
- kind: "write-start",
2473
- phase: "profiles",
2474
- total: profilesTotal,
2475
- });
2476
- }
2477
- let profileIndex = 0;
2478
- for (const profile of profiles.values()) {
2479
- writeProfile.run(
2480
- profile.id,
2481
- profile.handle,
2482
- profile.displayName,
2483
- profile.bio,
2484
- profile.followersCount,
2485
- profile.followingCount,
2486
- profile.publicMetricsJson,
2487
- profile.avatarHue,
2488
- profile.avatarUrl,
2489
- profile.location,
2490
- profile.url,
2491
- profile.verifiedType,
2492
- profile.entitiesJson,
2493
- profile.rawJson,
2494
- profile.createdAt,
2495
- );
2496
- profileIndex += 1;
2497
- tickWrite("profiles", profileIndex, profilesTotal);
2498
- }
2499
-
2500
- if (tweetRows.length > 0) {
2501
- onProgress({
2502
- kind: "write-start",
2503
- phase: "tweets",
2504
- total: tweetRows.length,
2505
- });
2506
- }
2507
- let tweetWriteIndex = 0;
2508
- for (const tweet of tweetRows) {
2509
- const authorProfileId =
2510
- tweet.authorProfileId === "profile_me"
2511
- ? localProfile.id
2512
- : resolveProfileId(tweet.authorProfileId);
2513
- insertTweet.run(
2514
- tweet.id,
2515
- "acct_primary",
2516
- authorProfileId,
2517
- tweet.kind,
2518
- tweet.text,
2519
- tweet.createdAt,
2520
- tweet.isReplied,
2521
- tweet.replyToId,
2522
- tweet.likeCount,
2523
- tweet.mediaCount,
2524
- tweet.bookmarked,
2525
- tweet.liked,
2526
- tweet.entitiesJson,
2527
- tweet.mediaJson,
2528
- tweet.quotedTweetId,
2529
- );
2530
- deleteTweetFts.run(tweet.id);
2531
- if (tweet.kind === "home") {
2532
- insertTimelineEdge.run(
2533
- "acct_primary",
2534
- tweet.id,
2535
- tweet.kind,
2536
- tweet.createdAt,
2537
- tweet.createdAt,
2538
- new Date().toISOString(),
2539
- );
2540
- }
2541
- if (authorProfileId === localProfile.id) {
2542
- insertTimelineEdge.run(
2543
- "acct_primary",
2544
- tweet.id,
2545
- "authored",
2546
- tweet.createdAt,
2547
- tweet.createdAt,
2548
- new Date().toISOString(),
2549
- );
2550
- }
2551
- const storedTweet = selectTweetFtsText.get(tweet.id) as
2552
- | { text: string }
2553
- | undefined;
2554
- insertTweetFts.run(tweet.id, storedTweet?.text ?? tweet.text);
2555
- tweetWriteIndex += 1;
2556
- tickWrite("tweets", tweetWriteIndex, tweetRows.length);
2557
- }
2558
-
2559
- const importedAt = new Date().toISOString();
2560
- if (collectionRows.length > 0) {
2561
- onProgress({
2562
- kind: "write-start",
2563
- phase: "collections",
2564
- total: collectionRows.length,
2565
- });
2566
- }
2567
- let collectionIndex = 0;
2568
- for (const collection of collectionRows) {
2569
- insertCollection.run(
2570
- "acct_primary",
2571
- collection.tweetId,
2572
- collection.kind,
2573
- collection.collectedAt,
2574
- collection.source,
2575
- collection.rawJson,
2576
- importedAt,
2577
- );
2578
- collectionIndex += 1;
2579
- tickWrite("collections", collectionIndex, collectionRows.length);
2580
- }
2581
-
2582
- for (const conversation of conversations.values()) {
2583
- insertConversation.run(
2584
- conversation.id,
2585
- conversation.accountId,
2586
- conversation.participantProfileId,
2587
- conversation.title,
2588
- conversation.lastMessageAt,
2589
- conversation.unreadCount,
2590
- conversation.needsReply,
2591
- );
2592
- }
2593
-
2594
- if (dmMessages.length > 0) {
2595
- onProgress({
2596
- kind: "write-start",
2597
- phase: "dmMessages",
2598
- total: dmMessages.length,
2599
- });
2600
- }
2601
- let dmWriteIndex = 0;
2602
- for (const message of dmMessages) {
2603
- insertMessage.run(
2604
- message.id,
2605
- message.conversationId,
2606
- message.senderProfileId,
2607
- message.text,
2608
- message.createdAt,
2609
- message.direction,
2610
- message.direction === "outbound" ? 1 : 0,
2611
- message.mediaCount,
2612
- );
2613
- insertDmFts.run(message.id, message.text);
2614
- dmWriteIndex += 1;
2615
- tickWrite("dmMessages", dmWriteIndex, dmMessages.length);
2616
- }
2617
-
2618
- if (includeFollowers && followerEntries.length > 0) {
2619
- importFollowRows(
2620
- "followers",
2621
- followerRows,
2622
- followerEntries.length,
2623
- importedAt,
2624
- );
2625
- } else if (includeFollowers) {
2626
- clearArchiveFollowRows("followers");
2627
- }
2628
- if (includeFollowing && followingEntries.length > 0) {
2629
- importFollowRows(
2630
- "following",
2631
- followingRows,
2632
- followingEntries.length,
2633
- importedAt,
2634
- );
2635
- } else if (includeFollowing) {
2636
- clearArchiveFollowRows("following");
2637
- }
2638
- }, db);
2639
- onProgress({ kind: "done" });
2640
-
2641
- return {
2642
- ok: true,
2643
- archivePath,
2644
- account: {
2645
- id: accountPayload.accountId,
2646
- handle: accountPayload.username,
2647
- displayName: accountPayload.displayName,
2648
- },
2649
- counts: {
2650
- tweets: authoredTweetCount,
2651
- likes: likeCount,
2652
- bookmarks: bookmarkCount,
2653
- dmConversations: conversations.size,
2654
- dmMessages: dmMessages.length,
2655
- profiles: profiles.size,
2656
- mediaFiles: mediaFileCounts,
2657
- followers: followerRows.length,
2658
- following: followingRows.length,
2659
- },
2660
- };
2661
- });
2662
- }
2663
-
2664
- export function importArchiveEffect(
2665
- archivePath: string,
2666
- options: ImportArchiveOptions = {},
2667
- ): Effect.Effect<ImportedArchiveSummary, unknown> {
2668
- return importArchiveInternalEffect(archivePath, options);
2669
- }
2670
-
2671
- export function importArchive(
2672
- archivePath: string,
2673
- options: ImportArchiveOptions = {},
2674
- ): Promise<ImportedArchiveSummary> {
2675
- return runEffectPromise(importArchiveEffect(archivePath, options));
2676
- }
2677
-
2678
- export const __test__ = {
2679
- normalizeArchivePath,
2680
- extractArchiveJson,
2681
- parseArchiveArray,
2682
- getFirstEntry,
2683
- getMatchingEntries,
2684
- parseTwitterDate,
2685
- asRecord,
2686
- asArray,
2687
- toInt,
2688
- compareIsoTimestamp,
2689
- getTweetMediaCount,
2690
- extractTweetEntities,
2691
- extractTweetMedia,
2692
- extractCollectionTweet,
2693
- buildAccountPayload,
2694
- inferProfileFromDirectory,
2695
- };