birdclaw 0.1.0 → 0.2.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.
@@ -0,0 +1,115 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { TimelineCard } from "#/components/TimelineCard";
3
+ import type { QueryEnvelope, QueryResponse, TimelineItem } from "#/lib/types";
4
+ import {
5
+ cx,
6
+ eyebrowClass,
7
+ feedPageClass,
8
+ heroControlsClass,
9
+ heroCopyClass,
10
+ heroShellClass,
11
+ heroTitleClass,
12
+ pageWrapClass,
13
+ textFieldClass,
14
+ textFieldWideClass,
15
+ timelineLaneClass,
16
+ } from "#/lib/ui";
17
+
18
+ interface SavedTimelineViewProps {
19
+ filter: "liked" | "bookmarked";
20
+ eyebrow: string;
21
+ title: string;
22
+ loadingLabel: string;
23
+ searchPlaceholder: string;
24
+ }
25
+
26
+ export function SavedTimelineView({
27
+ filter,
28
+ eyebrow,
29
+ title,
30
+ loadingLabel,
31
+ searchPlaceholder,
32
+ }: SavedTimelineViewProps) {
33
+ const [meta, setMeta] = useState<QueryEnvelope | null>(null);
34
+ const [items, setItems] = useState<TimelineItem[]>([]);
35
+ const [search, setSearch] = useState("");
36
+ const [refreshTick, setRefreshTick] = useState(0);
37
+
38
+ useEffect(() => {
39
+ fetch("/api/status")
40
+ .then((response) => response.json())
41
+ .then((data: QueryEnvelope) => setMeta(data));
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ const url = new URL("/api/query", window.location.origin);
46
+ url.searchParams.set("resource", "home");
47
+ url.searchParams.set(filter, "true");
48
+ url.searchParams.set("refresh", String(refreshTick));
49
+ if (search.trim()) {
50
+ url.searchParams.set("search", search.trim());
51
+ }
52
+
53
+ fetch(url)
54
+ .then((response) => response.json())
55
+ .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
56
+ }, [filter, refreshTick, search]);
57
+
58
+ const subtitle = useMemo(() => {
59
+ if (!meta) {
60
+ return items.length > 0 ? `${items.length} visible` : loadingLabel;
61
+ }
62
+ return `${items.length} visible · ${meta.transport.statusText}`;
63
+ }, [items.length, loadingLabel, meta]);
64
+
65
+ async function replyToTweet(tweetId: string) {
66
+ const text = window.prompt("Reply text");
67
+ if (!text?.trim()) return;
68
+
69
+ await fetch("/api/action", {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json" },
72
+ body: JSON.stringify({
73
+ kind: "replyTweet",
74
+ accountId: "acct_primary",
75
+ tweetId,
76
+ text,
77
+ }),
78
+ });
79
+
80
+ setRefreshTick((value) => value + 1);
81
+ }
82
+
83
+ return (
84
+ <main className={pageWrapClass}>
85
+ <div className={feedPageClass}>
86
+ <section className={heroShellClass}>
87
+ <div>
88
+ <p className={eyebrowClass}>{eyebrow}</p>
89
+ <h2 className={heroTitleClass}>{title}</h2>
90
+ <p className={heroCopyClass}>{subtitle}</p>
91
+ </div>
92
+ <div className={heroControlsClass}>
93
+ <input
94
+ className={cx(textFieldClass, textFieldWideClass)}
95
+ onChange={(event) => setSearch(event.target.value)}
96
+ placeholder={searchPlaceholder}
97
+ value={search}
98
+ />
99
+ </div>
100
+ </section>
101
+
102
+ <section className={timelineLaneClass}>
103
+ {items.map((item) => (
104
+ <TimelineCard
105
+ key={item.id}
106
+ item={item}
107
+ onReply={replyToTweet}
108
+ showReplyControls={false}
109
+ />
110
+ ))}
111
+ </section>
112
+ </div>
113
+ </main>
114
+ );
115
+ }
@@ -34,10 +34,15 @@ function getVisibleUrlCards(item: TimelineItem) {
34
34
  export function TimelineCard({
35
35
  item,
36
36
  onReply,
37
+ showReplyControls = true,
37
38
  }: {
38
39
  item: TimelineItem;
39
40
  onReply: (tweetId: string) => void;
41
+ showReplyControls?: boolean;
40
42
  }) {
43
+ const canReply =
44
+ showReplyControls && item.kind !== "like" && item.kind !== "bookmark";
45
+
41
46
  return (
42
47
  <article className={contentCardClass}>
43
48
  <header className={cardHeaderClass}>
@@ -62,14 +67,16 @@ export function TimelineCard({
62
67
  </div>
63
68
  </div>
64
69
  <div className={metaStackClass}>
65
- <span
66
- className={cx(
67
- pillClass,
68
- item.isReplied ? pillSoftClass : pillAlertClass,
69
- )}
70
- >
71
- {item.isReplied ? "replied" : "needs reply"}
72
- </span>
70
+ {canReply ? (
71
+ <span
72
+ className={cx(
73
+ pillClass,
74
+ item.isReplied ? pillSoftClass : pillAlertClass,
75
+ )}
76
+ >
77
+ {item.isReplied ? "replied" : "needs reply"}
78
+ </span>
79
+ ) : null}
73
80
  <span className={timestampClass}>
74
81
  {formatShortTimestamp(item.createdAt)}
75
82
  </span>
@@ -83,9 +90,9 @@ export function TimelineCard({
83
90
  {item.quotedTweet ? (
84
91
  <EmbeddedTweetCard item={item.quotedTweet} label="Quoted tweet" />
85
92
  ) : null}
86
- {getVisibleUrlCards(item).map((entry) => (
93
+ {getVisibleUrlCards(item).map((entry, index) => (
87
94
  <a
88
- key={entry.expandedUrl}
95
+ key={`${entry.expandedUrl}-${String(index)}`}
89
96
  className={linkPreviewCardClass}
90
97
  href={entry.expandedUrl}
91
98
  rel="noreferrer"
@@ -105,13 +112,15 @@ export function TimelineCard({
105
112
  <span>{item.bookmarked ? "bookmarked" : "not bookmarked"}</span>
106
113
  <span>{item.accountHandle}</span>
107
114
  </div>
108
- <button
109
- className={actionButtonClass}
110
- onClick={() => onReply(item.id)}
111
- type="button"
112
- >
113
- Reply
114
- </button>
115
+ {canReply ? (
116
+ <button
117
+ className={actionButtonClass}
118
+ onClick={() => onReply(item.id)}
119
+ type="button"
120
+ >
121
+ Reply
122
+ </button>
123
+ ) : null}
115
124
  </footer>
116
125
  </article>
117
126
  );
@@ -25,6 +25,7 @@ interface ImportedArchiveSummary {
25
25
  counts: {
26
26
  tweets: number;
27
27
  likes: number;
28
+ bookmarks: number;
28
29
  dmConversations: number;
29
30
  dmMessages: number;
30
31
  profiles: number;
@@ -99,6 +100,10 @@ function parseTwitterDate(value: unknown) {
99
100
  : parsed.toISOString();
100
101
  }
101
102
 
103
+ function compareIsoTimestamp(left: string, right: string) {
104
+ return left < right ? -1 : left > right ? 1 : 0;
105
+ }
106
+
102
107
  function asRecord(value: unknown): Record<string, unknown> | null {
103
108
  return value && typeof value === "object" && !Array.isArray(value)
104
109
  ? (value as Record<string, unknown>)
@@ -211,6 +216,39 @@ function extractTweetMedia(tweet: Record<string, unknown>) {
211
216
  });
212
217
  }
213
218
 
219
+ function extractCollectionTweet(
220
+ wrapper: ArchiveRecord,
221
+ key: "like" | "bookmark",
222
+ ) {
223
+ const entry = asRecord(wrapper[key]) ?? asRecord(wrapper.tweet);
224
+ if (!entry) return null;
225
+
226
+ const id = String(
227
+ entry.tweetId ?? entry.tweet_id ?? entry.id_str ?? entry.id ?? "",
228
+ );
229
+ if (!id) return null;
230
+
231
+ return {
232
+ id,
233
+ text: String(
234
+ entry.fullText ??
235
+ entry.full_text ??
236
+ entry.text ??
237
+ entry.expandedUrl ??
238
+ entry.expanded_url ??
239
+ "",
240
+ ),
241
+ createdAt: parseTwitterDate(
242
+ entry.likedAt ??
243
+ entry.bookmarkedAt ??
244
+ entry.createdAt ??
245
+ entry.created_at ??
246
+ new Date(0).toISOString(),
247
+ ),
248
+ likeCount: toInt(entry.favorite_count ?? entry.like_count),
249
+ };
250
+ }
251
+
214
252
  function buildAccountPayload(
215
253
  accountRecord: Record<string, unknown> | null,
216
254
  profileRecord: Record<string, unknown> | null,
@@ -248,6 +286,7 @@ function clearImportedData() {
248
286
  db.exec(`
249
287
  delete from ai_scores;
250
288
  delete from tweet_actions;
289
+ delete from tweet_collections;
251
290
  delete from dm_fts;
252
291
  delete from tweets_fts;
253
292
  delete from dm_messages;
@@ -276,6 +315,10 @@ export async function importArchive(
276
315
  entries,
277
316
  /(?:^|\/)data\/(?:like|likes)(?:-part\d+)?\.js$/i,
278
317
  );
318
+ const bookmarkEntries = getMatchingEntries(
319
+ entries,
320
+ /(?:^|\/)data\/(?:bookmark|bookmarks)(?:-part\d+)?\.js$/i,
321
+ );
279
322
  const dmEntries = getMatchingEntries(
280
323
  entries,
281
324
  /(?:^|\/)data\/direct-messages(?:-group)?(?:-part\d+)?\.js$/i,
@@ -303,16 +346,40 @@ export async function importArchive(
303
346
  >();
304
347
  const tweetRows: Array<{
305
348
  id: string;
349
+ kind: "home" | "like" | "bookmark";
350
+ authorProfileId: string;
306
351
  text: string;
307
352
  createdAt: string;
308
353
  isReplied: number;
309
354
  replyToId: string | null;
310
355
  likeCount: number;
311
356
  mediaCount: number;
357
+ bookmarked: number;
358
+ liked: number;
312
359
  entitiesJson: string;
313
360
  mediaJson: string;
314
361
  quotedTweetId: string | null;
315
362
  }> = [];
363
+ const collectionRows: Array<{
364
+ tweetId: string;
365
+ kind: "likes" | "bookmarks";
366
+ collectedAt: string | null;
367
+ source: string;
368
+ rawJson: string;
369
+ }> = [];
370
+ const tweetRowsById = new Map<string, (typeof tweetRows)[number]>();
371
+
372
+ function addTweetRow(row: (typeof tweetRows)[number]) {
373
+ const existing = tweetRowsById.get(row.id);
374
+ if (existing) {
375
+ existing.bookmarked = Math.max(existing.bookmarked, row.bookmarked);
376
+ existing.liked = Math.max(existing.liked, row.liked);
377
+ if (!existing.text && row.text) existing.text = row.text;
378
+ return;
379
+ }
380
+ tweetRows.push(row);
381
+ tweetRowsById.set(row.id, row);
382
+ }
316
383
 
317
384
  for (const entry of tweetEntries) {
318
385
  const content = await readArchiveEntry(archivePath, entry);
@@ -342,8 +409,10 @@ export async function importArchive(
342
409
  });
343
410
  }
344
411
 
345
- tweetRows.push({
412
+ addTweetRow({
346
413
  id: String(tweet.id_str ?? tweet.id),
414
+ kind: "home",
415
+ authorProfileId: "profile_me",
347
416
  text: String(tweet.full_text ?? tweet.text ?? ""),
348
417
  createdAt: parseTwitterDate(tweet.created_at),
349
418
  isReplied: tweet.in_reply_to_status_id_str ? 1 : 0,
@@ -352,6 +421,8 @@ export async function importArchive(
352
421
  : null,
353
422
  likeCount: toInt(tweet.favorite_count),
354
423
  mediaCount: getTweetMediaCount(tweet),
424
+ bookmarked: 0,
425
+ liked: 0,
355
426
  entitiesJson: JSON.stringify(extractTweetEntities(tweet)),
356
427
  mediaJson: JSON.stringify(extractTweetMedia(tweet)),
357
428
  quotedTweetId: tweet.quoted_status_id_str
@@ -367,20 +438,25 @@ export async function importArchive(
367
438
  const noteTweet = asRecord(wrapper.noteTweet);
368
439
  if (!noteTweet) continue;
369
440
  const core = asRecord(noteTweet.core);
370
- tweetRows.push({
441
+ addTweetRow({
371
442
  id: String(noteTweet.noteTweetId ?? noteTweet.id ?? randomUUID()),
443
+ kind: "home",
444
+ authorProfileId: "profile_me",
372
445
  text: String(core?.text ?? ""),
373
446
  createdAt: parseTwitterDate(noteTweet.createdAt),
374
447
  isReplied: 0,
375
448
  replyToId: null,
376
449
  likeCount: 0,
377
450
  mediaCount: 0,
451
+ bookmarked: 0,
452
+ liked: 0,
378
453
  entitiesJson: "{}",
379
454
  mediaJson: "[]",
380
455
  quotedTweetId: null,
381
456
  });
382
457
  }
383
458
  }
459
+ const authoredTweetCount = tweetRows.length;
384
460
 
385
461
  type MessageRow = {
386
462
  id: string;
@@ -567,10 +643,8 @@ export async function importArchive(
567
643
  mediaCount: asArray(messageCreate.mediaUrls).length,
568
644
  } satisfies MessageRow;
569
645
  })
570
- .sort(
571
- (left, right) =>
572
- new Date(left.createdAt).getTime() -
573
- new Date(right.createdAt).getTime(),
646
+ .sort((left, right) =>
647
+ compareIsoTimestamp(left.createdAt, right.createdAt),
574
648
  );
575
649
 
576
650
  if (messageEvents.length === 0) {
@@ -596,18 +670,92 @@ export async function importArchive(
596
670
  }
597
671
  }
598
672
 
599
- const likeCount = likeEntries.reduce(async (countPromise, entry) => {
600
- const count = await countPromise;
673
+ let likeCount = 0;
674
+ for (const entry of likeEntries) {
675
+ const content = await readArchiveEntry(archivePath, entry);
676
+ const likes = parseArchiveArray(content);
677
+ for (const like of likes) {
678
+ const tweet = extractCollectionTweet(like, "like");
679
+ if (!tweet) continue;
680
+ collectionRows.push({
681
+ tweetId: tweet.id,
682
+ kind: "likes",
683
+ collectedAt: tweet.createdAt,
684
+ source: "archive",
685
+ rawJson: JSON.stringify(like),
686
+ });
687
+ addTweetRow({
688
+ id: tweet.id,
689
+ kind: "like",
690
+ authorProfileId: "profile_unknown",
691
+ text: tweet.text,
692
+ createdAt: tweet.createdAt,
693
+ isReplied: 0,
694
+ replyToId: null,
695
+ likeCount: tweet.likeCount,
696
+ mediaCount: 0,
697
+ bookmarked: 0,
698
+ liked: 1,
699
+ entitiesJson: "{}",
700
+ mediaJson: "[]",
701
+ quotedTweetId: null,
702
+ });
703
+ }
704
+ likeCount += likes.length;
705
+ }
706
+
707
+ let bookmarkCount = 0;
708
+ for (const entry of bookmarkEntries) {
601
709
  const content = await readArchiveEntry(archivePath, entry);
602
- return count + parseArchiveArray(content).length;
603
- }, Promise.resolve(0));
710
+ const bookmarks = parseArchiveArray(content);
711
+ for (const bookmark of bookmarks) {
712
+ const tweet = extractCollectionTweet(bookmark, "bookmark");
713
+ if (!tweet) continue;
714
+ collectionRows.push({
715
+ tweetId: tweet.id,
716
+ kind: "bookmarks",
717
+ collectedAt: tweet.createdAt,
718
+ source: "archive",
719
+ rawJson: JSON.stringify(bookmark),
720
+ });
721
+ addTweetRow({
722
+ id: tweet.id,
723
+ kind: "bookmark",
724
+ authorProfileId: "profile_unknown",
725
+ text: tweet.text,
726
+ createdAt: tweet.createdAt,
727
+ isReplied: 0,
728
+ replyToId: null,
729
+ likeCount: tweet.likeCount,
730
+ mediaCount: 0,
731
+ bookmarked: 1,
732
+ liked: 0,
733
+ entitiesJson: "{}",
734
+ mediaJson: "[]",
735
+ quotedTweetId: null,
736
+ });
737
+ }
738
+ bookmarkCount += bookmarks.length;
739
+ }
740
+
741
+ if (tweetRows.some((tweet) => tweet.authorProfileId === "profile_unknown")) {
742
+ profiles.set("profile_unknown", {
743
+ id: "profile_unknown",
744
+ handle: "unknown",
745
+ displayName: "Unknown",
746
+ bio: "Imported from archive collection metadata",
747
+ followersCount: 0,
748
+ avatarHue: 210,
749
+ createdAt: accountPayload.createdAt,
750
+ });
751
+ }
604
752
 
605
753
  clearImportedData();
606
754
 
607
755
  const db = getNativeDb();
608
756
  const insertAccount = db.prepare(`
609
- insert into accounts (id, name, handle, transport, is_default, created_at)
610
- values (?, ?, ?, ?, 1, ?)
757
+ insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
758
+ values (?, ?, ?, ?, ?, 1, ?)
611
759
  `);
612
760
  const insertProfile = db.prepare(`
613
761
  insert into profiles (id, handle, display_name, bio, followers_count, avatar_hue, avatar_url, created_at)
@@ -617,11 +765,21 @@ export async function importArchive(
617
765
  insert into tweets (
618
766
  id, account_id, author_profile_id, kind, text, created_at, is_replied,
619
767
  reply_to_id, like_count, media_count, bookmarked, liked, entities_json, media_json, quoted_tweet_id
620
- ) values (?, ?, ?, 'home', ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
768
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
621
769
  `);
622
770
  const insertTweetFts = db.prepare(
623
771
  "insert into tweets_fts (tweet_id, text) values (?, ?)",
624
772
  );
773
+ const insertCollection = db.prepare(`
774
+ insert into tweet_collections (
775
+ account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
776
+ ) values (?, ?, ?, ?, ?, ?, ?)
777
+ on conflict(account_id, tweet_id, kind) do update set
778
+ collected_at = coalesce(excluded.collected_at, tweet_collections.collected_at),
779
+ source = excluded.source,
780
+ raw_json = excluded.raw_json,
781
+ updated_at = excluded.updated_at
782
+ `);
625
783
  const insertConversation = db.prepare(`
626
784
  insert into dm_conversations (
627
785
  id, account_id, participant_profile_id, title, last_message_at, unread_count, needs_reply
@@ -641,6 +799,7 @@ export async function importArchive(
641
799
  "acct_primary",
642
800
  accountPayload.displayName,
643
801
  `@${accountPayload.username}`,
802
+ accountPayload.accountId,
644
803
  "archive",
645
804
  accountPayload.createdAt,
646
805
  );
@@ -662,13 +821,16 @@ export async function importArchive(
662
821
  insertTweet.run(
663
822
  tweet.id,
664
823
  "acct_primary",
665
- "profile_me",
824
+ tweet.authorProfileId,
825
+ tweet.kind,
666
826
  tweet.text,
667
827
  tweet.createdAt,
668
828
  tweet.isReplied,
669
829
  tweet.replyToId,
670
830
  tweet.likeCount,
671
831
  tweet.mediaCount,
832
+ tweet.bookmarked,
833
+ tweet.liked,
672
834
  tweet.entitiesJson,
673
835
  tweet.mediaJson,
674
836
  tweet.quotedTweetId,
@@ -676,6 +838,19 @@ export async function importArchive(
676
838
  insertTweetFts.run(tweet.id, tweet.text);
677
839
  }
678
840
 
841
+ const importedAt = new Date().toISOString();
842
+ for (const collection of collectionRows) {
843
+ insertCollection.run(
844
+ "acct_primary",
845
+ collection.tweetId,
846
+ collection.kind,
847
+ collection.collectedAt,
848
+ collection.source,
849
+ collection.rawJson,
850
+ importedAt,
851
+ );
852
+ }
853
+
679
854
  for (const conversation of conversations.values()) {
680
855
  insertConversation.run(
681
856
  conversation.id,
@@ -712,8 +887,9 @@ export async function importArchive(
712
887
  displayName: accountPayload.displayName,
713
888
  },
714
889
  counts: {
715
- tweets: tweetRows.length,
716
- likes: await likeCount,
890
+ tweets: authoredTweetCount,
891
+ likes: likeCount,
892
+ bookmarks: bookmarkCount,
717
893
  dmConversations: conversations.size,
718
894
  dmMessages: dmMessages.length,
719
895
  profiles: profiles.size,
@@ -728,6 +904,7 @@ export const __test__ = {
728
904
  asRecord,
729
905
  asArray,
730
906
  toInt,
907
+ compareIsoTimestamp,
731
908
  getTweetMediaCount,
732
909
  extractTweetEntities,
733
910
  extractTweetMedia,