diff --git a/CHANGELOG.md b/CHANGELOG.md index a735361..55268a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.7.1 — 2026-01-19 + +### Added +- `bookmarks` thread expansion controls (`--expand-root-only`, `--author-chain`, `--author-only`, `--full-chain-only`, `--include-ancestor-branches`, `--include-parent`, `--thread-meta`, `--sort-chronological`) for richer context exports (#55) — thanks @kkretschmer2. + +### Fixed +- `bookmarks` expanded JSON now preserves pagination `nextCursor`, and full-chain filtering only includes ancestor branches when requested. + ## 0.7.0 — 2026-01-12 ### Added diff --git a/README.md b/README.md index 0de03a9..7a316ce 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ bird bookmarks -n 5 bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ bird bookmarks --all --json bird bookmarks --all --max-pages 2 --json +bird bookmarks --include-parent --json bird unbookmark 1234567890123456789 bird unbookmark https://x.com/user/status/1234567890123456789 @@ -182,7 +183,7 @@ Fields: - `bird search "" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline. -- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all` or `--cursor`. +- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`. - `bird unbookmark ` — remove one or more bookmarks by tweet ID or URL. - `bird likes [-n count] [--all] [--max-pages n] [--cursor string] [--json] [--json-full]` — list your liked tweets; `--max-pages` requires `--all` or `--cursor`. - `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs. @@ -195,6 +196,16 @@ Fields: - `bird whoami` — print which Twitter account your relay belongs to. - `bird check` — verify relay configuration and account access. +Bookmarks flags: +- `--expand-root-only`: expand threads only when the bookmark is a root tweet. +- `--author-chain`: keep only the bookmarked author's connected self-reply chain. +- `--author-only`: include all tweets from the bookmarked author within the thread. +- `--full-chain-only`: keep the entire reply chain connected to the bookmarked tweet (all authors). +- `--include-ancestor-branches`: include sibling branches for ancestors when using `--full-chain-only`. +- `--include-parent`: include the direct parent tweet for non-root bookmarks. +- `--thread-meta`: add thread metadata fields to each tweet. +- `--sort-chronological`: sort output globally oldest to newest (default preserves bookmark order). + Global options: - `--relay-base-url `: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`). - `--profile-name `: twitter safe relay profile name (sent as `x-profile-name`). diff --git a/src/commands/bookmarks.ts b/src/commands/bookmarks.ts index bb6412d..a1f4609 100644 --- a/src/commands/bookmarks.ts +++ b/src/commands/bookmarks.ts @@ -2,7 +2,14 @@ import type { Command } from 'commander'; import { parsePaginationFlags } from '../cli/pagination.js'; import type { CliContext } from '../cli/shared.js'; import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.js'; +import { + addThreadMetadata, + filterAuthorChain, + filterAuthorOnly, + filterFullChain, +} from '../lib/thread-filters.js'; import { TwitterClient } from '../lib/twitter-client.js'; +import type { TweetData, TweetWithMeta } from '../lib/twitter-client-types.js'; export function registerBookmarksCommand(program: Command, ctx: CliContext): void { program @@ -13,6 +20,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi .option('--all', 'Fetch all bookmarks (paged)') .option('--max-pages ', 'Stop after N pages when using --all') .option('--cursor ', 'Resume pagination from a cursor') + .option('--expand-root-only', 'Only expand threads when bookmarked tweet is root') + .option('--author-chain', 'Only include author self-reply chains connected to the bookmark') + .option('--author-only', 'Include all tweets from bookmarked tweet author in thread') + .option('--full-chain-only', 'Save entire reply chain connected to the bookmarked tweet') + .option('--include-ancestor-branches', 'Include sibling branches for ancestors when using --full-chain-only') + .option('--include-parent', 'Include direct parent tweet for non-root bookmarks') + .option('--thread-meta', 'Add metadata fields (isThread, threadPosition, etc.)') + .option('--sort-chronological', 'Sort output globally oldest -> newest') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') .action( @@ -24,6 +39,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi all?: boolean; maxPages?: string; cursor?: string; + expandRootOnly?: boolean; + authorChain?: boolean; + authorOnly?: boolean; + fullChainOnly?: boolean; + includeAncestorBranches?: boolean; + includeParent?: boolean; + threadMeta?: boolean; + sortChronological?: boolean; }) => { const opts = program.opts(); const timeoutMs = ctx.resolveTimeoutFromOptions(opts); @@ -65,14 +88,148 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi ? await client.getAllBookmarks(paginationOptions) : await client.getBookmarks(count, timelineOptions); - if (result.success) { - const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; - const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); - ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage }); - } else { + if (!result.success) { console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`); process.exit(1); } + + if (cmdOpts.authorChain && (cmdOpts.authorOnly || cmdOpts.fullChainOnly)) { + console.error( + `${ctx.p('warn')}--author-chain already limits to the connected self-reply chain; ` + + 'other chain filters are redundant.', + ); + } + if (cmdOpts.includeAncestorBranches && !cmdOpts.fullChainOnly) { + console.error(`${ctx.p('warn')}--include-ancestor-branches only applies with --full-chain-only.`); + } + + const bookmarks = result.tweets; + if (!bookmarks || bookmarks.length === 0) { + const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; + const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); + ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage }); + return; + } + + const expandedResults: TweetData[] = []; + const threadCache = new Map(); + const includeMeta = Boolean(cmdOpts.threadMeta); + const includeParent = Boolean(cmdOpts.includeParent); + const expandRootOnly = Boolean(cmdOpts.expandRootOnly); + const filterAuthorChainFlag = Boolean(cmdOpts.authorChain); + const filterAuthorOnlyFlag = Boolean(cmdOpts.authorOnly); + const filterFullChainFlag = Boolean(cmdOpts.fullChainOnly); + const includeAncestorBranches = Boolean(cmdOpts.includeAncestorBranches) && filterFullChainFlag; + const useChronologicalSort = Boolean(cmdOpts.sortChronological); + + const shouldAttemptExpand = + expandRootOnly || filterAuthorChainFlag || filterAuthorOnlyFlag || filterFullChainFlag; + const shouldFetchThread = shouldAttemptExpand || includeMeta; + + const fetchThread = async (tweet: TweetData): Promise => { + const cachedKey = tweet.conversationId ?? tweet.id; + const cached = threadCache.get(cachedKey); + if (cached) { + return cached; + } + + const threadResult = await client.getThread(tweet.id, { includeRaw }); + if (!threadResult.success) { + console.error( + `${ctx.p('warn')}Failed to expand thread for ${tweet.id}: ${threadResult.error ?? 'Unknown error'}`, + ); + return null; + } + if (!threadResult.tweets) { + console.error(`${ctx.p('warn')}No thread tweets returned for ${tweet.id}.`); + return null; + } + + const rootKey = threadResult.tweets[0]?.conversationId ?? cachedKey; + threadCache.set(rootKey, threadResult.tweets); + return threadResult.tweets; + }; + + const delayBetweenExpansionsMs = 1000; + + for (let index = 0; index < bookmarks.length; index += 1) { + const bookmark = bookmarks[index]; + const isRoot = !bookmark.inReplyToStatusId; + let threadTweets: TweetData[] | null = null; + + if (shouldFetchThread) { + if (!expandRootOnly || isRoot || includeMeta) { + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, delayBetweenExpansionsMs)); + } + threadTweets = await fetchThread(bookmark); + } + } + + let outputTweets: TweetData[] = [bookmark]; + + if (shouldAttemptExpand) { + if (expandRootOnly && !isRoot) { + outputTweets = [bookmark]; + } else if (threadTweets) { + if (filterAuthorChainFlag) { + outputTweets = filterAuthorChain(threadTweets, bookmark); + } else { + outputTweets = filterFullChainFlag + ? filterFullChain(threadTweets, bookmark, { includeAncestorBranches }) + : threadTweets; + if (filterAuthorOnlyFlag) { + outputTweets = filterAuthorOnly(outputTweets, bookmark); + } + } + } + } + + if (includeParent && bookmark.inReplyToStatusId) { + const alreadyIncluded = outputTweets.some((tweet) => tweet.id === bookmark.inReplyToStatusId); + if (!alreadyIncluded) { + const parentFromThread = threadTweets?.find((tweet) => tweet.id === bookmark.inReplyToStatusId); + if (parentFromThread) { + expandedResults.push(parentFromThread); + } else { + const parentResult = await client.getTweet(bookmark.inReplyToStatusId, { includeRaw }); + if (parentResult.success && parentResult.tweet) { + expandedResults.push(parentResult.tweet); + } + } + } + } + + expandedResults.push(...outputTweets); + } + + let finalResults: Array = expandedResults; + if (includeMeta) { + finalResults = expandedResults.map((tweet) => { + const cacheKey = tweet.conversationId ?? tweet.id; + let conversationTweets = threadCache.get(cacheKey); + if (!conversationTweets) { + conversationTweets = [tweet]; + } + return addThreadMetadata(tweet, conversationTweets); + }); + } + + const uniqueTweets = Array.from(new Map(finalResults.map((tweet) => [tweet.id, tweet])).values()); + if (useChronologicalSort) { + uniqueTweets.sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return aTime - bTime; + }); + } + + const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; + const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); + ctx.printTweetsResult( + { tweets: uniqueTweets, nextCursor: result.nextCursor }, + { json: isJson, usePagination, emptyMessage }, + ); }, ); } diff --git a/src/lib/thread-filters.ts b/src/lib/thread-filters.ts new file mode 100644 index 0000000..8d25868 --- /dev/null +++ b/src/lib/thread-filters.ts @@ -0,0 +1,146 @@ +import type { TweetData, TweetWithMeta } from './twitter-client-types.js'; + +const sortByCreatedAt = (tweets: TweetData[]): TweetData[] => + tweets.slice().sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return aTime - bTime; + }); + +export function filterAuthorChain(tweets: TweetData[], bookmarkedTweet: TweetData): TweetData[] { + const author = bookmarkedTweet.author.username; + const byId = new Map(tweets.map((tweet) => [tweet.id, tweet])); + const chainIds = new Set(); + + let current: TweetData | undefined = bookmarkedTweet; + while (current && current.author.username === author) { + chainIds.add(current.id); + const parentId = current.inReplyToStatusId; + if (!parentId) { + break; + } + const parent = byId.get(parentId); + if (!parent || parent.author.username !== author) { + break; + } + current = parent; + } + + let changed = true; + while (changed) { + changed = false; + for (const tweet of tweets) { + if (tweet.author.username !== author) { + continue; + } + if (chainIds.has(tweet.id)) { + continue; + } + if (tweet.inReplyToStatusId && chainIds.has(tweet.inReplyToStatusId)) { + chainIds.add(tweet.id); + changed = true; + } + } + } + + return sortByCreatedAt(tweets.filter((tweet) => chainIds.has(tweet.id))); +} + +export function filterAuthorOnly(tweets: TweetData[], bookmarkedTweet: TweetData): TweetData[] { + const author = bookmarkedTweet.author.username; + return tweets.filter((tweet) => tweet.author.username === author); +} + +export function filterFullChain( + tweets: TweetData[], + bookmarkedTweet: TweetData, + options: { includeAncestorBranches?: boolean } = {}, +): TweetData[] { + const byId = new Map(tweets.map((tweet) => [tweet.id, tweet])); + const repliesByParent = new Map(); + + for (const tweet of tweets) { + if (!tweet.inReplyToStatusId) { + continue; + } + const list = repliesByParent.get(tweet.inReplyToStatusId) ?? []; + list.push(tweet); + repliesByParent.set(tweet.inReplyToStatusId, list); + } + + const chainIds = new Set(); + const ancestorIds: string[] = []; + + chainIds.add(bookmarkedTweet.id); + let current: TweetData | undefined = bookmarkedTweet; + while (current?.inReplyToStatusId) { + const parent = byId.get(current.inReplyToStatusId); + if (!parent) { + break; + } + if (!chainIds.has(parent.id)) { + chainIds.add(parent.id); + ancestorIds.push(parent.id); + } + current = parent; + } + + const addDescendants = (startIds: string[]) => { + const queue = [...startIds]; + while (queue.length > 0) { + const currentId = queue.shift(); + if (!currentId) { + continue; + } + if (!chainIds.has(currentId)) { + chainIds.add(currentId); + } + const replies = repliesByParent.get(currentId) ?? []; + for (const reply of replies) { + if (chainIds.has(reply.id)) { + continue; + } + chainIds.add(reply.id); + queue.push(reply.id); + } + } + }; + + addDescendants([bookmarkedTweet.id]); + + if (options.includeAncestorBranches) { + for (const ancestorId of ancestorIds) { + addDescendants([ancestorId]); + } + } + + return sortByCreatedAt(tweets.filter((tweet) => chainIds.has(tweet.id))); +} + +export function addThreadMetadata(tweet: TweetData, allConversationTweets: TweetData[]): TweetWithMeta { + const author = tweet.author.username; + const hasSelfReplies = allConversationTweets.some( + (candidate) => candidate.inReplyToStatusId === tweet.id && candidate.author.username === author, + ); + + const isRoot = !tweet.inReplyToStatusId; + let threadPosition: 'root' | 'middle' | 'end' | 'standalone'; + + if (isRoot && !hasSelfReplies) { + threadPosition = 'standalone'; + } else if (isRoot && hasSelfReplies) { + threadPosition = 'root'; + } else if (!isRoot && hasSelfReplies) { + threadPosition = 'middle'; + } else { + threadPosition = 'end'; + } + + return { + ...tweet, + isThread: hasSelfReplies || !isRoot, + threadPosition, + hasSelfReplies, + threadRootId: tweet.conversationId ?? null, + }; +} diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index e14cc04..4896303 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -284,6 +284,13 @@ export interface TweetData { _raw?: GraphqlTweetResult; } +export interface TweetWithMeta extends TweetData { + isThread: boolean; + threadPosition: 'root' | 'middle' | 'end' | 'standalone'; + hasSelfReplies: boolean; + threadRootId: string | null; +} + export interface GetTweetResult { success: boolean; tweet?: TweetData; diff --git a/tests/live/live-all.test.ts b/tests/live/live-all.test.ts index fcb3d20..5e01c24 100644 --- a/tests/live/live-all.test.ts +++ b/tests/live/live-all.test.ts @@ -247,6 +247,28 @@ d('live CLI (Twitter/X) all commands', () => { expect(Array.isArray(bookmarkTweets)).toBe(true); }); + it('bookmarks full-chain-only with ancestor branches returns JSON array', async () => { + const bookmarks = await runBird( + [ + ...baseArgs, + '--cookie-timeout', + cookieTimeoutArg, + 'bookmarks', + '-n', + '1', + '--full-chain-only', + '--include-ancestor-branches', + '--json', + ], + { + timeoutMs: 45_000, + }, + ); + expect(bookmarks.exitCode).toBe(0); + const bookmarkTweets = parseJson>(bookmarks.stdout); + expect(Array.isArray(bookmarkTweets)).toBe(true); + }); + it('likes returns JSON array', async () => { const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], { timeoutMs: 45_000, diff --git a/tests/thread-filters.test.ts b/tests/thread-filters.test.ts new file mode 100644 index 0000000..e074f8c --- /dev/null +++ b/tests/thread-filters.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { filterFullChain } from '../src/lib/thread-filters.js'; +import type { TweetData } from '../src/lib/twitter-client-types.js'; + +const makeTweet = ( + id: string, + createdAt: string, + inReplyToStatusId?: string, + conversationId = '1', +): TweetData => ({ + id, + text: id, + author: { username: 'alice', name: 'Alice' }, + createdAt, + inReplyToStatusId, + conversationId, +}); + +describe('filterFullChain', () => { + it('returns ancestors + descendants from the bookmark only', () => { + const root = makeTweet('1', '2020-01-01T00:00:00Z'); + const replyA = makeTweet('2', '2020-01-02T00:00:00Z', '1'); + const bookmark = makeTweet('3', '2020-01-03T00:00:00Z', '2'); + const childA = makeTweet('4', '2020-01-04T00:00:00Z', '3'); + const childB = makeTweet('7', '2020-01-05T00:00:00Z', '4'); + const siblingOfAncestor = makeTweet('5', '2020-01-06T00:00:00Z', '2'); + const siblingOfRoot = makeTweet('6', '2020-01-07T00:00:00Z', '1'); + + const tweets = [siblingOfAncestor, childB, root, siblingOfRoot, bookmark, childA, replyA]; + + const result = filterFullChain(tweets, bookmark); + expect(result.map((tweet) => tweet.id)).toEqual(['1', '2', '3', '4', '7']); + }); + + it('includes ancestor branches when requested', () => { + const root = makeTweet('1', '2020-01-01T00:00:00Z'); + const replyA = makeTweet('2', '2020-01-02T00:00:00Z', '1'); + const bookmark = makeTweet('3', '2020-01-03T00:00:00Z', '2'); + const childA = makeTweet('4', '2020-01-04T00:00:00Z', '3'); + const childB = makeTweet('7', '2020-01-05T00:00:00Z', '4'); + const siblingOfAncestor = makeTweet('5', '2020-01-06T00:00:00Z', '2'); + const siblingOfRoot = makeTweet('6', '2020-01-07T00:00:00Z', '1'); + + const tweets = [siblingOfRoot, childA, replyA, childB, bookmark, root, siblingOfAncestor]; + + const result = filterFullChain(tweets, bookmark, { includeAncestorBranches: true }); + expect(result.map((tweet) => tweet.id)).toEqual(['1', '2', '3', '4', '7', '5', '6']); + }); +});