birdclaw 0.8.0 → 0.8.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 +13 -0
- package/package.json +5 -5
- package/src/components/ConversationThread.tsx +5 -4
- package/src/components/DmWorkspace.tsx +26 -9
- package/src/components/EmbeddedTweetCard.tsx +5 -4
- package/src/components/InboxCard.tsx +6 -4
- package/src/components/MarkdownViewer.tsx +3 -2
- package/src/components/SmartTimestamp.tsx +72 -0
- package/src/components/TimelineCard.tsx +6 -4
- package/src/lib/ndjson-stream.ts +106 -0
- package/src/lib/period-digest.ts +184 -24
- package/src/lib/present.ts +99 -1
- package/src/lib/queries.ts +61 -0
- package/src/routes/api/period-digest.tsx +21 -69
- package/src/routes/api/profile-analysis.tsx +22 -77
- package/src/routes/api/search-discussion.tsx +19 -75
- package/src/routes/links.tsx +4 -3
- package/src/routes/today.tsx +67 -11
package/src/lib/period-digest.ts
CHANGED
|
@@ -6,10 +6,14 @@ import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
|
6
6
|
import { getLinkInsights } from "./link-insights";
|
|
7
7
|
import { syncMentionThreadsEffect } from "./mention-threads-live";
|
|
8
8
|
import { syncMentionsEffect } from "./mentions-live";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getTweetsByIds,
|
|
11
|
+
listDmConversations,
|
|
12
|
+
listTimelineItems,
|
|
13
|
+
} from "./queries";
|
|
10
14
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
11
15
|
import { syncHomeTimelineEffect, type HomeTimelineMode } from "./timeline-live";
|
|
12
|
-
import type { ProfileRecord, TweetEntities } from "./types";
|
|
16
|
+
import type { EmbeddedTweet, ProfileRecord, TweetEntities } from "./types";
|
|
13
17
|
|
|
14
18
|
export type PeriodDigestPreset = "today" | "yesterday" | "24h" | "week";
|
|
15
19
|
export type PeriodDigestSourceKind =
|
|
@@ -222,6 +226,7 @@ const DEFAULT_LIVE_MENTIONS_LIMIT = 100;
|
|
|
222
226
|
const DEFAULT_LIVE_MENTIONS_MAX_PAGES = undefined;
|
|
223
227
|
const DEFAULT_LIVE_THREAD_LIMIT = 12;
|
|
224
228
|
const DEFAULT_LIVE_THREAD_TIMEOUT_MS = 5_000;
|
|
229
|
+
const DEFAULT_DIGEST_FRESHNESS_MS = 5 * 60_000;
|
|
225
230
|
const MAX_PROMPT_DATA_CHARS = 1_200_000;
|
|
226
231
|
const DELIMITER_PATTERN = /\n---\s*\n/;
|
|
227
232
|
const VISIBLE_DELIMITER_HOLD = 8;
|
|
@@ -363,6 +368,26 @@ function compactTweet(
|
|
|
363
368
|
};
|
|
364
369
|
}
|
|
365
370
|
|
|
371
|
+
function compactEmbeddedTweet(item: EmbeddedTweet): CompactTweet {
|
|
372
|
+
return {
|
|
373
|
+
id: item.id,
|
|
374
|
+
url: tweetUrl(item.author.handle, item.id),
|
|
375
|
+
source: "home",
|
|
376
|
+
author: item.author.handle,
|
|
377
|
+
name: item.author.displayName,
|
|
378
|
+
authorProfile: item.author,
|
|
379
|
+
createdAt: item.createdAt,
|
|
380
|
+
text: item.text,
|
|
381
|
+
entities: item.entities,
|
|
382
|
+
likeCount: item.likeCount ?? 0,
|
|
383
|
+
liked: Boolean(item.liked),
|
|
384
|
+
bookmarked: Boolean(item.bookmarked),
|
|
385
|
+
needsReply: !item.isReplied,
|
|
386
|
+
replyToId: item.replyToId ?? null,
|
|
387
|
+
replyToTweet: null,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
366
391
|
function dedupeTweets(tweets: CompactTweet[]) {
|
|
367
392
|
const seen = new Set<string>();
|
|
368
393
|
const items: CompactTweet[] = [];
|
|
@@ -879,6 +904,110 @@ function digestCacheKey(
|
|
|
879
904
|
return parts.join(":");
|
|
880
905
|
}
|
|
881
906
|
|
|
907
|
+
function latestDigestCacheKey(options: PeriodDigestOptions) {
|
|
908
|
+
const period = normalizePeriod(options.period);
|
|
909
|
+
const window = resolvePeriodDigestWindow(options);
|
|
910
|
+
const identity = {
|
|
911
|
+
period,
|
|
912
|
+
day:
|
|
913
|
+
period === "today" || period === "yesterday"
|
|
914
|
+
? window.since
|
|
915
|
+
: localDateStart(new Date()).toISOString(),
|
|
916
|
+
since: options.since?.trim() || null,
|
|
917
|
+
until: options.until?.trim() || null,
|
|
918
|
+
account: options.account?.trim() || null,
|
|
919
|
+
includeDms: Boolean(options.includeDms),
|
|
920
|
+
maxTweets: Math.max(
|
|
921
|
+
20,
|
|
922
|
+
Math.trunc(options.maxTweets ?? DEFAULT_MAX_TWEETS),
|
|
923
|
+
),
|
|
924
|
+
maxLinks: Math.max(3, Math.trunc(options.maxLinks ?? DEFAULT_MAX_LINKS)),
|
|
925
|
+
model: modelFromOptions(options),
|
|
926
|
+
language: languageFromOptions(options) ?? null,
|
|
927
|
+
reasoningEffort: reasoningEffortFromOptions(options),
|
|
928
|
+
serviceTier: serviceTierFromOptions(options),
|
|
929
|
+
};
|
|
930
|
+
return `period-digest-latest:v1:${createHash("sha1")
|
|
931
|
+
.update(JSON.stringify(identity))
|
|
932
|
+
.digest("hex")}`;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function collectDigestTweetIds(digest: PeriodDigest) {
|
|
936
|
+
const tweetIds = new Set(digest.sourceTweetIds);
|
|
937
|
+
for (const topic of digest.keyTopics) {
|
|
938
|
+
for (const tweetId of topic.tweetIds) tweetIds.add(tweetId);
|
|
939
|
+
}
|
|
940
|
+
for (const link of digest.notableLinks) {
|
|
941
|
+
for (const tweetId of link.sourceTweetIds) tweetIds.add(tweetId);
|
|
942
|
+
}
|
|
943
|
+
for (const action of digest.actionItems) {
|
|
944
|
+
if (action.tweetId) tweetIds.add(action.tweetId);
|
|
945
|
+
}
|
|
946
|
+
return [...tweetIds];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function enrichContextWithCitedTweets(
|
|
950
|
+
context: PeriodDigestContext,
|
|
951
|
+
digest: PeriodDigest,
|
|
952
|
+
) {
|
|
953
|
+
const existingIds = new Set(context.tweets.map((tweet) => tweet.id));
|
|
954
|
+
const missingIds = collectDigestTweetIds(digest).filter(
|
|
955
|
+
(tweetId) => !existingIds.has(tweetId.replace(/^tweet_/, "")),
|
|
956
|
+
);
|
|
957
|
+
if (missingIds.length === 0) return context;
|
|
958
|
+
const citedTweets = getTweetsByIds(missingIds, context.account).map(
|
|
959
|
+
compactEmbeddedTweet,
|
|
960
|
+
);
|
|
961
|
+
return citedTweets.length > 0
|
|
962
|
+
? { ...context, tweets: [...context.tweets, ...citedTweets] }
|
|
963
|
+
: context;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
interface CachedPeriodDigestValue {
|
|
967
|
+
context?: PeriodDigestContext;
|
|
968
|
+
digest: PeriodDigest;
|
|
969
|
+
markdown: string;
|
|
970
|
+
model: string;
|
|
971
|
+
reasoningEffort: string;
|
|
972
|
+
serviceTier: string;
|
|
973
|
+
updatedAt?: string;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function cachedDigestResult(
|
|
977
|
+
cached: { value: CachedPeriodDigestValue; updatedAt: string },
|
|
978
|
+
context: PeriodDigestContext,
|
|
979
|
+
): PeriodDigestRunResult {
|
|
980
|
+
const digest = PeriodDigestSchema.parse(cached.value.digest);
|
|
981
|
+
return {
|
|
982
|
+
context: enrichContextWithCitedTweets(context, digest),
|
|
983
|
+
digest,
|
|
984
|
+
markdown: cached.value.markdown,
|
|
985
|
+
model: cached.value.model,
|
|
986
|
+
reasoningEffort: cached.value.reasoningEffort,
|
|
987
|
+
serviceTier: cached.value.serviceTier,
|
|
988
|
+
cached: true,
|
|
989
|
+
updatedAt: cached.value.updatedAt ?? cached.updatedAt,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function isFreshDigestCache(updatedAt: string) {
|
|
994
|
+
const timestamp = Date.parse(updatedAt);
|
|
995
|
+
return (
|
|
996
|
+
Number.isFinite(timestamp) &&
|
|
997
|
+
Date.now() - timestamp <= DEFAULT_DIGEST_FRESHNESS_MS
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function emitCachedDigest(
|
|
1002
|
+
result: PeriodDigestRunResult,
|
|
1003
|
+
handlers: PeriodDigestStreamHandlers,
|
|
1004
|
+
) {
|
|
1005
|
+
handlers.onEvent?.({ type: "start", context: result.context, cached: true });
|
|
1006
|
+
handlers.onDelta?.(result.markdown);
|
|
1007
|
+
handlers.onEvent?.({ type: "delta", delta: result.markdown });
|
|
1008
|
+
handlers.onEvent?.({ type: "done", result });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
882
1011
|
function buildPrompt(
|
|
883
1012
|
context: PeriodDigestContext,
|
|
884
1013
|
options?: { language?: string },
|
|
@@ -1235,6 +1364,9 @@ function readOpenAIStreamEffect(
|
|
|
1235
1364
|
languageFromOptions(options),
|
|
1236
1365
|
),
|
|
1237
1366
|
);
|
|
1367
|
+
const enrichedContext = yield* tryDigestSync(() =>
|
|
1368
|
+
enrichContextWithCitedTweets(context, parsed.digest),
|
|
1369
|
+
);
|
|
1238
1370
|
const cacheKey = digestCacheKey(context, options);
|
|
1239
1371
|
const updatedAt = yield* tryDigestSync(() =>
|
|
1240
1372
|
writeSyncCache(cacheKey, {
|
|
@@ -1248,7 +1380,7 @@ function readOpenAIStreamEffect(
|
|
|
1248
1380
|
}),
|
|
1249
1381
|
);
|
|
1250
1382
|
const result: PeriodDigestRunResult = {
|
|
1251
|
-
context,
|
|
1383
|
+
context: enrichedContext,
|
|
1252
1384
|
digest: parsed.digest,
|
|
1253
1385
|
markdown: parsed.markdown,
|
|
1254
1386
|
model: modelFromOptions(options),
|
|
@@ -1257,6 +1389,17 @@ function readOpenAIStreamEffect(
|
|
|
1257
1389
|
cached: false,
|
|
1258
1390
|
updatedAt,
|
|
1259
1391
|
};
|
|
1392
|
+
yield* tryDigestSync(() =>
|
|
1393
|
+
writeSyncCache(latestDigestCacheKey(options), {
|
|
1394
|
+
context: result.context,
|
|
1395
|
+
digest: result.digest,
|
|
1396
|
+
markdown: result.markdown,
|
|
1397
|
+
model: result.model,
|
|
1398
|
+
reasoningEffort: result.reasoningEffort,
|
|
1399
|
+
serviceTier: result.serviceTier,
|
|
1400
|
+
updatedAt: result.updatedAt,
|
|
1401
|
+
}),
|
|
1402
|
+
);
|
|
1260
1403
|
handlers.onEvent?.({ type: "done", result });
|
|
1261
1404
|
return result;
|
|
1262
1405
|
}
|
|
@@ -1278,6 +1421,28 @@ export function streamPeriodDigestEffect(
|
|
|
1278
1421
|
...options,
|
|
1279
1422
|
language: yield* tryDigestSync(() => languageFromOptions(options)),
|
|
1280
1423
|
};
|
|
1424
|
+
const latestCached = resolvedOptions.refresh
|
|
1425
|
+
? null
|
|
1426
|
+
: !resolvedOptions.liveSync
|
|
1427
|
+
? yield* tryDigestSync(() =>
|
|
1428
|
+
readSyncCache<CachedPeriodDigestValue>(
|
|
1429
|
+
latestDigestCacheKey(resolvedOptions),
|
|
1430
|
+
),
|
|
1431
|
+
)
|
|
1432
|
+
: null;
|
|
1433
|
+
const latestContext = latestCached?.value.context;
|
|
1434
|
+
if (
|
|
1435
|
+
latestCached &&
|
|
1436
|
+
latestContext &&
|
|
1437
|
+
isFreshDigestCache(latestCached.value.updatedAt ?? latestCached.updatedAt)
|
|
1438
|
+
) {
|
|
1439
|
+
const result = yield* tryDigestSync(() =>
|
|
1440
|
+
cachedDigestResult(latestCached, latestContext),
|
|
1441
|
+
);
|
|
1442
|
+
emitCachedDigest(result, handlers);
|
|
1443
|
+
return result;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1281
1446
|
yield* refreshPeriodDigestInputsEffect(
|
|
1282
1447
|
resolvedOptions,
|
|
1283
1448
|
{ threads: false },
|
|
@@ -1290,30 +1455,25 @@ export function streamPeriodDigestEffect(
|
|
|
1290
1455
|
const cached = resolvedOptions.refresh
|
|
1291
1456
|
? null
|
|
1292
1457
|
: yield* tryDigestSync(() =>
|
|
1293
|
-
readSyncCache<
|
|
1294
|
-
digest: PeriodDigest;
|
|
1295
|
-
markdown: string;
|
|
1296
|
-
model: string;
|
|
1297
|
-
reasoningEffort: string;
|
|
1298
|
-
serviceTier: string;
|
|
1299
|
-
}>(cacheKey),
|
|
1458
|
+
readSyncCache<CachedPeriodDigestValue>(cacheKey),
|
|
1300
1459
|
);
|
|
1301
1460
|
|
|
1302
1461
|
if (cached) {
|
|
1303
|
-
const result
|
|
1304
|
-
context,
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1462
|
+
const result = yield* tryDigestSync(() =>
|
|
1463
|
+
cachedDigestResult(cached, context),
|
|
1464
|
+
);
|
|
1465
|
+
yield* tryDigestSync(() =>
|
|
1466
|
+
writeSyncCache(latestDigestCacheKey(resolvedOptions), {
|
|
1467
|
+
context: result.context,
|
|
1468
|
+
digest: result.digest,
|
|
1469
|
+
markdown: result.markdown,
|
|
1470
|
+
model: result.model,
|
|
1471
|
+
reasoningEffort: result.reasoningEffort,
|
|
1472
|
+
serviceTier: result.serviceTier,
|
|
1473
|
+
updatedAt: result.updatedAt,
|
|
1474
|
+
}),
|
|
1475
|
+
);
|
|
1476
|
+
emitCachedDigest(result, handlers);
|
|
1317
1477
|
return result;
|
|
1318
1478
|
}
|
|
1319
1479
|
|
package/src/lib/present.ts
CHANGED
|
@@ -2,13 +2,111 @@ export function formatCompactNumber(value: number) {
|
|
|
2
2
|
return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
const SECOND_MS = 1_000;
|
|
6
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
7
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
8
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
9
|
+
|
|
10
|
+
const timeFormatter = new Intl.DateTimeFormat("en", {
|
|
11
|
+
hour: "numeric",
|
|
12
|
+
minute: "2-digit",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const weekdayFormatter = new Intl.DateTimeFormat("en", {
|
|
16
|
+
weekday: "long",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const sameYearDateFormatter = new Intl.DateTimeFormat("en", {
|
|
20
|
+
month: "short",
|
|
21
|
+
day: "numeric",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const otherYearDateFormatter = new Intl.DateTimeFormat("en", {
|
|
25
|
+
year: "numeric",
|
|
26
|
+
month: "short",
|
|
27
|
+
day: "numeric",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const exactTimestampFormatter = new Intl.DateTimeFormat("en", {
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "long",
|
|
33
|
+
day: "numeric",
|
|
34
|
+
hour: "numeric",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
timeZoneName: "short",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function parseTimestamp(value: string) {
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function localCalendarDay(date: Date) {
|
|
46
|
+
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatCalendarTimestamp(date: Date, now: Date) {
|
|
50
|
+
const time = timeFormatter.format(date);
|
|
51
|
+
const dayDifference = localCalendarDay(now) - localCalendarDay(date);
|
|
52
|
+
|
|
53
|
+
if (dayDifference === 1) {
|
|
54
|
+
return `Yesterday at ${time}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (dayDifference >= 2 && dayDifference <= 6) {
|
|
58
|
+
return `${weekdayFormatter.format(date)} at ${time}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dateFormatter =
|
|
62
|
+
date.getFullYear() === now.getFullYear()
|
|
63
|
+
? sameYearDateFormatter
|
|
64
|
+
: otherYearDateFormatter;
|
|
65
|
+
return `${dateFormatter.format(date)} at ${time}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
5
68
|
export function formatShortTimestamp(value: string) {
|
|
69
|
+
const date = parseTimestamp(value);
|
|
70
|
+
if (!date) return value;
|
|
71
|
+
|
|
6
72
|
return new Intl.DateTimeFormat("en", {
|
|
7
73
|
hour: "numeric",
|
|
8
74
|
minute: "2-digit",
|
|
9
75
|
month: "short",
|
|
10
76
|
day: "numeric",
|
|
11
|
-
}).format(
|
|
77
|
+
}).format(date);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatExactTimestamp(value: string) {
|
|
81
|
+
const date = parseTimestamp(value);
|
|
82
|
+
return date ? exactTimestampFormatter.format(date) : value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function formatSmartTimestamp(value: string, nowValue = Date.now()) {
|
|
86
|
+
const date = parseTimestamp(value);
|
|
87
|
+
const now = new Date(nowValue);
|
|
88
|
+
if (!date || Number.isNaN(now.getTime())) return value;
|
|
89
|
+
|
|
90
|
+
const elapsed = now.getTime() - date.getTime();
|
|
91
|
+
if (elapsed >= -45 * SECOND_MS && elapsed < 45 * SECOND_MS) {
|
|
92
|
+
return "just now";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (elapsed < 0) {
|
|
96
|
+
return formatCalendarTimestamp(date, now);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (elapsed < HOUR_MS) {
|
|
100
|
+
const minutes = Math.max(1, Math.floor(elapsed / MINUTE_MS));
|
|
101
|
+
return `${minutes} min ago`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (elapsed < DAY_MS) {
|
|
105
|
+
const hours = Math.max(1, Math.floor(elapsed / HOUR_MS));
|
|
106
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return formatCalendarTimestamp(date, now);
|
|
12
110
|
}
|
|
13
111
|
|
|
14
112
|
export function getInitials(value: string) {
|
package/src/lib/queries.ts
CHANGED
|
@@ -1286,6 +1286,67 @@ function getTweetById(
|
|
|
1286
1286
|
);
|
|
1287
1287
|
}
|
|
1288
1288
|
|
|
1289
|
+
export function getTweetsByIds(
|
|
1290
|
+
tweetIds: string[],
|
|
1291
|
+
accountId?: string,
|
|
1292
|
+
): EmbeddedTweet[] {
|
|
1293
|
+
const db = getNativeDb();
|
|
1294
|
+
const scopedAccountId =
|
|
1295
|
+
accountId && accountId !== "all" ? accountId : undefined;
|
|
1296
|
+
const urlExpansionCache: UrlExpansionCache = new Map();
|
|
1297
|
+
const profileByHandleCache: ProfileByHandleCache = new Map();
|
|
1298
|
+
const resolveProfileByHandle = (handle: string) =>
|
|
1299
|
+
getProfileByHandle(db, profileByHandleCache, handle);
|
|
1300
|
+
const seen = new Set<string>();
|
|
1301
|
+
const tweets: EmbeddedTweet[] = [];
|
|
1302
|
+
|
|
1303
|
+
for (const tweetId of tweetIds) {
|
|
1304
|
+
const normalized = tweetId.trim().replace(/^tweet_/, "");
|
|
1305
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
1306
|
+
seen.add(normalized);
|
|
1307
|
+
if (
|
|
1308
|
+
scopedAccountId &&
|
|
1309
|
+
!db
|
|
1310
|
+
.prepare(
|
|
1311
|
+
`
|
|
1312
|
+
select 1
|
|
1313
|
+
from tweets tweet
|
|
1314
|
+
where tweet.id = ?
|
|
1315
|
+
and (
|
|
1316
|
+
tweet.account_id = ?
|
|
1317
|
+
or exists (
|
|
1318
|
+
select 1
|
|
1319
|
+
from tweet_account_edges edge
|
|
1320
|
+
where edge.account_id = ?
|
|
1321
|
+
and edge.tweet_id = tweet.id
|
|
1322
|
+
)
|
|
1323
|
+
or exists (
|
|
1324
|
+
select 1
|
|
1325
|
+
from tweet_collections collection
|
|
1326
|
+
where collection.account_id = ?
|
|
1327
|
+
and collection.tweet_id = tweet.id
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
limit 1
|
|
1331
|
+
`,
|
|
1332
|
+
)
|
|
1333
|
+
.get(normalized, scopedAccountId, scopedAccountId, scopedAccountId)
|
|
1334
|
+
) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
const tweet = getTweetById(
|
|
1338
|
+
db,
|
|
1339
|
+
urlExpansionCache,
|
|
1340
|
+
normalized,
|
|
1341
|
+
resolveProfileByHandle,
|
|
1342
|
+
scopedAccountId,
|
|
1343
|
+
);
|
|
1344
|
+
if (tweet) tweets.push(tweet);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
return tweets;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1289
1350
|
function listTweetDescendants(
|
|
1290
1351
|
db: Database,
|
|
1291
1352
|
urlExpansionCache: UrlExpansionCache,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router";
|
|
2
2
|
import { Effect } from "effect";
|
|
3
3
|
import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
|
|
4
|
-
import { runEffectBackground } from "#/lib/effect-runtime";
|
|
5
4
|
import {
|
|
6
5
|
jsonResponse,
|
|
7
6
|
parseBoundedInteger,
|
|
8
7
|
runRouteEffect,
|
|
9
8
|
sensitiveRequestErrorResponse,
|
|
10
9
|
} from "#/lib/http-effect";
|
|
10
|
+
import { createEffectNdjsonResponse } from "#/lib/ndjson-stream";
|
|
11
11
|
import {
|
|
12
12
|
normalizeDigestLanguage,
|
|
13
13
|
streamPeriodDigestEffect,
|
|
@@ -15,8 +15,6 @@ import {
|
|
|
15
15
|
type PeriodDigestStreamEvent,
|
|
16
16
|
} from "#/lib/period-digest";
|
|
17
17
|
|
|
18
|
-
const encoder = new TextEncoder();
|
|
19
|
-
|
|
20
18
|
function parseBoolean(value: string | null) {
|
|
21
19
|
return value === "true" || value === "1" || value === "yes";
|
|
22
20
|
}
|
|
@@ -52,16 +50,12 @@ function parseOptions(url: URL): PeriodDigestOptions {
|
|
|
52
50
|
};
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
function encodeEvent(event: PeriodDigestStreamEvent) {
|
|
56
|
-
return encoder.encode(`${JSON.stringify(event)}\n`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
53
|
export const Route = createFileRoute("/api/period-digest")({
|
|
60
54
|
server: {
|
|
61
55
|
handlers: {
|
|
62
56
|
GET: ({ request }) =>
|
|
63
57
|
runRouteEffect(
|
|
64
|
-
Effect.
|
|
58
|
+
Effect.sync(() => {
|
|
65
59
|
const denied = sensitiveRequestErrorResponse(request);
|
|
66
60
|
if (denied) return denied;
|
|
67
61
|
|
|
@@ -78,70 +72,28 @@ export const Route = createFileRoute("/api/period-digest")({
|
|
|
78
72
|
{ status: 400 },
|
|
79
73
|
);
|
|
80
74
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
return createEffectNdjsonResponse<PeriodDigestStreamEvent>({
|
|
76
|
+
request,
|
|
77
|
+
initialEvents: [
|
|
78
|
+
{
|
|
79
|
+
type: "status",
|
|
80
|
+
label: "Preparing local archive",
|
|
81
|
+
detail: "Checking for backup updates.",
|
|
88
82
|
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const closeController = () => {
|
|
97
|
-
request.signal.removeEventListener("abort", onAbort);
|
|
98
|
-
if (!closed) {
|
|
99
|
-
closed = true;
|
|
100
|
-
controller.close();
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
const onAbort = () => close();
|
|
104
|
-
request.signal.addEventListener("abort", onAbort, {
|
|
105
|
-
once: true,
|
|
106
|
-
});
|
|
107
|
-
abortDigest = close;
|
|
108
|
-
const enqueue = (event: PeriodDigestStreamEvent) => {
|
|
109
|
-
if (closed) return;
|
|
110
|
-
try {
|
|
111
|
-
controller.enqueue(encodeEvent(event));
|
|
112
|
-
} catch {
|
|
113
|
-
close();
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
runEffectBackground(
|
|
118
|
-
streamPeriodDigestEffect(
|
|
119
|
-
{ ...options, signal: abortController.signal },
|
|
120
|
-
{ onEvent: enqueue },
|
|
121
|
-
),
|
|
122
|
-
{
|
|
123
|
-
onSuccess: closeController,
|
|
124
|
-
onFailure: (error) => {
|
|
125
|
-
enqueue({
|
|
126
|
-
type: "error",
|
|
127
|
-
error:
|
|
128
|
-
error instanceof Error
|
|
129
|
-
? error.message
|
|
130
|
-
: "Digest failed",
|
|
131
|
-
});
|
|
132
|
-
closeController();
|
|
133
|
-
},
|
|
134
|
-
},
|
|
83
|
+
],
|
|
84
|
+
run: ({ signal, emit }) =>
|
|
85
|
+
Effect.gen(function* () {
|
|
86
|
+
yield* maybeAutoUpdateBackupEffect();
|
|
87
|
+
return yield* streamPeriodDigestEffect(
|
|
88
|
+
{ ...options, signal },
|
|
89
|
+
{ onEvent: emit },
|
|
135
90
|
);
|
|
136
|
-
},
|
|
91
|
+
}),
|
|
92
|
+
errorEvent: (error) => ({
|
|
93
|
+
type: "error",
|
|
94
|
+
error: error instanceof Error ? error.message : "Digest failed",
|
|
137
95
|
}),
|
|
138
|
-
|
|
139
|
-
headers: {
|
|
140
|
-
"cache-control": "no-store",
|
|
141
|
-
"content-type": "application/x-ndjson; charset=utf-8",
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
);
|
|
96
|
+
});
|
|
145
97
|
}),
|
|
146
98
|
),
|
|
147
99
|
},
|