A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

feat: add fuzzy search, global account view, and group manager

jack f349fd21 f7b5720c

+929 -33
+156
src/db.ts
··· 147 147 created_at?: string; 148 148 } 149 149 150 + export interface ProcessedTweetSearchResult extends ProcessedTweet { 151 + score: number; 152 + } 153 + 154 + function normalizeSearchValue(value: string): string { 155 + return value 156 + .toLowerCase() 157 + .replace(/[^a-z0-9@#._\-\s]+/g, ' ') 158 + .replace(/\s+/g, ' ') 159 + .trim(); 160 + } 161 + 162 + function tokenizeSearchValue(value: string): string[] { 163 + if (!value) { 164 + return []; 165 + } 166 + return value.split(' ').filter((token) => token.length > 0); 167 + } 168 + 169 + function orderedSubsequenceScore(query: string, candidate: string): number { 170 + if (!query || !candidate) { 171 + return 0; 172 + } 173 + 174 + let matched = 0; 175 + let searchIndex = 0; 176 + for (const char of query) { 177 + const foundIndex = candidate.indexOf(char, searchIndex); 178 + if (foundIndex === -1) { 179 + continue; 180 + } 181 + matched += 1; 182 + searchIndex = foundIndex + 1; 183 + } 184 + 185 + return matched / query.length; 186 + } 187 + 188 + function buildBigrams(value: string): Set<string> { 189 + const result = new Set<string>(); 190 + if (value.length < 2) { 191 + if (value.length === 1) { 192 + result.add(value); 193 + } 194 + return result; 195 + } 196 + 197 + for (let i = 0; i < value.length - 1; i += 1) { 198 + result.add(value.slice(i, i + 2)); 199 + } 200 + 201 + return result; 202 + } 203 + 204 + function diceCoefficient(a: string, b: string): number { 205 + const aBigrams = buildBigrams(a); 206 + const bBigrams = buildBigrams(b); 207 + if (aBigrams.size === 0 || bBigrams.size === 0) { 208 + return 0; 209 + } 210 + 211 + let overlap = 0; 212 + for (const gram of aBigrams) { 213 + if (bBigrams.has(gram)) { 214 + overlap += 1; 215 + } 216 + } 217 + 218 + return (2 * overlap) / (aBigrams.size + bBigrams.size); 219 + } 220 + 221 + function scoreCandidateField(query: string, tokens: string[], candidateValue?: string): number { 222 + const candidate = normalizeSearchValue(candidateValue || ''); 223 + if (!query || !candidate) { 224 + return 0; 225 + } 226 + 227 + let score = 0; 228 + if (candidate === query) { 229 + score += 170; 230 + } else if (candidate.startsWith(query)) { 231 + score += 140; 232 + } else if (candidate.includes(query)) { 233 + score += 112; 234 + } 235 + 236 + let matchedTokens = 0; 237 + for (const token of tokens) { 238 + if (candidate.includes(token)) { 239 + matchedTokens += 1; 240 + score += token.length >= 4 ? 18 : 12; 241 + } 242 + } 243 + 244 + if (tokens.length > 0) { 245 + score += (matchedTokens / tokens.length) * 48; 246 + } 247 + 248 + score += orderedSubsequenceScore(query, candidate) * 46; 249 + score += diceCoefficient(query, candidate) * 55; 250 + 251 + return score; 252 + } 253 + 254 + function scoreProcessedTweet(tweet: ProcessedTweet, query: string, tokens: string[]): number { 255 + const usernameScore = scoreCandidateField(query, tokens, tweet.twitter_username) * 1.25; 256 + const identifierScore = scoreCandidateField(query, tokens, tweet.bsky_identifier) * 1.18; 257 + const textScore = scoreCandidateField(query, tokens, tweet.tweet_text) * 0.98; 258 + const idScore = scoreCandidateField(query, tokens, tweet.twitter_id) * 0.72; 259 + 260 + const maxScore = Math.max(usernameScore, identifierScore, textScore, idScore); 261 + const blendedScore = maxScore + (usernameScore + identifierScore + textScore + idScore - maxScore) * 0.22; 262 + 263 + const recencyBoost = (() => { 264 + if (!tweet.created_at) return 0; 265 + const timestamp = Date.parse(tweet.created_at); 266 + if (!Number.isFinite(timestamp)) return 0; 267 + const ageDays = (Date.now() - timestamp) / (24 * 60 * 60 * 1000); 268 + return Math.max(0, 7 - ageDays); 269 + })(); 270 + 271 + return blendedScore + recencyBoost; 272 + } 273 + 150 274 export const dbService = { 151 275 getTweet(twitterId: string, bskyIdentifier: string): ProcessedTweet | null { 152 276 const stmt = db.prepare('SELECT * FROM processed_tweets WHERE twitter_id = ? AND bsky_identifier = ?'); ··· 226 350 getRecentProcessedTweets(limit = 50): ProcessedTweet[] { 227 351 const stmt = db.prepare('SELECT * FROM processed_tweets ORDER BY datetime(created_at) DESC, rowid DESC LIMIT ?'); 228 352 return stmt.all(limit) as ProcessedTweet[]; 353 + }, 354 + 355 + searchMigratedTweets(query: string, limit = 60, scanLimit = 3000): ProcessedTweetSearchResult[] { 356 + const normalizedQuery = normalizeSearchValue(query || ''); 357 + if (!normalizedQuery) { 358 + return []; 359 + } 360 + 361 + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(limit, 200)) : 60; 362 + const safeScanLimit = Number.isFinite(scanLimit) ? Math.max(safeLimit, Math.min(scanLimit, 8000)) : 3000; 363 + const tokens = tokenizeSearchValue(normalizedQuery); 364 + 365 + const stmt = db.prepare( 366 + 'SELECT * FROM processed_tweets WHERE status = "migrated" ORDER BY datetime(created_at) DESC, rowid DESC LIMIT ?', 367 + ); 368 + const rows = stmt.all(safeScanLimit) as ProcessedTweet[]; 369 + 370 + return rows 371 + .map((row) => ({ 372 + ...row, 373 + score: scoreProcessedTweet(row, normalizedQuery, tokens), 374 + })) 375 + .filter((row) => row.score >= 22) 376 + .sort((a, b) => { 377 + if (b.score !== a.score) { 378 + return b.score - a.score; 379 + } 380 + const aTime = a.created_at ? Date.parse(a.created_at) : 0; 381 + const bTime = b.created_at ? Date.parse(b.created_at) : 0; 382 + return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0); 383 + }) 384 + .slice(0, safeLimit); 229 385 }, 230 386 231 387 deleteTweetsByUsername(username: string) {
+165 -4
src/server.ts
··· 23 23 const BSKY_APPVIEW_URL = process.env.BSKY_APPVIEW_URL || 'https://public.api.bsky.app'; 24 24 const POST_VIEW_CACHE_TTL_MS = 60_000; 25 25 const PROFILE_CACHE_TTL_MS = 5 * 60_000; 26 + const RESERVED_UNGROUPED_KEY = 'ungrouped'; 26 27 27 28 interface CacheEntry<T> { 28 29 value: T; ··· 74 75 media: EnrichedPostMedia[]; 75 76 } 76 77 78 + interface LocalPostSearchResult { 79 + twitterId: string; 80 + twitterUsername: string; 81 + bskyIdentifier: string; 82 + tweetText?: string; 83 + bskyUri?: string; 84 + bskyCid?: string; 85 + createdAt?: string; 86 + postUrl?: string; 87 + twitterUrl?: string; 88 + score: number; 89 + } 90 + 77 91 const postViewCache = new Map<string, CacheEntry<any>>(); 78 92 const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 79 93 ··· 114 128 return typeof value === 'string' ? value.trim() : ''; 115 129 } 116 130 131 + function getNormalizedGroupKey(value: unknown): string { 132 + return normalizeGroupName(value).toLowerCase(); 133 + } 134 + 117 135 function ensureGroupExists(config: AppConfig, name?: string, emoji?: string) { 118 136 const normalizedName = normalizeGroupName(name); 119 - if (!normalizedName) return; 137 + if (!normalizedName || getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) return; 120 138 121 139 if (!Array.isArray(config.groups)) { 122 140 config.groups = []; 123 141 } 124 142 125 - const existingIndex = config.groups.findIndex((group) => normalizeGroupName(group.name).toLowerCase() === normalizedName.toLowerCase()); 143 + const existingIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === getNormalizedGroupKey(normalizedName)); 126 144 const normalizedEmoji = normalizeGroupEmoji(emoji); 127 145 128 146 if (existingIndex === -1) { ··· 444 462 445 463 app.get('/api/groups', authenticateToken, (_req, res) => { 446 464 const config = getConfig(); 447 - res.json(Array.isArray(config.groups) ? config.groups : []); 465 + const groups = Array.isArray(config.groups) 466 + ? config.groups.filter((group) => getNormalizedGroupKey(group.name) !== RESERVED_UNGROUPED_KEY) 467 + : []; 468 + res.json(groups); 448 469 }); 449 470 450 471 app.post('/api/groups', authenticateToken, (req, res) => { ··· 457 478 return; 458 479 } 459 480 481 + if (getNormalizedGroupKey(normalizedName) === RESERVED_UNGROUPED_KEY) { 482 + res.status(400).json({ error: '"Ungrouped" is reserved for default behavior.' }); 483 + return; 484 + } 485 + 460 486 ensureGroupExists(config, normalizedName, normalizedEmoji); 461 487 saveConfig(config); 462 488 463 - const group = config.groups.find((entry) => normalizeGroupName(entry.name).toLowerCase() === normalizedName.toLowerCase()); 489 + const group = config.groups.find((entry) => getNormalizedGroupKey(entry.name) === getNormalizedGroupKey(normalizedName)); 464 490 res.json(group || { name: normalizedName, ...(normalizedEmoji ? { emoji: normalizedEmoji } : {}) }); 465 491 }); 466 492 493 + app.put('/api/groups/:groupKey', authenticateToken, (req, res) => { 494 + const currentGroupKey = getNormalizedGroupKey(req.params.groupKey); 495 + if (!currentGroupKey || currentGroupKey === RESERVED_UNGROUPED_KEY) { 496 + res.status(400).json({ error: 'Invalid group key.' }); 497 + return; 498 + } 499 + 500 + const requestedName = normalizeGroupName(req.body?.name); 501 + const requestedEmoji = normalizeGroupEmoji(req.body?.emoji); 502 + if (!requestedName) { 503 + res.status(400).json({ error: 'Group name is required.' }); 504 + return; 505 + } 506 + 507 + const requestedGroupKey = getNormalizedGroupKey(requestedName); 508 + if (requestedGroupKey === RESERVED_UNGROUPED_KEY) { 509 + res.status(400).json({ error: '"Ungrouped" is reserved and cannot be edited.' }); 510 + return; 511 + } 512 + 513 + const config = getConfig(); 514 + if (!Array.isArray(config.groups)) { 515 + config.groups = []; 516 + } 517 + 518 + const groupIndex = config.groups.findIndex((group) => getNormalizedGroupKey(group.name) === currentGroupKey); 519 + if (groupIndex === -1) { 520 + res.status(404).json({ error: 'Group not found.' }); 521 + return; 522 + } 523 + 524 + const mergeIndex = config.groups.findIndex( 525 + (group, index) => index !== groupIndex && getNormalizedGroupKey(group.name) === requestedGroupKey, 526 + ); 527 + 528 + let finalName = requestedName; 529 + let finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[groupIndex]?.emoji); 530 + if (mergeIndex !== -1) { 531 + finalName = normalizeGroupName(config.groups[mergeIndex]?.name) || requestedName; 532 + finalEmoji = requestedEmoji || normalizeGroupEmoji(config.groups[mergeIndex]?.emoji) || finalEmoji; 533 + 534 + config.groups[mergeIndex] = { 535 + name: finalName, 536 + ...(finalEmoji ? { emoji: finalEmoji } : {}), 537 + }; 538 + config.groups.splice(groupIndex, 1); 539 + } else { 540 + config.groups[groupIndex] = { 541 + name: finalName, 542 + ...(finalEmoji ? { emoji: finalEmoji } : {}), 543 + }; 544 + } 545 + 546 + const keysToRewrite = new Set([currentGroupKey, requestedGroupKey]); 547 + config.mappings = config.mappings.map((mapping) => { 548 + const mappingGroupKey = getNormalizedGroupKey(mapping.groupName); 549 + if (!keysToRewrite.has(mappingGroupKey)) { 550 + return mapping; 551 + } 552 + return { 553 + ...mapping, 554 + groupName: finalName, 555 + groupEmoji: finalEmoji || undefined, 556 + }; 557 + }); 558 + 559 + saveConfig(config); 560 + res.json({ 561 + name: finalName, 562 + ...(finalEmoji ? { emoji: finalEmoji } : {}), 563 + }); 564 + }); 565 + 566 + app.delete('/api/groups/:groupKey', authenticateToken, (req, res) => { 567 + const groupKey = getNormalizedGroupKey(req.params.groupKey); 568 + if (!groupKey || groupKey === RESERVED_UNGROUPED_KEY) { 569 + res.status(400).json({ error: 'Invalid group key.' }); 570 + return; 571 + } 572 + 573 + const config = getConfig(); 574 + if (!Array.isArray(config.groups)) { 575 + config.groups = []; 576 + } 577 + 578 + const beforeCount = config.groups.length; 579 + config.groups = config.groups.filter((group) => getNormalizedGroupKey(group.name) !== groupKey); 580 + if (config.groups.length === beforeCount) { 581 + res.status(404).json({ error: 'Group not found.' }); 582 + return; 583 + } 584 + 585 + let reassigned = 0; 586 + config.mappings = config.mappings.map((mapping) => { 587 + if (getNormalizedGroupKey(mapping.groupName) !== groupKey) { 588 + return mapping; 589 + } 590 + reassigned += 1; 591 + return { 592 + ...mapping, 593 + groupName: undefined, 594 + groupEmoji: undefined, 595 + }; 596 + }); 597 + 598 + saveConfig(config); 599 + res.json({ success: true, reassignedCount: reassigned }); 600 + }); 601 + 467 602 app.post('/api/mappings', authenticateToken, (req, res) => { 468 603 const { twitterUsernames, bskyIdentifier, bskyPassword, bskyServiceUrl, owner, groupName, groupEmoji } = req.body; 469 604 const config = getConfig(); ··· 790 925 const limitedActors = actors.slice(0, 200); 791 926 const profiles = await fetchProfilesByActor(limitedActors); 792 927 res.json(profiles); 928 + }); 929 + 930 + app.get('/api/posts/search', authenticateToken, (req, res) => { 931 + const query = typeof req.query.q === 'string' ? req.query.q : ''; 932 + if (!query.trim()) { 933 + res.json([]); 934 + return; 935 + } 936 + 937 + const requestedLimit = req.query.limit ? Number(req.query.limit) : 80; 938 + const limit = Number.isFinite(requestedLimit) ? Math.max(1, Math.min(requestedLimit, 200)) : 80; 939 + 940 + const results = dbService.searchMigratedTweets(query, limit).map<LocalPostSearchResult>((row) => ({ 941 + twitterId: row.twitter_id, 942 + twitterUsername: row.twitter_username, 943 + bskyIdentifier: row.bsky_identifier, 944 + tweetText: row.tweet_text, 945 + bskyUri: row.bsky_uri, 946 + bskyCid: row.bsky_cid, 947 + createdAt: row.created_at, 948 + postUrl: buildPostUrl(row.bsky_identifier, row.bsky_uri), 949 + twitterUrl: buildTwitterPostUrl(row.twitter_username, row.twitter_id), 950 + score: Number(row.score.toFixed(2)), 951 + })); 952 + 953 + res.json(results); 793 954 }); 794 955 795 956 app.get('/api/posts/enriched', authenticateToken, async (req, res) => {
+608 -29
web/src/App.tsx
··· 150 150 media: EnrichedPostMedia[]; 151 151 } 152 152 153 + interface LocalPostSearchResult { 154 + twitterId: string; 155 + twitterUsername: string; 156 + bskyIdentifier: string; 157 + tweetText?: string; 158 + bskyUri?: string; 159 + bskyCid?: string; 160 + createdAt?: string; 161 + postUrl?: string; 162 + twitterUrl?: string; 163 + score: number; 164 + } 165 + 153 166 interface BskyProfileView { 154 167 did?: string; 155 168 handle?: string; ··· 226 239 }; 227 240 const ADD_ACCOUNT_STEP_COUNT = 4; 228 241 const ADD_ACCOUNT_STEPS = ['Owner', 'Sources', 'Bluesky', 'Confirm'] as const; 242 + const ACCOUNT_SEARCH_MIN_SCORE = 22; 229 243 230 244 const selectClassName = 231 245 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'; ··· 343 357 return next; 344 358 } 345 359 360 + function normalizeSearchValue(value: string): string { 361 + return value 362 + .toLowerCase() 363 + .replace(/[^a-z0-9@#._\-\s]+/g, ' ') 364 + .replace(/\s+/g, ' ') 365 + .trim(); 366 + } 367 + 368 + function tokenizeSearchValue(value: string): string[] { 369 + if (!value) { 370 + return []; 371 + } 372 + return value.split(' ').filter((token) => token.length > 0); 373 + } 374 + 375 + function orderedSubsequenceScore(query: string, candidate: string): number { 376 + if (!query || !candidate) { 377 + return 0; 378 + } 379 + 380 + let matched = 0; 381 + let searchIndex = 0; 382 + for (const char of query) { 383 + const foundIndex = candidate.indexOf(char, searchIndex); 384 + if (foundIndex === -1) { 385 + continue; 386 + } 387 + matched += 1; 388 + searchIndex = foundIndex + 1; 389 + } 390 + 391 + return matched / query.length; 392 + } 393 + 394 + function buildBigrams(value: string): Set<string> { 395 + const result = new Set<string>(); 396 + if (value.length < 2) { 397 + if (value.length === 1) { 398 + result.add(value); 399 + } 400 + return result; 401 + } 402 + for (let i = 0; i < value.length - 1; i += 1) { 403 + result.add(value.slice(i, i + 2)); 404 + } 405 + return result; 406 + } 407 + 408 + function diceCoefficient(a: string, b: string): number { 409 + const aBigrams = buildBigrams(a); 410 + const bBigrams = buildBigrams(b); 411 + if (aBigrams.size === 0 || bBigrams.size === 0) { 412 + return 0; 413 + } 414 + let overlap = 0; 415 + for (const gram of aBigrams) { 416 + if (bBigrams.has(gram)) { 417 + overlap += 1; 418 + } 419 + } 420 + return (2 * overlap) / (aBigrams.size + bBigrams.size); 421 + } 422 + 423 + function scoreSearchField(query: string, tokens: string[], candidateValue?: string): number { 424 + const candidate = normalizeSearchValue(candidateValue || ''); 425 + if (!query || !candidate) { 426 + return 0; 427 + } 428 + 429 + let score = 0; 430 + if (candidate === query) { 431 + score += 170; 432 + } else if (candidate.startsWith(query)) { 433 + score += 138; 434 + } else if (candidate.includes(query)) { 435 + score += 108; 436 + } 437 + 438 + let matchedTokens = 0; 439 + for (const token of tokens) { 440 + if (candidate.includes(token)) { 441 + matchedTokens += 1; 442 + score += token.length >= 4 ? 18 : 12; 443 + } 444 + } 445 + if (tokens.length > 0) { 446 + score += (matchedTokens / tokens.length) * 46; 447 + } 448 + 449 + score += orderedSubsequenceScore(query, candidate) * 45; 450 + score += diceCoefficient(query, candidate) * 52; 451 + return score; 452 + } 453 + 454 + function scoreAccountMapping(mapping: AccountMapping, query: string, tokens: string[]): number { 455 + const usernameScores = mapping.twitterUsernames.map((username) => scoreSearchField(query, tokens, username) * 1.24); 456 + const bestUsernameScore = usernameScores.length > 0 ? Math.max(...usernameScores) : 0; 457 + const identifierScore = scoreSearchField(query, tokens, mapping.bskyIdentifier) * 1.2; 458 + const ownerScore = scoreSearchField(query, tokens, mapping.owner) * 0.92; 459 + const groupScore = scoreSearchField(query, tokens, mapping.groupName) * 0.72; 460 + const combined = [bestUsernameScore, identifierScore, ownerScore, groupScore]; 461 + const maxScore = Math.max(...combined); 462 + return maxScore + (combined.reduce((total, value) => total + value, 0) - maxScore) * 0.24; 463 + } 464 + 346 465 const textEncoder = new TextEncoder(); 347 466 const textDecoder = new TextDecoder(); 348 467 const compactNumberFormatter = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); ··· 468 587 return {}; 469 588 } 470 589 }); 590 + const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); 591 + const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); 471 592 const [postsGroupFilter, setPostsGroupFilter] = useState('all'); 593 + const [postsSearchQuery, setPostsSearchQuery] = useState(''); 594 + const [localPostSearchResults, setLocalPostSearchResults] = useState<LocalPostSearchResult[]>([]); 595 + const [isSearchingLocalPosts, setIsSearchingLocalPosts] = useState(false); 472 596 const [activityGroupFilter, setActivityGroupFilter] = useState('all'); 597 + const [groupDraftsByKey, setGroupDraftsByKey] = useState<Record<string, { name: string; emoji: string }>>({}); 598 + const [isGroupActionBusy, setIsGroupActionBusy] = useState(false); 473 599 const [notice, setNotice] = useState<Notice | null>(null); 474 600 475 601 const [isBusy, setIsBusy] = useState(false); ··· 477 603 478 604 const noticeTimerRef = useRef<number | null>(null); 479 605 const importInputRef = useRef<HTMLInputElement>(null); 606 + const postsSearchRequestRef = useRef(0); 480 607 481 608 const isAdmin = me?.isAdmin ?? false; 482 609 const authHeaders = useMemo(() => (token ? { Authorization: `Bearer ${token}` } : undefined), [token]); ··· 509 636 setIsAddAccountSheetOpen(false); 510 637 setAddAccountStep(1); 511 638 setSettingsSectionOverrides({}); 639 + setAccountsViewMode('grouped'); 640 + setAccountsSearchQuery(''); 641 + setPostsSearchQuery(''); 642 + setLocalPostSearchResults([]); 643 + setIsSearchingLocalPosts(false); 644 + setGroupDraftsByKey({}); 645 + setIsGroupActionBusy(false); 646 + postsSearchRequestRef.current = 0; 512 647 setAuthView('login'); 513 648 }, []); 514 649 ··· 907 1042 ), 908 1043 })); 909 1044 }, [groupOptions, mappings]); 1045 + const normalizedAccountsQuery = useMemo(() => normalizeSearchValue(accountsSearchQuery), [accountsSearchQuery]); 1046 + const accountSearchTokens = useMemo(() => tokenizeSearchValue(normalizedAccountsQuery), [normalizedAccountsQuery]); 1047 + const accountSearchScores = useMemo(() => { 1048 + const scores = new Map<string, number>(); 1049 + if (!normalizedAccountsQuery) { 1050 + return scores; 1051 + } 1052 + 1053 + for (const mapping of mappings) { 1054 + scores.set(mapping.id, scoreAccountMapping(mapping, normalizedAccountsQuery, accountSearchTokens)); 1055 + } 1056 + return scores; 1057 + }, [accountSearchTokens, mappings, normalizedAccountsQuery]); 1058 + const filteredGroupedMappings = useMemo(() => { 1059 + const hasQuery = normalizedAccountsQuery.length > 0; 1060 + const sortByScore = (items: AccountMapping[]) => { 1061 + if (!hasQuery) { 1062 + return items; 1063 + } 1064 + return [...items].sort((a, b) => { 1065 + const scoreDelta = (accountSearchScores.get(b.id) || 0) - (accountSearchScores.get(a.id) || 0); 1066 + if (scoreDelta !== 0) return scoreDelta; 1067 + return `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1068 + `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1069 + ); 1070 + }); 1071 + }; 1072 + 1073 + const withSearch = groupedMappings 1074 + .map((group) => { 1075 + const mappingsForGroup = hasQuery 1076 + ? group.mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE) 1077 + : group.mappings; 1078 + return { 1079 + ...group, 1080 + mappings: sortByScore(mappingsForGroup), 1081 + }; 1082 + }) 1083 + .filter((group) => !hasQuery || group.mappings.length > 0); 1084 + 1085 + if (accountsViewMode === 'grouped') { 1086 + return withSearch; 1087 + } 1088 + 1089 + const allMappings = sortByScore( 1090 + hasQuery 1091 + ? mappings.filter((mapping) => (accountSearchScores.get(mapping.id) || 0) >= ACCOUNT_SEARCH_MIN_SCORE) 1092 + : [...mappings].sort((a, b) => 1093 + `${(a.owner || '').toLowerCase()}-${a.bskyIdentifier.toLowerCase()}`.localeCompare( 1094 + `${(b.owner || '').toLowerCase()}-${b.bskyIdentifier.toLowerCase()}`, 1095 + ), 1096 + ), 1097 + ); 1098 + 1099 + return [ 1100 + { 1101 + key: '__all__', 1102 + name: hasQuery ? 'Search Results' : 'All Accounts', 1103 + emoji: hasQuery ? '🔎' : '🌐', 1104 + mappings: allMappings, 1105 + }, 1106 + ]; 1107 + }, [accountSearchScores, accountsViewMode, groupedMappings, mappings, normalizedAccountsQuery]); 1108 + const accountMatchesCount = useMemo( 1109 + () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), 1110 + [filteredGroupedMappings], 1111 + ); 1112 + const groupKeysForCollapse = useMemo( 1113 + () => groupedMappings.map((group) => group.key), 1114 + [groupedMappings], 1115 + ); 1116 + const allGroupsCollapsed = useMemo( 1117 + () => groupKeysForCollapse.length > 0 && groupKeysForCollapse.every((groupKey) => collapsedGroupKeys[groupKey] === true), 1118 + [collapsedGroupKeys, groupKeysForCollapse], 1119 + ); 1120 + const resolveMappingForLocalPost = useCallback( 1121 + (post: LocalPostSearchResult) => 1122 + mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || 1123 + mappingsByTwitterUsername.get(normalizeTwitterUsername(post.twitterUsername)), 1124 + [mappingsByBskyIdentifier, mappingsByTwitterUsername], 1125 + ); 910 1126 const resolveMappingForPost = useCallback( 911 1127 (post: EnrichedPost) => 912 1128 mappingsByBskyIdentifier.get(normalizeTwitterUsername(post.bskyIdentifier)) || ··· 928 1144 }), 929 1145 [postedActivity, postsGroupFilter, resolveMappingForPost], 930 1146 ); 1147 + const filteredLocalPostSearchResults = useMemo( 1148 + () => 1149 + localPostSearchResults.filter((post) => { 1150 + if (postsGroupFilter === 'all') return true; 1151 + const mapping = resolveMappingForLocalPost(post); 1152 + return getMappingGroupMeta(mapping).key === postsGroupFilter; 1153 + }), 1154 + [localPostSearchResults, postsGroupFilter, resolveMappingForLocalPost], 1155 + ); 931 1156 const filteredRecentActivity = useMemo( 932 1157 () => 933 1158 recentActivity.filter((activity) => { ··· 967 1192 } 968 1193 }, [activityGroupFilter, groupOptions, postsGroupFilter]); 969 1194 1195 + useEffect(() => { 1196 + setGroupDraftsByKey((previous) => { 1197 + const next: Record<string, { name: string; emoji: string }> = {}; 1198 + for (const group of reusableGroupOptions) { 1199 + const existing = previous[group.key]; 1200 + next[group.key] = { 1201 + name: existing?.name ?? group.name, 1202 + emoji: existing?.emoji ?? group.emoji, 1203 + }; 1204 + } 1205 + return next; 1206 + }); 1207 + }, [reusableGroupOptions]); 1208 + 1209 + useEffect(() => { 1210 + if (!authHeaders) { 1211 + setIsSearchingLocalPosts(false); 1212 + setLocalPostSearchResults([]); 1213 + return; 1214 + } 1215 + 1216 + const query = postsSearchQuery.trim(); 1217 + if (!query) { 1218 + postsSearchRequestRef.current += 1; 1219 + setIsSearchingLocalPosts(false); 1220 + setLocalPostSearchResults([]); 1221 + return; 1222 + } 1223 + 1224 + const requestId = postsSearchRequestRef.current + 1; 1225 + postsSearchRequestRef.current = requestId; 1226 + setIsSearchingLocalPosts(true); 1227 + 1228 + const timer = window.setTimeout(async () => { 1229 + try { 1230 + const response = await axios.get<LocalPostSearchResult[]>('/api/posts/search', { 1231 + params: { q: query, limit: 120 }, 1232 + headers: authHeaders, 1233 + }); 1234 + if (postsSearchRequestRef.current !== requestId) { 1235 + return; 1236 + } 1237 + setLocalPostSearchResults(Array.isArray(response.data) ? response.data : []); 1238 + } catch (error) { 1239 + if (postsSearchRequestRef.current !== requestId) { 1240 + return; 1241 + } 1242 + setLocalPostSearchResults([]); 1243 + handleAuthFailure(error, 'Failed to search local post history.'); 1244 + } finally { 1245 + if (postsSearchRequestRef.current === requestId) { 1246 + setIsSearchingLocalPosts(false); 1247 + } 1248 + } 1249 + }, 220); 1250 + 1251 + return () => { 1252 + window.clearTimeout(timer); 1253 + }; 1254 + }, [authHeaders, handleAuthFailure, postsSearchQuery]); 1255 + 970 1256 const isBackfillQueued = useCallback( 971 1257 (mappingId: string) => pendingBackfills.some((entry) => entry.id === mappingId), 972 1258 [pendingBackfills], ··· 1193 1479 })); 1194 1480 }; 1195 1481 1482 + const toggleCollapseAllGroups = () => { 1483 + const shouldCollapse = !allGroupsCollapsed; 1484 + setCollapsedGroupKeys((previous) => { 1485 + const next = { ...previous }; 1486 + for (const groupKey of groupKeysForCollapse) { 1487 + next[groupKey] = shouldCollapse; 1488 + } 1489 + return next; 1490 + }); 1491 + }; 1492 + 1196 1493 const handleCreateGroup = async (event: React.FormEvent<HTMLFormElement>) => { 1197 1494 event.preventDefault(); 1198 1495 if (!authHeaders) { ··· 1265 1562 } 1266 1563 }; 1267 1564 1565 + const updateGroupDraft = (groupKey: string, field: 'name' | 'emoji', value: string) => { 1566 + setGroupDraftsByKey((previous) => ({ 1567 + ...previous, 1568 + [groupKey]: { 1569 + name: previous[groupKey]?.name ?? '', 1570 + emoji: previous[groupKey]?.emoji ?? '', 1571 + [field]: value, 1572 + }, 1573 + })); 1574 + }; 1575 + 1576 + const handleRenameGroup = async (groupKey: string) => { 1577 + if (!authHeaders) { 1578 + return; 1579 + } 1580 + 1581 + const draft = groupDraftsByKey[groupKey]; 1582 + if (!draft || !draft.name.trim()) { 1583 + showNotice('error', 'Group name is required.'); 1584 + return; 1585 + } 1586 + 1587 + setIsGroupActionBusy(true); 1588 + try { 1589 + await axios.put( 1590 + `/api/groups/${encodeURIComponent(groupKey)}`, 1591 + { 1592 + name: draft.name.trim(), 1593 + emoji: draft.emoji.trim(), 1594 + }, 1595 + { headers: authHeaders }, 1596 + ); 1597 + showNotice('success', 'Group updated.'); 1598 + await fetchData(); 1599 + } catch (error) { 1600 + handleAuthFailure(error, 'Failed to update group.'); 1601 + } finally { 1602 + setIsGroupActionBusy(false); 1603 + } 1604 + }; 1605 + 1606 + const handleDeleteGroup = async (groupKey: string) => { 1607 + if (!authHeaders) { 1608 + return; 1609 + } 1610 + 1611 + const group = groupOptionsByKey.get(groupKey); 1612 + if (!group) { 1613 + showNotice('error', 'Group not found.'); 1614 + return; 1615 + } 1616 + 1617 + const confirmed = window.confirm( 1618 + `Delete "${group.name}"? Mappings in this folder will move to ${DEFAULT_GROUP_NAME}.`, 1619 + ); 1620 + if (!confirmed) { 1621 + return; 1622 + } 1623 + 1624 + setIsGroupActionBusy(true); 1625 + try { 1626 + const response = await axios.delete<{ reassignedCount?: number }>(`/api/groups/${encodeURIComponent(groupKey)}`, { 1627 + headers: authHeaders, 1628 + }); 1629 + const reassignedCount = response.data?.reassignedCount || 0; 1630 + showNotice('success', `Group deleted. ${reassignedCount} account${reassignedCount === 1 ? '' : 's'} moved.`); 1631 + await fetchData(); 1632 + } catch (error) { 1633 + handleAuthFailure(error, 'Failed to delete group.'); 1634 + } finally { 1635 + setIsGroupActionBusy(false); 1636 + } 1637 + }; 1638 + 1268 1639 const resetAddAccountDraft = () => { 1269 1640 setNewMapping(defaultMappingForm()); 1270 1641 setNewTwitterUsers([]); ··· 1791 2162 })} 1792 2163 </CardContent> 1793 2164 </Card> 2165 + 1794 2166 </section> 1795 2167 ) : null} 1796 2168 ··· 1849 2221 </div> 1850 2222 </form> 1851 2223 1852 - {groupedMappings.length === 0 ? ( 2224 + <div className="grid gap-2 md:grid-cols-[1fr_auto]"> 2225 + <div className="space-y-1"> 2226 + <Label htmlFor="accounts-search">Search accounts</Label> 2227 + <Input 2228 + id="accounts-search" 2229 + value={accountsSearchQuery} 2230 + onChange={(event) => setAccountsSearchQuery(event.target.value)} 2231 + placeholder="Find by @username, owner, Bluesky handle, or folder" 2232 + /> 2233 + {normalizedAccountsQuery ? ( 2234 + <p className="text-xs text-muted-foreground"> 2235 + {accountMatchesCount} result{accountMatchesCount === 1 ? '' : 's'} ranked by relevance 2236 + </p> 2237 + ) : null} 2238 + </div> 2239 + <div className="flex flex-wrap items-end justify-end gap-2"> 2240 + {accountsViewMode === 'grouped' ? ( 2241 + <Button 2242 + size="sm" 2243 + variant="outline" 2244 + onClick={toggleCollapseAllGroups} 2245 + disabled={groupKeysForCollapse.length === 0} 2246 + > 2247 + {allGroupsCollapsed ? 'Expand all' : 'Collapse all'} 2248 + </Button> 2249 + ) : null} 2250 + <Button 2251 + size="sm" 2252 + variant="outline" 2253 + onClick={() => setAccountsViewMode((previous) => (previous === 'grouped' ? 'global' : 'grouped'))} 2254 + > 2255 + {accountsViewMode === 'grouped' ? 'View all' : 'Grouped view'} 2256 + </Button> 2257 + </div> 2258 + </div> 2259 + 2260 + {filteredGroupedMappings.length === 0 ? ( 1853 2261 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 1854 - No mappings yet. 2262 + {normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} 1855 2263 {isAdmin ? ( 1856 2264 <div className="mt-3"> 1857 2265 <Button size="sm" variant="outline" onClick={openAddAccountSheet}> ··· 1863 2271 </div> 1864 2272 ) : ( 1865 2273 <div className="space-y-3"> 1866 - {groupedMappings.map((group, groupIndex) => { 1867 - const collapsed = collapsedGroupKeys[group.key] === true; 2274 + {filteredGroupedMappings.map((group, groupIndex) => { 2275 + const canCollapseGroup = accountsViewMode === 'grouped'; 2276 + const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false; 1868 2277 1869 2278 return ( 1870 2279 <div ··· 1873 2282 style={{ animationDelay: `${Math.min(groupIndex * 45, 220)}ms` }} 1874 2283 > 1875 2284 <button 1876 - className="group flex w-full items-center justify-between bg-muted/40 px-3 py-2 text-left transition-[background-color,padding] duration-200 hover:bg-muted/70" 1877 - onClick={() => toggleGroupCollapsed(group.key)} 2285 + className={cn( 2286 + 'group flex w-full items-center justify-between bg-muted/40 px-3 py-2 text-left transition-[background-color,padding] duration-200', 2287 + canCollapseGroup ? 'hover:bg-muted/70' : '', 2288 + )} 2289 + onClick={() => { 2290 + if (canCollapseGroup) { 2291 + toggleGroupCollapsed(group.key); 2292 + } 2293 + }} 1878 2294 type="button" 1879 2295 > 1880 2296 <div className="flex items-center gap-2"> ··· 1883 2299 <span className="font-medium">{group.name}</span> 1884 2300 <Badge variant="outline">{group.mappings.length}</Badge> 1885 2301 </div> 1886 - <ChevronDown 1887 - className={cn( 1888 - 'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none', 1889 - collapsed ? '-rotate-90' : 'rotate-0', 1890 - )} 1891 - /> 2302 + {canCollapseGroup ? ( 2303 + <ChevronDown 2304 + className={cn( 2305 + 'h-4 w-4 transition-transform duration-200 motion-reduce:transition-none', 2306 + collapsed ? '-rotate-90' : 'rotate-0', 2307 + )} 2308 + /> 2309 + ) : null} 1892 2310 </button> 1893 2311 1894 2312 <div ··· 2052 2470 )} 2053 2471 </CardContent> 2054 2472 </Card> 2473 + 2474 + <Card className="animate-slide-up"> 2475 + <CardHeader className="pb-3"> 2476 + <CardTitle>Group Manager</CardTitle> 2477 + <CardDescription>Edit folder names/emojis or delete a group.</CardDescription> 2478 + </CardHeader> 2479 + <CardContent className="pt-0"> 2480 + {reusableGroupOptions.length === 0 ? ( 2481 + <div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground"> 2482 + No custom folders yet. 2483 + </div> 2484 + ) : ( 2485 + <div className="space-y-2"> 2486 + {reusableGroupOptions.map((group) => { 2487 + const draft = groupDraftsByKey[group.key] || { name: group.name, emoji: group.emoji }; 2488 + return ( 2489 + <div 2490 + key={`group-manager-${group.key}`} 2491 + className="grid gap-2 rounded-lg border border-border/70 bg-muted/20 p-3 md:grid-cols-[90px_minmax(0,1fr)_auto_auto]" 2492 + > 2493 + <div className="space-y-1"> 2494 + <Label htmlFor={`group-manager-emoji-${group.key}`}>Emoji</Label> 2495 + <Input 2496 + id={`group-manager-emoji-${group.key}`} 2497 + value={draft.emoji} 2498 + onChange={(event) => updateGroupDraft(group.key, 'emoji', event.target.value)} 2499 + maxLength={8} 2500 + /> 2501 + </div> 2502 + <div className="space-y-1"> 2503 + <Label htmlFor={`group-manager-name-${group.key}`}>Name</Label> 2504 + <Input 2505 + id={`group-manager-name-${group.key}`} 2506 + value={draft.name} 2507 + onChange={(event) => updateGroupDraft(group.key, 'name', event.target.value)} 2508 + /> 2509 + </div> 2510 + <Button 2511 + variant="outline" 2512 + size="sm" 2513 + className="self-end" 2514 + disabled={isGroupActionBusy || !draft.name.trim()} 2515 + onClick={() => { 2516 + void handleRenameGroup(group.key); 2517 + }} 2518 + > 2519 + Save 2520 + </Button> 2521 + <Button 2522 + variant="ghost" 2523 + size="sm" 2524 + className="self-end text-red-600 hover:text-red-500 dark:text-red-300 dark:hover:text-red-200" 2525 + disabled={isGroupActionBusy} 2526 + onClick={() => { 2527 + void handleDeleteGroup(group.key); 2528 + }} 2529 + > 2530 + Delete 2531 + </Button> 2532 + </div> 2533 + ); 2534 + })} 2535 + </div> 2536 + )} 2537 + <p className="mt-3 text-xs text-muted-foreground"> 2538 + Deleting a folder keeps mappings intact and moves them to {DEFAULT_GROUP_NAME}. 2539 + </p> 2540 + </CardContent> 2541 + </Card> 2055 2542 </section> 2056 2543 ) : null} 2057 2544 ··· 2062 2549 <div className="flex flex-wrap items-center justify-between gap-3"> 2063 2550 <div className="space-y-1"> 2064 2551 <CardTitle>Already Posted</CardTitle> 2065 - <CardDescription>Native-styled feed of successfully posted Bluesky entries.</CardDescription> 2552 + <CardDescription> 2553 + Native-styled feed plus local SQLite search across all crossposted history. 2554 + </CardDescription> 2066 2555 </div> 2067 - <div className="w-full max-w-xs"> 2068 - <Label htmlFor="posts-group-filter">Filter group</Label> 2069 - <select 2070 - id="posts-group-filter" 2071 - className={selectClassName} 2072 - value={postsGroupFilter} 2073 - onChange={(event) => setPostsGroupFilter(event.target.value)} 2074 - > 2075 - <option value="all">All folders</option> 2076 - {groupOptions.map((group) => ( 2077 - <option key={`posts-filter-${group.key}`} value={group.key}> 2078 - {group.emoji} {group.name} 2079 - </option> 2080 - ))} 2081 - </select> 2556 + <div className="grid w-full gap-2 md:max-w-2xl md:grid-cols-[1fr_240px]"> 2557 + <div className="space-y-1"> 2558 + <Label htmlFor="posts-search">Search crossposted posts</Label> 2559 + <div className="relative"> 2560 + <Input 2561 + id="posts-search" 2562 + value={postsSearchQuery} 2563 + onChange={(event) => setPostsSearchQuery(event.target.value)} 2564 + placeholder="Search by text, @username, tweet id, or Bluesky handle" 2565 + /> 2566 + {isSearchingLocalPosts ? ( 2567 + <Loader2 className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground" /> 2568 + ) : null} 2569 + </div> 2570 + </div> 2571 + <div className="space-y-1"> 2572 + <Label htmlFor="posts-group-filter">Filter group</Label> 2573 + <select 2574 + id="posts-group-filter" 2575 + className={selectClassName} 2576 + value={postsGroupFilter} 2577 + onChange={(event) => setPostsGroupFilter(event.target.value)} 2578 + > 2579 + <option value="all">All folders</option> 2580 + {groupOptions.map((group) => ( 2581 + <option key={`posts-filter-${group.key}`} value={group.key}> 2582 + {group.emoji} {group.name} 2583 + </option> 2584 + ))} 2585 + </select> 2586 + </div> 2082 2587 </div> 2083 2588 </div> 2084 2589 </CardHeader> 2085 2590 <CardContent className="pt-0"> 2086 - {filteredPostedActivity.length === 0 ? ( 2591 + {postsSearchQuery.trim() ? ( 2592 + filteredLocalPostSearchResults.length === 0 ? ( 2593 + <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 2594 + {isSearchingLocalPosts ? 'Searching local history...' : 'No local crossposted posts matched.'} 2595 + </div> 2596 + ) : ( 2597 + <div className="space-y-2"> 2598 + {filteredLocalPostSearchResults.map((post) => { 2599 + const mapping = resolveMappingForLocalPost(post); 2600 + const groupMeta = getMappingGroupMeta(mapping); 2601 + const sourceTweetUrl = post.twitterUrl || getTwitterPostUrl(post.twitterUsername, post.twitterId); 2602 + const postUrl = 2603 + post.postUrl || 2604 + (post.bskyUri 2605 + ? `https://bsky.app/profile/${post.bskyIdentifier}/post/${post.bskyUri 2606 + .split('/') 2607 + .filter(Boolean) 2608 + .pop() || ''}` 2609 + : undefined); 2610 + 2611 + return ( 2612 + <article 2613 + key={`${post.twitterId}-${post.bskyIdentifier}-${post.bskyCid || post.createdAt || 'result'}`} 2614 + className="rounded-xl border border-border/70 bg-background/80 p-4 shadow-sm" 2615 + > 2616 + <div className="mb-2 flex flex-wrap items-center justify-between gap-2"> 2617 + <div className="min-w-0"> 2618 + <p className="truncate text-sm font-semibold"> 2619 + @{post.bskyIdentifier} <span className="text-muted-foreground">from @{post.twitterUsername}</span> 2620 + </p> 2621 + <p className="text-xs text-muted-foreground"> 2622 + {post.createdAt ? new Date(post.createdAt).toLocaleString() : 'Unknown time'} 2623 + </p> 2624 + </div> 2625 + <div className="flex items-center gap-2"> 2626 + <Badge variant="outline"> 2627 + {groupMeta.emoji} {groupMeta.name} 2628 + </Badge> 2629 + <Badge variant="secondary">Relevance {Math.round(post.score)}</Badge> 2630 + </div> 2631 + </div> 2632 + <p className="mb-2 whitespace-pre-wrap break-words text-sm leading-relaxed"> 2633 + {post.tweetText || 'No local tweet text stored for this record.'} 2634 + </p> 2635 + <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> 2636 + <span className="font-mono">Tweet ID: {post.twitterId}</span> 2637 + {sourceTweetUrl ? ( 2638 + <a 2639 + className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 2640 + href={sourceTweetUrl} 2641 + target="_blank" 2642 + rel="noreferrer" 2643 + > 2644 + Source 2645 + <ArrowUpRight className="ml-1 h-3 w-3" /> 2646 + </a> 2647 + ) : null} 2648 + {postUrl ? ( 2649 + <a 2650 + className="inline-flex items-center text-foreground underline-offset-4 hover:underline" 2651 + href={postUrl} 2652 + target="_blank" 2653 + rel="noreferrer" 2654 + > 2655 + Bluesky 2656 + <ArrowUpRight className="ml-1 h-3 w-3" /> 2657 + </a> 2658 + ) : null} 2659 + </div> 2660 + </article> 2661 + ); 2662 + })} 2663 + </div> 2664 + ) 2665 + ) : filteredPostedActivity.length === 0 ? ( 2087 2666 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 2088 2667 No posted entries yet. 2089 2668 </div>