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
+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;