import type { Client } from './api.ts'; import { parseAtUri } from './api.ts'; import { feedUri, listUri } from './refs.ts'; import { getPreferences, SAVED_FEEDS, type SavedFeed } from './prefs.ts'; import { UsageError, NotFoundError } from './errors.ts'; import { info } from './output.ts'; // Feeds and lists by name. // // Wherever a feed or a list is wanted, it can be named the way a person // thinks of it — "mutuals", "JS/TS Wien", "discover" — as well as by at:// URI // or bsky.app link. A name is looked for in widening circles, and the first // circle with a good match wins: // // 1. the feeds and lists you have saved or pinned ("following" is your // home timeline); // 2. the feeds and lists you have made; // 3. Bluesky's feed search, for feeds anyone has made. // // A name matches a feed's display name, or the last segment of its address // (its record key: the "mutuals" of …/feed/mutuals), however it is // capitalised, accented or punctuated, and also when it is only the start // of the name, some of its words, or its letters in order. Two of your own // feeds that match equally well are an error that lists them, rather than a // guess; of feeds found by searching, the most liked is taken, and the // others are mentioned. export type FeedKind = 'feed' | 'list' | 'timeline'; export interface FeedTarget { kind: FeedKind; /** The at:// URI; `following` for the home timeline. */ uri: string; /** What it is called, when known. */ name?: string | undefined; /** Who made it, when known. */ creator?: string | undefined; /** How it was found: by address, or among your feeds, your own, or a search. */ via: 'address' | 'saved' | 'yours' | 'search'; } interface Candidate { kind: FeedKind; uri: string; name: string; creator?: string | undefined; likes: number; via: FeedTarget['via']; } type Json = Record; // ---------------------------------------------------------------- matching --- export function normalise(text: string): string { return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); } function isSubsequence(needle: string, haystack: string): boolean { let at = 0; for (const char of haystack) if (char === needle[at]) at++; return at === needle.length; } /** How well `query` names something called `name` at record key `rkey`: 0 is not at all, 100 exactly. */ export function matchScore(query: string, name: string, rkey = ''): number { const q = normalise(query); if (!q) return 0; const qCompact = q.replace(/ /g, ''); const n = normalise(name); const nCompact = n.replace(/ /g, ''); const r = normalise(rkey).replace(/ /g, ''); if (n === q || (nCompact && nCompact === qCompact)) return 100; if (r && r === qCompact) return 95; if (n.startsWith(q) || (nCompact && nCompact.startsWith(qCompact))) return 80; if (r && r.startsWith(qCompact)) return 75; const queryWords = q.split(' '); const nameWords = n.split(' '); if (queryWords.every((word) => nameWords.some((each) => each.startsWith(word)))) return 70; if (queryWords.every((word) => n.includes(word))) return 60; if (nCompact.includes(qCompact) || (r && r.includes(qCompact))) return 50; if (qCompact.length >= 3 && (isSubsequence(qCompact, nCompact) || (r && isSubsequence(qCompact, r)))) return 30; return 0; } const STRONG = 75; const ENOUGH = 30; // -------------------------------------------------------------- candidates --- function rkeyOf(uri: string): string { try { return parseAtUri(uri).rkey ?? ''; } catch { return ''; } } async function hydrate(client: Client, items: Array<{ kind: 'feed' | 'list'; uri: string }>): Promise> { const views = new Map(); const feeds = items.filter((item) => item.kind === 'feed').map((item) => item.uri); for (let at = 0; at < feeds.length; at += 25) { const { feeds: found } = await client.appview<{ feeds: Json[] }>('app.bsky.feed.getFeedGenerators', { feeds: feeds.slice(at, at + 25) }); for (const view of found) views.set(String(view['uri']), view); } await Promise.all(items.filter((item) => item.kind === 'list').map(async (item) => { const response = await client.appview<{ list: Json }>('app.bsky.graph.getList', { list: item.uri, limit: 1 }).catch(() => undefined); if (response) views.set(item.uri, response.list); })); return views; } function fromView(view: Json, kind: 'feed' | 'list', via: FeedTarget['via']): Candidate { return { kind, uri: String(view['uri']), name: String(view['displayName'] ?? view['name'] ?? ''), creator: view['creator']?.handle, likes: Number(view['likeCount'] ?? 0), via, }; } async function savedCandidates(client: Client): Promise { const preferences = await getPreferences(client); const items = (preferences.find((preference) => preference.$type === SAVED_FEEDS)?.['items'] as SavedFeed[] | undefined) ?? []; const candidates: Candidate[] = []; const hydrating: Array<{ kind: 'feed' | 'list'; uri: string }> = []; for (const item of items) { if (item.type === 'timeline') candidates.push({ kind: 'timeline', uri: 'following', name: 'Following', likes: 0, via: 'saved' }); else hydrating.push({ kind: item.type, uri: item.value }); } const views = await hydrate(client, hydrating); for (const item of hydrating) { const view = views.get(item.uri); candidates.push(view ? fromView(view, item.kind, 'saved') : { kind: item.kind, uri: item.uri, name: '', likes: 0, via: 'saved' }); } return candidates; } async function ownCandidates(client: Client, did: string): Promise { const [feeds, lists] = await Promise.all([ client.appview<{ feeds: Json[] }>('app.bsky.feed.getActorFeeds', { actor: did, limit: 100 }), client.appview<{ lists: Json[] }>('app.bsky.graph.getLists', { actor: did, limit: 100 }), ]); return [...feeds.feeds.map((view) => fromView(view, 'feed', 'yours')), ...lists.lists.map((view) => fromView(view, 'list', 'yours'))]; } async function searchCandidates(client: Client, query: string): Promise { const { feeds } = await client.appview<{ feeds: Json[] }>('app.bsky.unspecced.getPopularFeedGenerators', { query, limit: 50 }); return feeds.map((view) => fromView(view, 'feed', 'search')); } function scored(query: string, candidates: Candidate[], kinds: readonly FeedKind[]): Array { return candidates .filter((candidate) => kinds.includes(candidate.kind)) .map((candidate) => ({ ...candidate, score: matchScore(query, candidate.name, candidate.kind === 'timeline' ? 'following' : rkeyOf(candidate.uri)) })) .filter((candidate) => candidate.score >= ENOUGH) .sort((a, b) => b.score - a.score || b.likes - a.likes); } /** `best`, unless another of your own fits exactly as well: then which is meant is yours to say. */ function unambiguous(text: string, best: Candidate & { score: number }, mine: Array): Candidate { const rivals = mine.filter((candidate) => candidate.score === best.score && candidate.uri !== best.uri); if (rivals.length) { throw new UsageError(`'${text}' fits ${rivals.length + 1} of your feeds equally well:\n${[best, ...rivals].map((each) => ` ${describe(each)}`).join('\n')}\nGive more of the name, or its at:// URI.`); } return best; } /** How to name a feed exactly, and briefly: /. */ function short(candidate: Candidate): string { return candidate.creator ? `${candidate.creator}/${rkeyOf(candidate.uri)}` : candidate.uri; } function describe(candidate: Candidate): string { return `${candidate.name || candidate.uri}${candidate.creator ? ` by @${candidate.creator}` : ''} (${candidate.uri})`; } // --------------------------------------------------------------- resolving --- /** Whether `ref` is an address (at://, a link, handle/rkey) rather than a name. */ function isAddress(ref: string): boolean { return /^at:\/\//.test(ref) || /^https?:\/\//.test(ref) || /^@?(did:[^/\s]+|[a-z0-9-]+(\.[a-z0-9-]+)+)\/[A-Za-z0-9._:~-]+$/i.test(ref); } /** * The feed or list `ref` names. `kinds` says what may be meant: a command * that reads a feed takes any, one that changes a list only lists. With * `ownOnly`, only lists and feeds you made (or saved) are looked among. */ export async function resolveFeed(client: Client, ref: string, { kinds = ['feed', 'list', 'timeline'] as readonly FeedKind[], ownOnly = false, quiet = false }: { kinds?: readonly FeedKind[]; ownOnly?: boolean; quiet?: boolean } = {}): Promise { const text = ref.trim(); if (!text) throw new UsageError('give a feed: its name, an at:// URI or a bsky.app link'); const wants = kinds.includes('feed') && kinds.includes('list') ? 'feed or list' : kinds.includes('list') ? 'list' : 'feed'; if (isAddress(text)) { const list = /app\.bsky\.graph\.list|\/lists\//.test(text); if (list && !kinds.includes('list')) throw new UsageError(`${text} is a list, not a ${wants}`); if (!list && !kinds.includes('feed')) throw new UsageError(`${text} is a feed, not a ${wants}`); const kind = list ? 'list' : 'feed'; const uri = list ? await listUri(text) : await feedUri(text); // What it is called, for saying what was read; not knowing is no reason to fail. const view = (await hydrate(client, [{ kind, uri }]).catch(() => new Map())).get(uri); return { kind, uri, name: view ? fromView(view, kind, 'address').name || undefined : undefined, creator: view?.['creator']?.handle, via: 'address' }; } // "following" (or "timeline") is your home timeline, saved among your feeds or not. if (kinds.includes('timeline') && /^(following|timeline|home)$/.test(normalise(text))) return { kind: 'timeline', uri: 'following', name: 'Following', via: 'saved' }; const session = await client.maybeSession(); const pick = (best: Candidate): FeedTarget => ({ kind: best.kind, uri: best.uri, name: best.name || undefined, creator: best.creator, via: best.via }); // Your own circles first: what you saved, then what you made. let mine: Array = []; if (session) { // A failure to look is said, not taken for having nothing: that would // send the name off to a search, and answer 'not found' for a feed // that is right there. const [saved, own] = await Promise.all([savedCandidates(client), ownCandidates(client, session.did)]); const seen = new Set(); mine = scored(text, [...saved, ...own].filter((candidate) => !seen.has(candidate.uri) && seen.add(candidate.uri)), kinds); const best = mine[0]; if (best && best.score >= STRONG) return pick(unambiguous(text, best, mine)); } // Then anyone's feeds, by search; a good match of your own still wins a tie. const found = ownOnly || !kinds.includes('feed') ? [] : scored(text, await searchCandidates(client, text), kinds); const best = [...mine, ...found].sort((a, b) => b.score - a.score || (a.via === 'search' ? 1 : 0) - (b.via === 'search' ? 1 : 0) || b.likes - a.likes)[0]; if (!best) { const where = session ? (ownOnly ? 'among your lists and feeds' : 'among your feeds, the ones you made, or by searching') : 'by searching (sign in to look among your own feeds too)'; throw new NotFoundError(`no ${wants} called '${text}' found ${where}`, 'FeedNotFound'); } if (best.via !== 'search') return pick(unambiguous(text, best, mine)); if (!quiet) { const others = found.filter((candidate) => candidate.uri !== best.uri && candidate.score >= best.score - 10).slice(0, 3); info(`using ${best.name}${best.creator ? ` by @${best.creator}` : ''}, found by searching${others.length ? `; others called that: ${others.map(short).join(', ')} (name one like that to read it)` : ''}`); } return pick(best); }