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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
import type { LinkIndexItem } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface UrlExpansionRecordInput {
|
|
5
|
+
url: string;
|
|
6
|
+
expandedUrl: string;
|
|
7
|
+
finalUrl: string;
|
|
8
|
+
status: "hit" | "miss" | "error";
|
|
9
|
+
title?: string | null;
|
|
10
|
+
description?: string | null;
|
|
11
|
+
imageUrl?: string | null;
|
|
12
|
+
siteName?: string | null;
|
|
13
|
+
error?: string;
|
|
14
|
+
source: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getTweetTarget(url: string) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
const host = parsed.hostname.toLowerCase();
|
|
22
|
+
if (
|
|
23
|
+
host !== "x.com" &&
|
|
24
|
+
host !== "twitter.com" &&
|
|
25
|
+
host !== "mobile.twitter.com" &&
|
|
26
|
+
host !== "www.x.com" &&
|
|
27
|
+
host !== "www.twitter.com"
|
|
28
|
+
) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
33
|
+
const statusIndex = parts.findIndex(
|
|
34
|
+
(part) => part === "status" || part === "statuses",
|
|
35
|
+
);
|
|
36
|
+
if (statusIndex === -1 || !parts[statusIndex + 1]) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tweetId = parts[statusIndex + 1]?.match(/^\d+/)?.[0];
|
|
41
|
+
const handle =
|
|
42
|
+
parts[statusIndex - 1] && parts[statusIndex - 1] !== "i"
|
|
43
|
+
? parts[statusIndex - 1]
|
|
44
|
+
: undefined;
|
|
45
|
+
return {
|
|
46
|
+
expandedTweetId: tweetId,
|
|
47
|
+
expandedHandle: handle,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeUrlExpansionForIndex(
|
|
55
|
+
item: UrlExpansionRecordInput,
|
|
56
|
+
): LinkIndexItem {
|
|
57
|
+
const target = getTweetTarget(item.finalUrl);
|
|
58
|
+
return {
|
|
59
|
+
shortUrl: item.url,
|
|
60
|
+
expandedUrl: item.expandedUrl,
|
|
61
|
+
finalUrl: item.finalUrl,
|
|
62
|
+
status: item.status,
|
|
63
|
+
expandedTweetId: target.expandedTweetId ?? null,
|
|
64
|
+
expandedHandle: target.expandedHandle ?? null,
|
|
65
|
+
title: item.title ?? null,
|
|
66
|
+
description: item.description ?? null,
|
|
67
|
+
imageUrl: item.imageUrl ?? null,
|
|
68
|
+
siteName: item.siteName ?? null,
|
|
69
|
+
error: item.error ?? null,
|
|
70
|
+
source: item.source,
|
|
71
|
+
updatedAt: item.updatedAt,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function upsertUrlExpansion(db: Database, item: LinkIndexItem) {
|
|
76
|
+
db.prepare(`
|
|
77
|
+
insert into url_expansions (
|
|
78
|
+
short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
79
|
+
expanded_handle, title, description, image_url, site_name, error, source,
|
|
80
|
+
updated_at
|
|
81
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
82
|
+
on conflict(short_url) do update set
|
|
83
|
+
expanded_url = excluded.expanded_url,
|
|
84
|
+
final_url = excluded.final_url,
|
|
85
|
+
status = excluded.status,
|
|
86
|
+
expanded_tweet_id = excluded.expanded_tweet_id,
|
|
87
|
+
expanded_handle = excluded.expanded_handle,
|
|
88
|
+
title = excluded.title,
|
|
89
|
+
description = excluded.description,
|
|
90
|
+
image_url = excluded.image_url,
|
|
91
|
+
site_name = excluded.site_name,
|
|
92
|
+
error = excluded.error,
|
|
93
|
+
source = excluded.source,
|
|
94
|
+
updated_at = excluded.updated_at
|
|
95
|
+
`).run(
|
|
96
|
+
item.shortUrl,
|
|
97
|
+
item.expandedUrl,
|
|
98
|
+
item.finalUrl,
|
|
99
|
+
item.status,
|
|
100
|
+
item.expandedTweetId,
|
|
101
|
+
item.expandedHandle,
|
|
102
|
+
item.title,
|
|
103
|
+
item.description,
|
|
104
|
+
item.imageUrl,
|
|
105
|
+
item.siteName,
|
|
106
|
+
item.error,
|
|
107
|
+
item.source,
|
|
108
|
+
item.updatedAt,
|
|
109
|
+
);
|
|
110
|
+
}
|
package/src/lib/url-expansion.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
1
2
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
2
3
|
import type { UrlExpansionItem } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
normalizeUrlExpansionForIndex,
|
|
6
|
+
upsertUrlExpansion,
|
|
7
|
+
} from "./url-expansion-store";
|
|
3
8
|
|
|
4
9
|
const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
5
10
|
const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -66,6 +71,11 @@ function toExpansionItem(
|
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
function persistExpansion(item: UrlExpansionItem) {
|
|
75
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
76
|
+
upsertUrlExpansion(db, normalizeUrlExpansionForIndex(item));
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
async function fetchExpansion(
|
|
70
80
|
url: string,
|
|
71
81
|
fetchImpl: typeof fetch,
|
|
@@ -124,16 +134,23 @@ export async function expandUrls(
|
|
|
124
134
|
? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
|
|
125
135
|
: (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
|
|
126
136
|
if (isFresh(cached.updatedAt, maxAge)) {
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
const item = toExpansionItem(
|
|
138
|
+
url,
|
|
139
|
+
cached.value,
|
|
140
|
+
"cache",
|
|
141
|
+
cached.updatedAt,
|
|
129
142
|
);
|
|
143
|
+
persistExpansion(item);
|
|
144
|
+
results.push(item);
|
|
130
145
|
continue;
|
|
131
146
|
}
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
const value = await fetchExpansion(url, fetchImpl, timeoutMs);
|
|
135
150
|
const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
|
|
136
|
-
|
|
151
|
+
const item = toExpansionItem(url, value, "network", updatedAt);
|
|
152
|
+
persistExpansion(item);
|
|
153
|
+
results.push(item);
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
return results;
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { maybeAutoSyncBackup } from "./backup";
|
|
3
|
+
import { getBirdclawPaths } from "./config";
|
|
4
|
+
import { syncDirectMessagesViaCachedBird } from "./dms-live";
|
|
5
|
+
import { syncMentionThreads } from "./mention-threads-live";
|
|
6
|
+
import { syncMentions } from "./mentions-live";
|
|
7
|
+
import NativeSqliteDatabase from "./sqlite";
|
|
8
|
+
import { syncTimelineCollection } from "./timeline-collections-live";
|
|
9
|
+
import { syncHomeTimeline } from "./timeline-live";
|
|
10
|
+
|
|
11
|
+
export type WebSyncKind =
|
|
12
|
+
| "timeline"
|
|
13
|
+
| "mentions"
|
|
14
|
+
| "likes"
|
|
15
|
+
| "bookmarks"
|
|
16
|
+
| "dms";
|
|
17
|
+
|
|
18
|
+
export interface WebSyncStep {
|
|
19
|
+
kind: WebSyncKind | "mention-threads";
|
|
20
|
+
label: string;
|
|
21
|
+
count: number;
|
|
22
|
+
source?: string;
|
|
23
|
+
partial?: boolean;
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WebSyncResponse {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
kind: WebSyncKind;
|
|
30
|
+
accountId?: string;
|
|
31
|
+
startedAt: string;
|
|
32
|
+
finishedAt?: string;
|
|
33
|
+
summary: string;
|
|
34
|
+
steps: WebSyncStep[];
|
|
35
|
+
inProgress?: boolean;
|
|
36
|
+
backup?: Awaited<ReturnType<typeof maybeAutoSyncBackup>>;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type WebSyncJobStatus = "running" | "succeeded" | "failed";
|
|
41
|
+
|
|
42
|
+
export interface WebSyncJobSnapshot {
|
|
43
|
+
id: string;
|
|
44
|
+
kind: WebSyncKind;
|
|
45
|
+
accountId?: string;
|
|
46
|
+
status: WebSyncJobStatus;
|
|
47
|
+
startedAt: string;
|
|
48
|
+
finishedAt?: string;
|
|
49
|
+
summary: string;
|
|
50
|
+
inProgress: boolean;
|
|
51
|
+
result?: WebSyncResponse;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WebSyncPlan {
|
|
56
|
+
label: string;
|
|
57
|
+
accountAware: boolean;
|
|
58
|
+
run: (accountId: string | undefined) => Promise<WebSyncStep[]>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const runningSyncs = new Map<string, WebSyncJobSnapshot>();
|
|
62
|
+
const webSyncJobs = new Map<string, WebSyncJobSnapshot>();
|
|
63
|
+
const webSyncJobKeys = new Map<string, string>();
|
|
64
|
+
const completedJobCleanupTimers = new Map<
|
|
65
|
+
string,
|
|
66
|
+
ReturnType<typeof setTimeout>
|
|
67
|
+
>();
|
|
68
|
+
const COMPLETED_JOB_TTL_MS = 5 * 60 * 1000;
|
|
69
|
+
|
|
70
|
+
function assertRecord(
|
|
71
|
+
value: unknown,
|
|
72
|
+
): asserts value is Record<string, unknown> {
|
|
73
|
+
if (!value || typeof value !== "object") {
|
|
74
|
+
throw new Error("Expected sync result object");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readNumber(value: unknown, key: string): number {
|
|
79
|
+
assertRecord(value);
|
|
80
|
+
const raw = value[key];
|
|
81
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readString(value: unknown, key: string) {
|
|
85
|
+
assertRecord(value);
|
|
86
|
+
const raw = value[key];
|
|
87
|
+
return typeof raw === "string" ? raw : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readBoolean(value: unknown, key: string) {
|
|
91
|
+
assertRecord(value);
|
|
92
|
+
const raw = value[key];
|
|
93
|
+
return typeof raw === "boolean" ? raw : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseWebSyncKind(value: unknown): WebSyncKind | null {
|
|
97
|
+
return value === "timeline" ||
|
|
98
|
+
value === "mentions" ||
|
|
99
|
+
value === "likes" ||
|
|
100
|
+
value === "bookmarks" ||
|
|
101
|
+
value === "dms"
|
|
102
|
+
? value
|
|
103
|
+
: null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summarizeSteps(steps: WebSyncStep[]) {
|
|
107
|
+
const total = steps.reduce((sum, step) => sum + step.count, 0);
|
|
108
|
+
const partial = steps.some((step) => step.partial);
|
|
109
|
+
const suffix = partial ? " (partial)" : "";
|
|
110
|
+
return `Synced ${String(total)} items${suffix}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const WEB_SYNC_PLANS: Record<WebSyncKind, WebSyncPlan> = {
|
|
114
|
+
timeline: {
|
|
115
|
+
label: "Home timeline",
|
|
116
|
+
accountAware: false,
|
|
117
|
+
run: async (account) => {
|
|
118
|
+
const result = await syncHomeTimeline({
|
|
119
|
+
account,
|
|
120
|
+
limit: 100,
|
|
121
|
+
following: true,
|
|
122
|
+
refresh: true,
|
|
123
|
+
});
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
kind: "timeline",
|
|
127
|
+
label: "Home timeline",
|
|
128
|
+
count: readNumber(result, "count"),
|
|
129
|
+
source: readString(result, "source"),
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
mentions: {
|
|
135
|
+
label: "Mentions",
|
|
136
|
+
accountAware: true,
|
|
137
|
+
run: async (account) => {
|
|
138
|
+
const mentions = await syncMentions({
|
|
139
|
+
account,
|
|
140
|
+
mode: "xurl",
|
|
141
|
+
limit: 100,
|
|
142
|
+
maxPages: 3,
|
|
143
|
+
refresh: true,
|
|
144
|
+
});
|
|
145
|
+
const steps: WebSyncStep[] = [
|
|
146
|
+
{
|
|
147
|
+
kind: "mentions",
|
|
148
|
+
label: "Mentions",
|
|
149
|
+
count: readNumber(mentions, "count"),
|
|
150
|
+
source: readString(mentions, "source"),
|
|
151
|
+
partial: readBoolean(mentions, "partial"),
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const threads = await syncMentionThreads({
|
|
156
|
+
account,
|
|
157
|
+
mode: "xurl",
|
|
158
|
+
limit: 30,
|
|
159
|
+
delayMs: 1500,
|
|
160
|
+
timeoutMs: 15000,
|
|
161
|
+
});
|
|
162
|
+
steps.push({
|
|
163
|
+
kind: "mention-threads",
|
|
164
|
+
label: "Mention threads",
|
|
165
|
+
count: readNumber(threads, "mergedTweets"),
|
|
166
|
+
source: readString(threads, "source"),
|
|
167
|
+
partial: readBoolean(threads, "partial"),
|
|
168
|
+
warnings:
|
|
169
|
+
Array.isArray(threads.warnings) && threads.warnings.length > 0
|
|
170
|
+
? threads.warnings.map(String)
|
|
171
|
+
: undefined,
|
|
172
|
+
});
|
|
173
|
+
return steps;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
likes: {
|
|
177
|
+
label: "Likes",
|
|
178
|
+
accountAware: true,
|
|
179
|
+
run: (account) => syncSavedCollection("likes", account),
|
|
180
|
+
},
|
|
181
|
+
bookmarks: {
|
|
182
|
+
label: "Bookmarks",
|
|
183
|
+
accountAware: true,
|
|
184
|
+
run: (account) => syncSavedCollection("bookmarks", account),
|
|
185
|
+
},
|
|
186
|
+
dms: {
|
|
187
|
+
label: "Direct messages",
|
|
188
|
+
accountAware: false,
|
|
189
|
+
run: async (account) => {
|
|
190
|
+
const result = await syncDirectMessagesViaCachedBird({
|
|
191
|
+
account,
|
|
192
|
+
limit: 50,
|
|
193
|
+
refresh: true,
|
|
194
|
+
});
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
kind: "dms",
|
|
198
|
+
label: "Direct messages",
|
|
199
|
+
count: readNumber(result, "messages"),
|
|
200
|
+
source: readString(result, "source"),
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function syncSavedCollection(
|
|
208
|
+
kind: "likes" | "bookmarks",
|
|
209
|
+
account: string | undefined,
|
|
210
|
+
) {
|
|
211
|
+
const isNonDefaultAccount =
|
|
212
|
+
account !== undefined && account !== resolveDefaultSyncAccountId();
|
|
213
|
+
const result = await syncTimelineCollection({
|
|
214
|
+
kind,
|
|
215
|
+
account,
|
|
216
|
+
mode: isNonDefaultAccount ? "xurl" : "auto",
|
|
217
|
+
limit: 100,
|
|
218
|
+
maxPages: 5,
|
|
219
|
+
refresh: true,
|
|
220
|
+
earlyStop: true,
|
|
221
|
+
});
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
kind,
|
|
225
|
+
label: kind === "likes" ? "Likes" : "Bookmarks",
|
|
226
|
+
count: readNumber(result, "count"),
|
|
227
|
+
source: readString(result, "source"),
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function performWebSync(
|
|
233
|
+
kind: WebSyncKind,
|
|
234
|
+
accountId?: string,
|
|
235
|
+
): Promise<WebSyncResponse> {
|
|
236
|
+
const startedAt = new Date().toISOString();
|
|
237
|
+
const steps = await WEB_SYNC_PLANS[kind].run(accountId);
|
|
238
|
+
|
|
239
|
+
const backup = await maybeAutoSyncBackup();
|
|
240
|
+
const finishedAt = new Date().toISOString();
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
kind,
|
|
244
|
+
...(accountId ? { accountId } : {}),
|
|
245
|
+
startedAt,
|
|
246
|
+
finishedAt,
|
|
247
|
+
summary: summarizeSteps(steps),
|
|
248
|
+
steps,
|
|
249
|
+
backup,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createWebSyncJobId(kind: WebSyncKind) {
|
|
254
|
+
return `sync_${kind}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function resolveDefaultSyncAccountId() {
|
|
258
|
+
const dbPath = getBirdclawPaths().dbPath;
|
|
259
|
+
if (!existsSync(dbPath)) {
|
|
260
|
+
return "acct_primary";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let db: NativeSqliteDatabase | undefined;
|
|
264
|
+
try {
|
|
265
|
+
db = new NativeSqliteDatabase(dbPath, { readonly: true });
|
|
266
|
+
const row = db
|
|
267
|
+
.prepare(
|
|
268
|
+
`
|
|
269
|
+
select id
|
|
270
|
+
from accounts
|
|
271
|
+
order by is_default desc, created_at asc
|
|
272
|
+
limit 1
|
|
273
|
+
`,
|
|
274
|
+
)
|
|
275
|
+
.get() as { id: string } | undefined;
|
|
276
|
+
return row?.id ?? "acct_primary";
|
|
277
|
+
} catch {
|
|
278
|
+
return "acct_primary";
|
|
279
|
+
} finally {
|
|
280
|
+
db?.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getRunningSyncKey(kind: WebSyncKind, accountId: string | undefined) {
|
|
285
|
+
if (!WEB_SYNC_PLANS[kind].accountAware) {
|
|
286
|
+
return kind;
|
|
287
|
+
}
|
|
288
|
+
return `${kind}:${accountId ?? resolveDefaultSyncAccountId()}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function getEffectiveAccountId(
|
|
292
|
+
kind: WebSyncKind,
|
|
293
|
+
accountId: string | undefined,
|
|
294
|
+
) {
|
|
295
|
+
return WEB_SYNC_PLANS[kind].accountAware ? accountId : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function setJobSnapshot(snapshot: WebSyncJobSnapshot) {
|
|
299
|
+
webSyncJobs.set(snapshot.id, snapshot);
|
|
300
|
+
const syncKey =
|
|
301
|
+
webSyncJobKeys.get(snapshot.id) ??
|
|
302
|
+
getRunningSyncKey(snapshot.kind, snapshot.accountId);
|
|
303
|
+
const cleanupTimer = completedJobCleanupTimers.get(snapshot.id);
|
|
304
|
+
if (cleanupTimer) {
|
|
305
|
+
clearTimeout(cleanupTimer);
|
|
306
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
307
|
+
}
|
|
308
|
+
if (snapshot.inProgress) {
|
|
309
|
+
runningSyncs.set(syncKey, snapshot);
|
|
310
|
+
} else if (runningSyncs.get(syncKey)?.id === snapshot.id) {
|
|
311
|
+
runningSyncs.delete(syncKey);
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
webSyncJobs.delete(snapshot.id);
|
|
314
|
+
webSyncJobKeys.delete(snapshot.id);
|
|
315
|
+
completedJobCleanupTimers.delete(snapshot.id);
|
|
316
|
+
}, COMPLETED_JOB_TTL_MS);
|
|
317
|
+
timer.unref?.();
|
|
318
|
+
completedJobCleanupTimers.set(snapshot.id, timer);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function toFailedResponse(
|
|
323
|
+
kind: WebSyncKind,
|
|
324
|
+
startedAt: string,
|
|
325
|
+
error: unknown,
|
|
326
|
+
accountId?: string,
|
|
327
|
+
): WebSyncResponse {
|
|
328
|
+
const finishedAt = new Date().toISOString();
|
|
329
|
+
const message = error instanceof Error ? error.message : "Sync failed";
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
kind,
|
|
333
|
+
...(accountId ? { accountId } : {}),
|
|
334
|
+
startedAt,
|
|
335
|
+
finishedAt,
|
|
336
|
+
summary: message,
|
|
337
|
+
steps: [],
|
|
338
|
+
error: message,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function startWebSync(
|
|
343
|
+
kind: WebSyncKind,
|
|
344
|
+
accountId?: string,
|
|
345
|
+
): WebSyncJobSnapshot {
|
|
346
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
347
|
+
const syncKey = getRunningSyncKey(kind, effectiveAccountId);
|
|
348
|
+
const current = runningSyncs.get(syncKey);
|
|
349
|
+
if (current) {
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const startedAt = new Date().toISOString();
|
|
354
|
+
const job: WebSyncJobSnapshot = {
|
|
355
|
+
id: createWebSyncJobId(kind),
|
|
356
|
+
kind,
|
|
357
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
358
|
+
status: "running",
|
|
359
|
+
startedAt,
|
|
360
|
+
summary: `Syncing ${WEB_SYNC_PLANS[kind].label}`,
|
|
361
|
+
inProgress: true,
|
|
362
|
+
};
|
|
363
|
+
webSyncJobKeys.set(job.id, syncKey);
|
|
364
|
+
setJobSnapshot(job);
|
|
365
|
+
|
|
366
|
+
void performWebSync(kind, effectiveAccountId)
|
|
367
|
+
.then((result) => {
|
|
368
|
+
setJobSnapshot({
|
|
369
|
+
...job,
|
|
370
|
+
status: "succeeded",
|
|
371
|
+
finishedAt: result.finishedAt,
|
|
372
|
+
summary: result.summary,
|
|
373
|
+
inProgress: false,
|
|
374
|
+
result,
|
|
375
|
+
});
|
|
376
|
+
})
|
|
377
|
+
.catch((error: unknown) => {
|
|
378
|
+
const result = toFailedResponse(
|
|
379
|
+
kind,
|
|
380
|
+
startedAt,
|
|
381
|
+
error,
|
|
382
|
+
effectiveAccountId,
|
|
383
|
+
);
|
|
384
|
+
setJobSnapshot({
|
|
385
|
+
...job,
|
|
386
|
+
status: "failed",
|
|
387
|
+
finishedAt: result.finishedAt,
|
|
388
|
+
summary: result.summary,
|
|
389
|
+
inProgress: false,
|
|
390
|
+
result,
|
|
391
|
+
error: result.error,
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return job;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function getWebSyncJob(id: string): WebSyncJobSnapshot | null {
|
|
399
|
+
return webSyncJobs.get(id) ?? null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export async function runWebSync(
|
|
403
|
+
kind: WebSyncKind,
|
|
404
|
+
accountId?: string,
|
|
405
|
+
): Promise<WebSyncResponse> {
|
|
406
|
+
const effectiveAccountId = getEffectiveAccountId(kind, accountId);
|
|
407
|
+
const current = runningSyncs.get(getRunningSyncKey(kind, effectiveAccountId));
|
|
408
|
+
const startedAt = new Date().toISOString();
|
|
409
|
+
if (current) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
kind,
|
|
413
|
+
...(effectiveAccountId ? { accountId: effectiveAccountId } : {}),
|
|
414
|
+
startedAt,
|
|
415
|
+
summary: "Sync already running",
|
|
416
|
+
steps: [],
|
|
417
|
+
inProgress: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const job = startWebSync(kind, effectiveAccountId);
|
|
422
|
+
while (job.inProgress) {
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
424
|
+
const latest = getWebSyncJob(job.id);
|
|
425
|
+
if (!latest?.inProgress) {
|
|
426
|
+
if (!latest?.result) throw new Error("Sync job disappeared");
|
|
427
|
+
return latest.result;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (!job.result) throw new Error("Sync job did not finish");
|
|
432
|
+
return job.result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function clearWebSyncLocksForTests() {
|
|
436
|
+
runningSyncs.clear();
|
|
437
|
+
webSyncJobs.clear();
|
|
438
|
+
webSyncJobKeys.clear();
|
|
439
|
+
for (const timer of completedJobCleanupTimers.values()) {
|
|
440
|
+
clearTimeout(timer);
|
|
441
|
+
}
|
|
442
|
+
completedJobCleanupTimers.clear();
|
|
443
|
+
}
|
package/src/lib/x-profile.ts
CHANGED
|
@@ -150,6 +150,7 @@ function updateExistingProfileFromUser(
|
|
|
150
150
|
bio = ?,
|
|
151
151
|
followers_count = ?,
|
|
152
152
|
following_count = coalesce(?, following_count),
|
|
153
|
+
public_metrics_json = ?,
|
|
153
154
|
avatar_url = coalesce(?, avatar_url),
|
|
154
155
|
location = coalesce(?, location),
|
|
155
156
|
url = coalesce(?, url),
|
|
@@ -167,6 +168,7 @@ function updateExistingProfileFromUser(
|
|
|
167
168
|
bio,
|
|
168
169
|
followersCount,
|
|
169
170
|
followingCount,
|
|
171
|
+
JSON.stringify(user.public_metrics ?? {}),
|
|
170
172
|
avatarUrl,
|
|
171
173
|
metadata.location,
|
|
172
174
|
metadata.url,
|
|
@@ -245,13 +247,15 @@ export function upsertProfileFromXUser(
|
|
|
245
247
|
`
|
|
246
248
|
insert into profiles (
|
|
247
249
|
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
248
|
-
avatar_url, location, url, verified_type, entities_json,
|
|
249
|
-
|
|
250
|
+
public_metrics_json, avatar_url, location, url, verified_type, entities_json,
|
|
251
|
+
raw_json, created_at
|
|
252
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
250
253
|
on conflict(id) do update set
|
|
251
254
|
handle = excluded.handle,
|
|
252
255
|
display_name = excluded.display_name,
|
|
253
256
|
bio = excluded.bio,
|
|
254
257
|
followers_count = excluded.followers_count,
|
|
258
|
+
public_metrics_json = excluded.public_metrics_json,
|
|
255
259
|
following_count = case
|
|
256
260
|
when ? then excluded.following_count
|
|
257
261
|
else profiles.following_count
|
|
@@ -274,6 +278,7 @@ export function upsertProfileFromXUser(
|
|
|
274
278
|
followersCount,
|
|
275
279
|
followingCount ?? 0,
|
|
276
280
|
avatarHue,
|
|
281
|
+
JSON.stringify(user.public_metrics ?? {}),
|
|
277
282
|
avatarUrl,
|
|
278
283
|
metadata.location,
|
|
279
284
|
metadata.url,
|