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