feat: the artist picker on Patreon and SubscribeStar too — and the Patreon name is canon (milestone 429)
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 6s
CI and images / sign-extension (push) Successful in 4m48s
CI and images / build-web (push) Failing after 6s
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped

Operator: "yes add the artist picker to patreon and subscribestar too. but
generally we treat the patreon name as the canon"

- Every add now goes through the panel. On Patreon/SubscribeStar it opens
  on the creator's display name (read only when the panel opens:
  probe?names=1), searches FC's artists with it, and auto-picks a match.
  An untouched URL handle sends no name, so the server still resolves it.
- Patreon is canon: joining a Patreon source to an artist known by another
  name offers "Rename “x” to the Patreon name “X”", ticked by default.
  quick-add's use_platform_name renames server-side from the name it reads
  itself; name only, the slug never moves (#130); never to a URL handle when
  the name can't be read; ignored on SubscribeStar and Discord.
- _platform_display_name returns None rather than the handle, bounded by the
  same 6s lookup budget as Discord's names.
- panelDefaults / addRequest / renameOffer replace the Discord-only helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-25 07:50:48 -04:00
co-authored by Claude Opus 5.5
parent eeb9263125
commit 423275a1e5
9 changed files with 386 additions and 86 deletions
+2 -1
View File
@@ -270,6 +270,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
return await api.quickAddSource(msg.url, {
artistId: msg.artistId ?? null,
artistName: msg.artistName ?? null,
usePlatformName: msg.usePlatformName === true,
});
} catch (e) {
return { error: e.message };
@@ -284,7 +285,7 @@ browser.runtime.onMessage.addListener(async (msg) => {
case 'PROBE_SOURCE':
try {
return await api.probeSource(msg.url);
return await api.probeSource(msg.url, { names: msg.names === true });
} catch (e) {
return { error: e.message };
}
+3
View File
@@ -97,3 +97,6 @@
.fc-panel__result-tag { font-size: 11px; color: rgb(140, 220, 160); }
.fc-panel__empty { padding: 6px 9px; font-size: 12px; color: rgb(170, 166, 156); }
.fc-panel__hint--match { color: rgb(140, 220, 160); }
/* The panel's own [hidden] — its rows are display:flex, which beats the UA's. */
.fc-panel [hidden] { display: none !important; }
.fc-panel__rename { margin-top: 8px; font-size: 13px; }
+66 -22
View File
@@ -84,13 +84,14 @@
await openArtist(btn, probe.artist?.slug);
return;
}
// A Discord URL names a channel, not a creator — ask which artist.
if (probe?.platform === 'discord') {
if (document.getElementById('fc-discord-panel')) closePanel();
else openDiscordPanel(probe);
// Every add goes through the panel, so the operator can match the page to
// an artist FabledCurator already has (the same creator is often spelled
// differently per platform) instead of minting a duplicate.
if (document.getElementById('fc-add-panel')) {
closePanel();
return;
}
await add(btn, { url: window.location.href });
await openAddPanel(btn, probe);
}
async function openArtist(btn, slug) {
@@ -121,7 +122,8 @@
return false;
}
const verb = r.created_source ? 'Added to' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
const renamed = r.renamed_from ? ` — renamed from “${r.renamed_from}”` : '';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})${renamed}`, 'success');
// Re-probe so the chip flips green without waiting for a navigation.
evaluate();
return true;
@@ -134,10 +136,13 @@
}
}
// ---- Discord Add panel ----
// Where: this channel or the whole server. Who: the suggested artist, one
// found by search, or a new one by name. Built with createElement only —
// server, channel and artist names are other people's text.
// ---- Add panel ----
// Who: the suggested artist, one found by search, or a new one by name —
// on every platform. Where (Discord only): this channel or the whole
// server. On Patreon, joining an artist known by another name offers the
// Patreon name, which the operator treats as canon. Built with
// createElement only — server, channel and artist names are other people's
// text.
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
@@ -150,13 +155,35 @@
}
function closePanel() {
document.getElementById('fc-discord-panel')?.remove();
document.getElementById('fc-add-panel')?.remove();
}
function openDiscordPanel(probe) {
async function openAddPanel(btn, probe) {
closePanel();
const platformName = PLATFORMS[probe.platform]?.name || probe.platform;
// Discord's probe already carries its names. Patreon/SubscribeStar read the
// creator's display name only now, when the panel needs it — a request to
// the platform the chip's own probe deliberately doesn't make.
if (probe.platform !== 'discord') {
const original = btn.textContent;
btn.disabled = true;
btn.textContent = `Reading the ${platformName} name…`;
try {
const named = await browser.runtime.sendMessage({
type: 'PROBE_SOURCE', url: window.location.href, names: true,
});
if (named && !named.error) probe = named;
} catch { /* fall back to the chip's probe: the URL handle */ }
btn.disabled = false;
btn.textContent = original;
if (probe.state === 'source_match') {
renderButton(probe);
return;
}
}
const d = probe.discord || {};
const choice = discordPanelDefaults(probe);
const discord = probe.platform === 'discord';
const choice = panelDefaults(probe, window.location.href);
const scopeRow = (value, label, disabled) => {
const input = el('input', {
@@ -186,14 +213,27 @@
const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' });
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' });
const panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [
el('div', { class: 'fc-panel__title', text: 'Add Discord source' }),
el('div', { class: 'fc-panel__sub', text: serverLabel(d) }),
el('div', { class: 'fc-panel__label', text: 'Follow' }),
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
// Patreon is canon: joining an artist known by another name takes the
// Patreon name unless this is unticked. Shown only when it would rename.
const renameBox = el('input', { type: 'checkbox', checked: choice.adoptPlatformName });
const renameText = el('span');
const renameRow = el('label', { class: 'fc-panel__radio fc-panel__rename' }, [renameBox, renameText]);
renameBox.addEventListener('change', () => { choice.adoptPlatformName = renameBox.checked; refresh(); });
const sub = discord
? serverLabel(d)
: [probe.display_name, probe.slug].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ');
const panel = el('div', { id: 'fc-add-panel', class: 'fc-panel' }, [
el('div', { class: 'fc-panel__title', text: `Add ${platformName} source` }),
el('div', { class: 'fc-panel__sub', text: sub }),
...(discord ? [
el('div', { class: 'fc-panel__label', text: 'Follow' }),
scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id),
scopeRow('server', `Every channel in ${serverLabel(d)}`, false),
] : []),
el('div', { class: 'fc-panel__label', text: 'Artist' }),
el('div', { class: 'fc-panel__combo' }, [nameInput, results]),
renameRow,
hint,
el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]),
]);
@@ -205,12 +245,16 @@
let listOpen = false;
function refresh() {
const req = discordAddRequest(choice);
const req = addRequest(choice);
addBtn.disabled = !req;
if (!req) hint.textContent = 'Pick an artist or type a name.';
else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`;
else hint.textContent = `Creates a new artist “${req.artistName}”.`;
else if (req.artistName) hint.textContent = `Creates a new artist “${req.artistName}”.`;
else hint.textContent = `Creates a new artist, named from the ${platformName} page.`;
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
const offer = renameOffer(choice);
renameRow.hidden = !offer;
if (offer) renameText.textContent = `Rename “${offer.from}” to the Patreon name “${offer.to}”`;
}
function pick(artist) {
@@ -346,7 +390,7 @@
cancelBtn.addEventListener('click', closePanel);
addBtn.addEventListener('click', async () => {
const req = discordAddRequest(choice);
const req = addRequest(choice);
if (!req) return;
const btn = document.getElementById('fc-add-source-btn');
addBtn.disabled = true;
+11 -4
View File
@@ -104,16 +104,23 @@ class FabledCuratorAPI {
// artistId connects the source to an existing artist, artistName to the
// artist of that name (created if new); with neither the server derives the
// artist from the URL. A Discord channel always sends one.
quickAddSource(url, { artistId = null, artistName = null } = {}) {
// usePlatformName: a Patreon source joining an existing artist renames it
// to the Patreon display name (Patreon is canon; name only, never the slug).
quickAddSource(url, { artistId = null, artistName = null, usePlatformName = false } = {}) {
const body = { url };
if (artistId != null) body.artist_id = artistId;
else if (artistName) body.artist_name = artistName;
if (usePlatformName) body.use_platform_name = true;
return this.request('POST', '/extension/quick-add-source', body);
}
probeSource(url) {
probeSource(url, { names = false } = {}) {
// Read-only existence check. Drives the content-script chip's
// color/copy BEFORE the operator clicks Add.
const qs = new URLSearchParams({ url }).toString();
// color/copy BEFORE the operator clicks Add. `names` also reads the
// creator's display name from the platform — the Add panel asks for it,
// the chip doesn't, so a plain page view never costs a platform request.
const params = { url };
if (names) params.names = '1';
const qs = new URLSearchParams(params).toString();
return this.request('GET', `/extension/probe?${qs}`);
}
// Latest published extension version on this instance — drives the in-app
+53 -14
View File
@@ -1,7 +1,7 @@
/**
* 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.
* 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.
@@ -46,37 +46,76 @@ function serverLabel(d) {
}
/**
* 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.
* 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 discordPanelDefaults(probe) {
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 {
scope: d.channel_id ? 'channel' : 'server',
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: suggested ? suggested.name : (d.server_name || ''),
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. null when there is nothing valid to send.
* 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 discordAddRequest(choice) {
const url = choice.scope === 'server' ? choice.serverUrl : choice.channelUrl;
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) {
return { url, artistId: choice.artist.id };
const req = { url, artistId: choice.artist.id };
if (choice.adoptPlatformName && renameOffer(choice)) req.usePlatformName = true;
return req;
}
const name = (choice.artistName || '').trim();
return name ? { url, artistName: name } : null;
if (!name) return null;
if (choice.nameIsHandle && name === choice.handle) return { url };
return { url, artistName: name };
}
/**
+71 -14
View File
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest'
import { loadLib } from './helpers/loadLib.js'
const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [
const { chipState, chipLabel, panelDefaults, addRequest, renameOffer } = loadLib('chip.js', [
'chipState',
'chipLabel',
'discordPanelDefaults',
'discordAddRequest'
'panelDefaults',
'addRequest',
'renameOffer'
])
const discord = (extra = {}) => ({
@@ -63,47 +64,47 @@ describe('chip state and label', () => {
})
})
describe('Discord Add panel', () => {
describe('Add panel on Discord', () => {
it('opens on the channel with the suggested artist preselected', () => {
const d = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
const d = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(d.scope).toBe('channel')
expect(d.artist).toEqual({ id: 7, name: 'Tamada' })
expect(d.artistName).toBe('Tamada')
})
it('proposes a new artist named after the server when nothing is suggested', () => {
const d = discordPanelDefaults(discord({ state: 'new' }))
const d = panelDefaults(discord({ state: 'new' }))
expect(d.artist).toBe(null)
expect(d.artistName).toBe('Studio')
})
it('sends a picked artist by id', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(discordAddRequest(choice)).toEqual({
const choice = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
expect(addRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistId: 7
})
})
it('sends a typed name once the picked artist has been edited away', () => {
const choice = discordPanelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
const choice = panelDefaults(discord({ state: 'artist_match', artist: { id: 7, name: 'Tamada' } }))
choice.artistName = 'Tamada Alt'
expect(discordAddRequest(choice)).toEqual({
expect(addRequest(choice)).toEqual({
url: 'https://discord.com/channels/111/222',
artistName: 'Tamada Alt'
})
})
it('adds the whole server when the operator picks it', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
const choice = panelDefaults(discord({ state: 'new' }))
choice.scope = 'server'
expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111')
expect(addRequest(choice).url).toBe('https://discord.com/channels/111')
})
it('has nothing to send without an artist', () => {
const choice = discordPanelDefaults(discord({ state: 'new' }))
const choice = panelDefaults(discord({ state: 'new' }))
choice.artistName = ' '
expect(discordAddRequest(choice)).toBe(null)
expect(addRequest(choice)).toBe(null)
})
})
@@ -141,3 +142,59 @@ describe('artist matching for the Add panel', () => {
expect(inlineCompletion('', results)).toBe(null)
})
})
describe('Add panel on Patreon and SubscribeStar', () => {
const PAGE = 'https://www.patreon.com/cw/tamadaheijun'
const patreon = (extra = {}) => ({ platform: 'patreon', slug: 'tamadaheijun', ...extra })
it('opens on the Patreon display name as a new artist', () => {
const c = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
expect(c.scope).toBe('page')
expect(c.artistName).toBe('Tamada Heijun')
expect(addRequest(c)).toEqual({ url: PAGE, artistName: 'Tamada Heijun' })
})
it('leaves an untouched URL handle to the server to resolve', () => {
const c = panelDefaults(patreon({ state: 'new' }), PAGE)
expect(c.artistName).toBe('tamadaheijun')
expect(addRequest(c)).toEqual({ url: PAGE })
c.artistName = 'Someone Else'
expect(addRequest(c)).toEqual({ url: PAGE, artistName: 'Someone Else' })
})
it('offers the Patreon name when joining an artist known by another name', () => {
const c = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
c.artist = { id: 4, name: 'tamada' }
c.artistName = 'tamada'
expect(renameOffer(c)).toEqual({ from: 'tamada', to: 'Tamada Heijun' })
expect(addRequest(c)).toEqual({ url: PAGE, artistId: 4, usePlatformName: true })
c.adoptPlatformName = false
expect(addRequest(c)).toEqual({ url: PAGE, artistId: 4 })
})
it('offers no rename when the names agree or the name was not read', () => {
const same = panelDefaults(patreon({ state: 'new', display_name: 'Tamada Heijun' }), PAGE)
same.artist = { id: 4, name: 'Tamada Heijun' }
expect(renameOffer(same)).toBe(null)
const unread = panelDefaults(patreon({ state: 'new' }), PAGE)
unread.artist = { id: 4, name: 'tamada' }
expect(renameOffer(unread)).toBe(null)
})
it('never renames from SubscribeStar or Discord — Patreon is the canon', () => {
const ss = panelDefaults(
{ platform: 'subscribestar', slug: 'tamada', state: 'new', display_name: 'SS Tamada' },
'https://subscribestar.adult/tamada'
)
ss.artist = { id: 4, name: 'Tamada Heijun' }
ss.artistName = 'Tamada Heijun'
expect(renameOffer(ss)).toBe(null)
expect(addRequest(ss)).toEqual({ url: 'https://subscribestar.adult/tamada', artistId: 4 })
})
it('preselects the artist the URL already names', () => {
const c = panelDefaults(patreon({ state: 'artist_match', artist: { id: 9, name: 'Tamada' } }), PAGE)
expect(c.artist).toEqual({ id: 9, name: 'Tamada' })
expect(c.artistName).toBe('Tamada')
})
})