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