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