birdclaw 0.6.0 → 0.8.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +32 -2
  3. package/package.json +29 -30
  4. package/scripts/build-docs-site.mjs +39 -12
  5. package/src/cli.ts +457 -26
  6. package/src/components/AppNav.tsx +10 -0
  7. package/src/components/MarkdownViewer.tsx +438 -72
  8. package/src/components/ProfileAnalysisStream.tsx +428 -0
  9. package/src/components/ProfilePreview.tsx +120 -9
  10. package/src/components/SavedTimelineView.tsx +30 -8
  11. package/src/components/SyncNowButton.tsx +5 -2
  12. package/src/components/TimelineCard.tsx +20 -4
  13. package/src/components/TimelineRouteFrame.tsx +16 -0
  14. package/src/components/TweetRichText.tsx +36 -12
  15. package/src/components/useTimelineRouteData.ts +74 -6
  16. package/src/lib/account-sync-job.ts +15 -3
  17. package/src/lib/archive-finder.ts +1 -1
  18. package/src/lib/archive-import.ts +245 -7
  19. package/src/lib/authored-live.ts +1 -0
  20. package/src/lib/avatar-cache.ts +50 -0
  21. package/src/lib/backup.ts +4 -3
  22. package/src/lib/bird.ts +33 -0
  23. package/src/lib/config.ts +35 -2
  24. package/src/lib/data-sources.ts +219 -0
  25. package/src/lib/db.ts +62 -1
  26. package/src/lib/geocoding.ts +296 -0
  27. package/src/lib/link-insights.ts +2 -0
  28. package/src/lib/location.ts +137 -0
  29. package/src/lib/mention-threads-live.ts +94 -1
  30. package/src/lib/mentions-live.ts +187 -40
  31. package/src/lib/network-map.ts +382 -0
  32. package/src/lib/period-digest.ts +468 -29
  33. package/src/lib/profile-analysis.ts +1272 -0
  34. package/src/lib/profile-bio-entities.ts +1 -1
  35. package/src/lib/queries.ts +14 -4
  36. package/src/lib/search-discussion.ts +1016 -0
  37. package/src/lib/timeline-live.ts +272 -19
  38. package/src/lib/tweet-account-edges.ts +2 -0
  39. package/src/lib/tweet-render.ts +141 -1
  40. package/src/lib/tweet-search-live.ts +565 -0
  41. package/src/lib/types.ts +37 -2
  42. package/src/lib/ui.ts +1 -1
  43. package/src/lib/web-sync.ts +7 -2
  44. package/src/lib/xurl-rate-limits.ts +267 -0
  45. package/src/lib/xurl.ts +551 -41
  46. package/src/routeTree.gen.ts +231 -0
  47. package/src/routes/__root.tsx +5 -6
  48. package/src/routes/api/data-sources.tsx +24 -0
  49. package/src/routes/api/network-map.tsx +55 -0
  50. package/src/routes/api/period-digest.tsx +29 -3
  51. package/src/routes/api/profile-analysis.tsx +152 -0
  52. package/src/routes/api/query.tsx +1 -0
  53. package/src/routes/api/search-discussion.tsx +169 -0
  54. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  55. package/src/routes/data-sources.tsx +257 -0
  56. package/src/routes/discuss.tsx +419 -0
  57. package/src/routes/network-map.tsx +1035 -0
  58. package/src/routes/profile-analyze.tsx +112 -0
  59. package/src/routes/profiles.$handle.tsx +228 -0
  60. package/src/routes/rate-limits.tsx +309 -0
  61. package/src/routes/today.tsx +22 -8
  62. package/src/styles.css +22 -0
@@ -0,0 +1,137 @@
1
+ const COMBINING_DIACRITIC_PATTERN = /[\u0300-\u036f]/g;
2
+ const COLLAPSED_WHITESPACE_PATTERN = /\s+/g;
3
+ const COORDINATE_CAPTURE_PATTERN =
4
+ /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/;
5
+ const LETTER_PATTERN = /[a-z]/i;
6
+ const URL_OR_HANDLE_PATTERN = /(https?:\/\/|@|[A-Z0-9._%+-]+@[A-Z0-9.-]+)/i;
7
+ const MONEY_PATTERN = /\$\s?\d/;
8
+ const EMOJI_BLOCK_PATTERN =
9
+ /[\u{1f1e6}-\u{1f1ff}\u{1f300}-\u{1faff}\u{2600}-\u{27bf}]/gu;
10
+
11
+ const GENERIC_LOCATIONS = new Set([
12
+ "earth",
13
+ "everywhere",
14
+ "internet",
15
+ "milky way",
16
+ "n/a",
17
+ "na",
18
+ "none",
19
+ "null",
20
+ "online",
21
+ "somewhere",
22
+ "space",
23
+ "the internet",
24
+ "the world",
25
+ "undefined",
26
+ "world",
27
+ "world wide web",
28
+ ]);
29
+
30
+ const DROP_WORDS = new Set([
31
+ "a",
32
+ "an",
33
+ "and",
34
+ "anywhere",
35
+ "around",
36
+ "at",
37
+ "between",
38
+ "by",
39
+ "everywhere",
40
+ "global",
41
+ "here",
42
+ "in",
43
+ "inside",
44
+ "my",
45
+ "near",
46
+ "nearby",
47
+ "of",
48
+ "on",
49
+ "remote",
50
+ "somewhere",
51
+ "the",
52
+ "there",
53
+ "where",
54
+ "worldwide",
55
+ ]);
56
+
57
+ const NON_LOCATION_PHRASES = [
58
+ /\b(check(out)? my|follow me|newsletter|subscribe|readers)\b/i,
59
+ /\b(in my (own )?head|right behind you|on my computer)\b/i,
60
+ /\b(dev\/null|dev null|xcode|vim|screen|framebuffer)\b/i,
61
+ /\b(blockchain|metaverse|omniverse|hyperamerica)\b/i,
62
+ ];
63
+
64
+ function parseCoordinatesFromString(input: string) {
65
+ const match = input.match(COORDINATE_CAPTURE_PATTERN);
66
+ if (!match) return null;
67
+ const lat = Number(match[1]);
68
+ const lng = Number(match[2]);
69
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
70
+ if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
71
+ return { lat, lng };
72
+ }
73
+
74
+ function stripJunkWords(part: string) {
75
+ const words = part
76
+ .split(" ")
77
+ .map((word) => word.trim())
78
+ .filter((word) => word.length > 0);
79
+ const kept = words.filter((word) => !DROP_WORDS.has(word));
80
+ return kept.join(" ").replace(COLLAPSED_WHITESPACE_PATTERN, " ").trim();
81
+ }
82
+
83
+ export function normalizeLocationKey(input: string): string {
84
+ const raw = String(input || "").trim();
85
+ if (!raw) return "";
86
+
87
+ if (URL_OR_HANDLE_PATTERN.test(raw) || MONEY_PATTERN.test(raw)) return "";
88
+ for (const pattern of NON_LOCATION_PHRASES) {
89
+ if (pattern.test(raw)) return "";
90
+ }
91
+
92
+ const coords = parseCoordinatesFromString(raw);
93
+ if (coords) {
94
+ return `coords:${Number(coords.lat.toFixed(6))},${Number(
95
+ coords.lng.toFixed(6),
96
+ )}`;
97
+ }
98
+
99
+ let value = raw
100
+ .toLowerCase()
101
+ .normalize("NFKD")
102
+ .replace(COMBINING_DIACRITIC_PATTERN, "")
103
+ .replace(EMOJI_BLOCK_PATTERN, "")
104
+ .replace(/[|;]+/g, ",")
105
+ .replace(/\s+(?:and|or|\/|-)\s+/g, ",")
106
+ .replace(/[\\/]+/g, ",")
107
+ .replace(/[“”"']/g, "")
108
+ .replace(COLLAPSED_WHITESPACE_PATTERN, " ")
109
+ .replace(/\s*,\s*/g, ",")
110
+ .replace(/,+/g, ",")
111
+ .replace(/[^a-z0-9, .-]/g, "")
112
+ .replace(/^[,\s.-]+|[,\s.-]+$/g, "");
113
+
114
+ if (!value || GENERIC_LOCATIONS.has(value)) return "";
115
+
116
+ const parts = value
117
+ .split(",")
118
+ .map((part) => stripJunkWords(part.trim()))
119
+ .filter((part) => part.length > 0 && !GENERIC_LOCATIONS.has(part));
120
+
121
+ value = parts.join(",");
122
+ if (!value || GENERIC_LOCATIONS.has(value)) return "";
123
+ return value.length <= 100 ? value : "";
124
+ }
125
+
126
+ export function isMeaningfulLocation(input: string): boolean {
127
+ const key = normalizeLocationKey(input);
128
+ if (!key) return false;
129
+ if (key.startsWith("coords:")) return true;
130
+ return LETTER_PATTERN.test(key) && key.length >= 2 && key.length <= 100;
131
+ }
132
+
133
+ export function coordinatesFromLocationKey(key: string) {
134
+ if (!key.startsWith("coords:")) return null;
135
+ const parsed = parseCoordinatesFromString(key.slice("coords:".length));
136
+ return parsed ? { ...parsed, provider: "coords" as const } : null;
137
+ }
@@ -23,14 +23,23 @@ const DEFAULT_FALLBACK_DEPTH = 12;
23
23
  const MAX_XURL_SEARCH_RESULTS = 100;
24
24
 
25
25
  export type MentionThreadsMode = "bird" | "xurl";
26
+ export interface MentionThreadsProgress {
27
+ source: MentionThreadsMode;
28
+ processed: number;
29
+ total: number;
30
+ fetched: number;
31
+ done: boolean;
32
+ }
26
33
  export interface SyncMentionThreadsOptions {
27
34
  account?: string;
28
35
  mode?: string;
29
36
  limit?: number;
37
+ tweetIds?: string[];
30
38
  delayMs?: number;
31
39
  timeoutMs?: number;
32
40
  all?: boolean;
33
41
  maxPages?: number;
42
+ onProgress?: (progress: MentionThreadsProgress) => void;
34
43
  }
35
44
 
36
45
  interface LocalMention {
@@ -268,6 +277,62 @@ function listRecentMentions(
268
277
  });
269
278
  }
270
279
 
280
+ function listMentionsByIds(
281
+ db: Database,
282
+ accountId: string,
283
+ tweetIds: string[],
284
+ limit: number,
285
+ ): LocalMention[] {
286
+ const ids = [...new Set(tweetIds.filter((id) => id.trim().length > 0))].slice(
287
+ 0,
288
+ limit,
289
+ );
290
+ if (ids.length === 0) return [];
291
+ const placeholders = ids.map(() => "?").join(", ");
292
+ const rows = db
293
+ .prepare(
294
+ `
295
+ select
296
+ t.id,
297
+ t.created_at as createdAt,
298
+ t.reply_to_id as replyToId,
299
+ coalesce(edge.raw_json, '{}') as rawJson
300
+ from tweets t
301
+ left join tweet_account_edges edge
302
+ on edge.tweet_id = t.id
303
+ and edge.account_id = ?
304
+ and edge.kind = 'mention'
305
+ where t.id in (${placeholders})
306
+ and (
307
+ edge.tweet_id is not null
308
+ or (t.kind = 'mention' and t.account_id = ?)
309
+ )
310
+ order by t.created_at desc
311
+ limit ?
312
+ `,
313
+ )
314
+ .all(accountId, ...ids, accountId, limit) as Array<{
315
+ id: string;
316
+ createdAt: string;
317
+ replyToId: string | null;
318
+ rawJson: string | null;
319
+ }>;
320
+
321
+ return rows.map((row) => {
322
+ const rawTweet = parseRawTweet(row.rawJson);
323
+ return {
324
+ id: row.id,
325
+ replyToId:
326
+ row.replyToId ?? (rawTweet ? getReplyToId(rawTweet) : undefined),
327
+ conversationId:
328
+ typeof rawTweet?.conversation_id === "string"
329
+ ? rawTweet.conversation_id
330
+ : undefined,
331
+ rawTweet,
332
+ };
333
+ });
334
+ }
335
+
271
336
  function mergeMentionThreadIntoLocalStore({
272
337
  db,
273
338
  accountId,
@@ -655,10 +720,12 @@ export function syncMentionThreadsEffect({
655
720
  account,
656
721
  mode = DEFAULT_MODE,
657
722
  limit = DEFAULT_LIMIT,
723
+ tweetIds,
658
724
  delayMs = DEFAULT_DELAY_MS,
659
725
  timeoutMs = DEFAULT_TIMEOUT_MS,
660
726
  all = false,
661
727
  maxPages,
728
+ onProgress,
662
729
  }: SyncMentionThreadsOptions) {
663
730
  return Effect.gen(function* () {
664
731
  const parsedMode = yield* trySync(() => parseMode(mode));
@@ -677,7 +744,14 @@ export function syncMentionThreadsEffect({
677
744
  const db = yield* trySync(() => getNativeDb());
678
745
  const resolvedAccount = yield* trySync(() => resolveAccount(db, account));
679
746
  const mentions = yield* trySync(() =>
680
- listRecentMentions(db, resolvedAccount.accountId, parsedLimit),
747
+ tweetIds
748
+ ? listMentionsByIds(
749
+ db,
750
+ resolvedAccount.accountId,
751
+ tweetIds,
752
+ parsedLimit,
753
+ )
754
+ : listRecentMentions(db, resolvedAccount.accountId, parsedLimit),
681
755
  );
682
756
  const mentionIds = mentions.map((mention) => mention.id);
683
757
  const mentionIdSet = new Set(mentionIds);
@@ -759,6 +833,15 @@ export function syncMentionThreadsEffect({
759
833
  ? fetched.error.message
760
834
  : String(fetched.error),
761
835
  });
836
+ yield* Effect.sync(() =>
837
+ onProgress?.({
838
+ source: parsedMode,
839
+ processed: index + 1,
840
+ total: mentions.length,
841
+ fetched: uniqueTweetIds.size,
842
+ done: index + 1 === mentions.length,
843
+ }),
844
+ );
762
845
  continue;
763
846
  }
764
847
 
@@ -782,6 +865,15 @@ export function syncMentionThreadsEffect({
782
865
  warnings:
783
866
  fetchResult.warnings.length > 0 ? fetchResult.warnings : undefined,
784
867
  });
868
+ yield* Effect.sync(() =>
869
+ onProgress?.({
870
+ source: parsedMode,
871
+ processed: index + 1,
872
+ total: mentions.length,
873
+ fetched: uniqueTweetIds.size,
874
+ done: index + 1 === mentions.length,
875
+ }),
876
+ );
785
877
  }
786
878
 
787
879
  const failures = results.filter((item) => !item.ok);
@@ -810,6 +902,7 @@ export function syncMentionThreadsEffect({
810
902
  all,
811
903
  maxPages: parsedMaxPages ?? null,
812
904
  maxFallbackDepth: DEFAULT_FALLBACK_DEPTH,
905
+ tweetIds: tweetIds ?? null,
813
906
  },
814
907
  results,
815
908
  failures,