feat(bookmarks): add thread expansion flags

Adds thread expansion + filtering for bookmarks, with optional ancestor branches and metadata.\n\nCo-authored-by: Klaus <[email protected]>
This commit is contained in:
clickingThingsAndMovingMountains
2026-06-24 18:44:38 +09:00
committed by yuta
parent 966599522a
commit 471cd9b26f
7 changed files with 406 additions and 6 deletions
+8
View File
@@ -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
+12 -1
View File
@@ -63,6 +63,7 @@ bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
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 "<query>" [-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 <tweet-id-or-url...>` — 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 <url>`: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`).
- `--profile-name <name>`: twitter safe relay profile name (sent as `x-profile-name`).
+162 -5
View File
@@ -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 <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', '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<string, TweetData[]>();
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<TweetData[] | null> => {
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<TweetData | TweetWithMeta> = 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 },
);
},
);
}
+146
View File
@@ -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<string>();
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<string, TweetData[]>();
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<string>();
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,
};
}
+7
View File
@@ -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;
+22
View File
@@ -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<Array<{ id?: string }>>(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,
+49
View File
@@ -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']);
});
});