/** * The content script's decisions, kept apart from its DOM so the specs can * load them (test/chip.spec.js): which state the chip shows, what it says, * and what the Discord Add panel starts out proposing. * * `probe` is /api/extension/probe's answer; `platformName` is the display * name (PLATFORMS[key].name), passed in so this file needs no other lib. */ function chipState(probe) { if (!probe || probe.error) return 'new'; return ({ source_match: 'source-match', artist_match: 'artist-match', new: 'new' })[probe.state] || 'new'; } function chipLabel(probe, platformName) { if (!probe || probe.error) return '+ Add to FabledCurator'; const artist = probe.artist?.name || 'artist'; if (probe.platform === 'discord') { const d = probe.discord || {}; if (probe.state === 'source_match') { return probe.covered_by_server ? `✓ Whole server in FabledCurator · ${artist}` : `✓ In FabledCurator · ${artist}`; } // Every other Discord state opens the panel: the artist is always chosen. return d.channel_id ? `+ Add ${channelLabel(d)} to FabledCurator` : '+ Add server to FabledCurator'; } switch (probe.state) { case 'source_match': return `✓ In FabledCurator · ${platformName}`; case 'artist_match': return `+ Add ${platformName} source to ${artist}`; default: return '+ Add to FabledCurator'; } } /** `#name` when the probe could read it, else a neutral "this channel". */ function channelLabel(d) { return d.channel_name ? `#${d.channel_name}` : 'this channel'; } /** `name` when the probe could read it, else "this server". */ function serverLabel(d) { return d.server_name || 'this server'; } /** * What the Discord Add panel opens with. The channel is the default scope * when there is one: a server source walks every channel the token can read, * which is rarely what a single art channel wants. The artist is the probe's * suggestion (the owner of another source on this server), else a new artist * named after the server. */ function discordPanelDefaults(probe) { const d = probe?.discord || {}; const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null; return { scope: d.channel_id ? 'channel' : 'server', channelUrl: d.channel_url || null, serverUrl: d.server_url || null, artist: suggested ? { id: suggested.id, name: suggested.name } : null, artistName: suggested ? suggested.name : (d.server_name || ''), }; } /** * The quick-add body for the panel's current choice. A picked artist goes by * id — names can collide once slugified — and a typed name creates (or * finds) that artist. null when there is nothing valid to send. */ function discordAddRequest(choice) { const url = choice.scope === 'server' ? choice.serverUrl : choice.channelUrl; if (!url) return null; if (choice.artist && choice.artist.id != null && choice.artist.name === choice.artistName) { return { url, artistId: choice.artist.id }; } const name = (choice.artistName || '').trim(); return name ? { url, artistName: name } : null; } /** * An artist name reduced to what identifies it: lowercase letters and digits * of any script, nothing else — so "Tamada Heijun", "tamada_heijun" and * "TamadaHeijun" are one name. Mirrors the server's autocomplete (#429). */ function squashName(name) { return String(name || '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); } /** The search result that IS the query, spacing aside, else null. */ function exactArtistMatch(query, results) { const q = squashName(query); if (!q) return null; return (results || []).find((a) => squashName(a.name) === q) || null; } /** * Inline autofill: the first result whose name extends what was typed * (case-insensitive), so the panel can fill in the rest and select it — * typing on overwrites it, Tab or Enter accepts it. null when none does. */ function inlineCompletion(typed, results) { const t = String(typed || '').toLowerCase(); if (!t) return null; return (results || []).find((a) => a.name.length > t.length && a.name.toLowerCase().startsWith(t)) || null; }