/** * 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; } // ---- Discord poster picker (#4488) ---- // /api/extension/discord/posters lists who posted in the channel lately, with // the creator the server is named for (or owned by) marked `suggested`. The // picker ticks posters; the source keeps their ids, since a username can // change on a whim and an id can't. /** "Todding (@todding)" — the display name, then the handle when it differs. */ function posterLabel(p) { const shown = p?.global_name || p?.username || p?.id || ''; const handle = p?.username && p.username !== shown ? ` (@${p.username})` : ''; return `${shown}${handle}`; } /** What the picker starts with: the source's current list when it has one, * else the suggested creator(s). A set of ids. */ function initialPosterPick(data) { const current = (data?.selected || []).map(String); if (current.length) return new Set(current); return new Set((data?.posters || []).filter((p) => p.suggested).map((p) => String(p.id))); } /** The checklist's rows: who posted lately, then anyone already on the * source's list who hasn't — by the name they were picked under — so they * can still be unticked. */ function withSelected(data) { const posters = [...(data?.posters || [])]; const shown = new Set(posters.map((p) => String(p.id))); const labels = data?.selected_labels || {}; for (const id of (data?.selected || []).map(String)) { if (shown.has(id)) continue; posters.push({ id, global_name: labels[id] || null, username: null, messages: 0, images: 0, reasons: ['on the list, not posting lately'] }); } return posters; } /** The picked posters as the API takes them: [{id, name}], in list order. * An id already on the source but no longer posting lately is kept. */ function postersRequest(posters, picked) { const byId = new Map((posters || []).map((p) => [String(p.id), p])); return [...picked].map((id) => ({ id, name: byId.has(id) ? (byId.get(id).global_name || byId.get(id).username || id) : null, })); }