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:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 0.7.0 — 2026-01-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ bird bookmarks -n 5
|
|||||||
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
||||||
bird bookmarks --all --json
|
bird bookmarks --all --json
|
||||||
bird bookmarks --all --max-pages 2 --json
|
bird bookmarks --all --max-pages 2 --json
|
||||||
|
bird bookmarks --include-parent --json
|
||||||
bird unbookmark 1234567890123456789
|
bird unbookmark 1234567890123456789
|
||||||
bird unbookmark https://x.com/user/status/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 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 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 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 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 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.
|
- `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 whoami` — print which Twitter account your relay belongs to.
|
||||||
- `bird check` — verify relay configuration and account access.
|
- `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:
|
Global options:
|
||||||
- `--relay-base-url <url>`: twitter safe relay base URL (defaults to `TWITTER_RELAY_BASE_URL`).
|
- `--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`).
|
- `--profile-name <name>`: twitter safe relay profile name (sent as `x-profile-name`).
|
||||||
|
|||||||
+162
-5
@@ -2,7 +2,14 @@ import type { Command } from 'commander';
|
|||||||
import { parsePaginationFlags } from '../cli/pagination.js';
|
import { parsePaginationFlags } from '../cli/pagination.js';
|
||||||
import type { CliContext } from '../cli/shared.js';
|
import type { CliContext } from '../cli/shared.js';
|
||||||
import { extractBookmarkFolderId } from '../lib/extract-bookmark-folder-id.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 { TwitterClient } from '../lib/twitter-client.js';
|
||||||
|
import type { TweetData, TweetWithMeta } from '../lib/twitter-client-types.js';
|
||||||
|
|
||||||
export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
|
export function registerBookmarksCommand(program: Command, ctx: CliContext): void {
|
||||||
program
|
program
|
||||||
@@ -13,6 +20,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
.option('--all', 'Fetch all bookmarks (paged)')
|
.option('--all', 'Fetch all bookmarks (paged)')
|
||||||
.option('--max-pages <number>', 'Stop after N pages when using --all')
|
.option('--max-pages <number>', 'Stop after N pages when using --all')
|
||||||
.option('--cursor <string>', 'Resume pagination from a cursor')
|
.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', 'Output as JSON')
|
||||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
.action(
|
.action(
|
||||||
@@ -24,6 +39,14 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
all?: boolean;
|
all?: boolean;
|
||||||
maxPages?: string;
|
maxPages?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
expandRootOnly?: boolean;
|
||||||
|
authorChain?: boolean;
|
||||||
|
authorOnly?: boolean;
|
||||||
|
fullChainOnly?: boolean;
|
||||||
|
includeAncestorBranches?: boolean;
|
||||||
|
includeParent?: boolean;
|
||||||
|
threadMeta?: boolean;
|
||||||
|
sortChronological?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
@@ -65,14 +88,148 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
? await client.getAllBookmarks(paginationOptions)
|
? await client.getAllBookmarks(paginationOptions)
|
||||||
: await client.getBookmarks(count, timelineOptions);
|
: await client.getBookmarks(count, timelineOptions);
|
||||||
|
|
||||||
if (result.success) {
|
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 {
|
|
||||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||||
process.exit(1);
|
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 },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -284,6 +284,13 @@ export interface TweetData {
|
|||||||
_raw?: GraphqlTweetResult;
|
_raw?: GraphqlTweetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TweetWithMeta extends TweetData {
|
||||||
|
isThread: boolean;
|
||||||
|
threadPosition: 'root' | 'middle' | 'end' | 'standalone';
|
||||||
|
hasSelfReplies: boolean;
|
||||||
|
threadRootId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
tweet?: TweetData;
|
tweet?: TweetData;
|
||||||
|
|||||||
@@ -247,6 +247,28 @@ d('live CLI (Twitter/X) all commands', () => {
|
|||||||
expect(Array.isArray(bookmarkTweets)).toBe(true);
|
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 () => {
|
it('likes returns JSON array', async () => {
|
||||||
const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
|
const likes = await runBird([...baseArgs, 'likes', '-n', '10', '--json'], {
|
||||||
timeoutMs: 45_000,
|
timeoutMs: 45_000,
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user