/** * 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 Add panel starts out proposing and finally sends. * * `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 Add panel opens with, on any platform. * * Discord: 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 one named after the server. * * Patreon / SubscribeStar: the page URL is the source. The artist is the one * whose slug the URL already names (artist_match), else a new one under the * creator's display name — `probe.display_name`, from the probe the panel * makes with names=1 — falling back to the URL handle, which the server * resolves on its own when it is left untouched (`nameIsHandle`). */ function panelDefaults(probe, pageUrl) { const d = probe?.discord || {}; const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null; const discord = probe?.platform === 'discord'; const shown = probe?.display_name || null; let artistName = ''; if (suggested) artistName = suggested.name; else if (discord) artistName = d.server_name || ''; else artistName = shown || probe?.slug || ''; return { platform: probe?.platform || null, scope: discord ? (d.channel_id ? 'channel' : 'server') : 'page', pageUrl: pageUrl || null, channelUrl: d.channel_url || null, serverUrl: d.server_url || null, artist: suggested ? { id: suggested.id, name: suggested.name } : null, artistName, nameIsHandle: !discord && !suggested && !shown, handle: probe?.slug || '', displayName: shown, // Patreon is canon: joining an artist known by another name takes the // Patreon name, unless the operator unticks it. adoptPlatformName: true, }; } /** * The rename the panel offers, or null: only on Patreon (the canon name), * only when joining an existing artist, only with a name actually read from * Patreon, and only when it differs from what the artist is called now. */ function renameOffer(choice) { if (choice.platform !== 'patreon' || !choice.artist || !choice.displayName) return null; if (choice.artist.name === choice.displayName) return null; return { from: choice.artist.name, to: choice.displayName }; } /** * 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. The URL handle left untouched sends no name, so the * server resolves the display name itself. null when there is nothing valid. */ function addRequest(choice) { let url = choice.pageUrl; if (choice.scope === 'server') url = choice.serverUrl; else if (choice.scope === 'channel') url = choice.channelUrl; if (!url) return null; if (choice.artist && choice.artist.id != null && choice.artist.name === choice.artistName) { const req = { url, artistId: choice.artist.id }; if (choice.adoptPlatformName && renameOffer(choice)) req.usePlatformName = true; return req; } const name = (choice.artistName || '').trim(); if (!name) return null; if (choice.nameIsHandle && name === choice.handle) return { url }; return { url, artistName: name }; } /** * 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; }