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
146 lines
5.2 KiB
JavaScript
146 lines
5.2 KiB
JavaScript
/**
|
|
* FC backend client. Talks to /api/credentials (FC-3b), /api/sources
|
|
* (FC-3a), and the new /api/extension/* endpoints (FC-3g).
|
|
*/
|
|
|
|
class FabledCuratorAPI {
|
|
constructor() {
|
|
this.baseUrl = null;
|
|
this.apiKey = null;
|
|
}
|
|
|
|
async init() {
|
|
const cfg = await browser.storage.local.get(['apiUrl', 'apiKey']);
|
|
// Normalize on READ, not just on save: configs stored before the options
|
|
// page started normalizing are missing the `/api` suffix, and this heals
|
|
// them without the operator having to reopen Settings.
|
|
this.baseUrl = normalizeApiUrl(cfg.apiUrl) || null;
|
|
this.apiKey = cfg.apiKey || null;
|
|
return this.isConfigured();
|
|
}
|
|
|
|
isConfigured() {
|
|
return !!(this.baseUrl && this.apiKey);
|
|
}
|
|
|
|
async request(method, endpoint, data = null) {
|
|
if (!this.isConfigured()) {
|
|
throw new Error('Not configured. Set FC URL + API key in settings.');
|
|
}
|
|
const url = `${this.baseUrl.replace(/\/+$/, '')}${endpoint}`;
|
|
const options = {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Extension-Key': this.apiKey,
|
|
},
|
|
};
|
|
if (data) options.body = JSON.stringify(data);
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(url, options);
|
|
} catch (e) {
|
|
throw new Error('Cannot connect to FabledCurator. Check URL.');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let message;
|
|
try {
|
|
const body = await response.json();
|
|
message = body.error || `HTTP ${response.status}`;
|
|
if (body.detail) message += `: ${body.detail}`;
|
|
} catch {
|
|
message = `HTTP ${response.status}: ${response.statusText}`;
|
|
}
|
|
// 404/405 from FC almost always means the request never reached the JSON
|
|
// API — it fell through to the SPA catch-all, which serves HTML on GET
|
|
// and rejects everything else. Say so, rather than making the operator
|
|
// decode "Method Not Allowed" on an endpoint that plainly allows POST.
|
|
if (response.status === 404 || response.status === 405) {
|
|
message += ` — ${url} isn't the FC API. Check the FC URL in settings.`;
|
|
}
|
|
const err = new Error(message);
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
if (response.status === 204) return { success: true };
|
|
return response.json();
|
|
}
|
|
|
|
// FC-3b — credentials.
|
|
uploadCredentials(platform, credentialType, data) {
|
|
return this.request('POST', '/credentials', {
|
|
platform,
|
|
credential_type: credentialType,
|
|
data,
|
|
});
|
|
}
|
|
getCredentials() {
|
|
return this.request('GET', '/credentials');
|
|
}
|
|
// Test the STORED credential against one of the platform's sources — the
|
|
// same check the web UI's Verify button runs. {valid: true|false|null, reason}.
|
|
verifyCredential(platform) {
|
|
return this.request('POST', `/credentials/${encodeURIComponent(platform)}/verify`);
|
|
}
|
|
|
|
// Artist search for the Discord Add panel — the web UI's autocomplete.
|
|
searchArtists(q, limit = 8) {
|
|
const qs = new URLSearchParams({ q, limit: String(limit) }).toString();
|
|
return this.request('GET', `/artists/autocomplete?${qs}`);
|
|
}
|
|
|
|
// FC-3a — sources.
|
|
listSources() {
|
|
return this.request('GET', '/sources');
|
|
}
|
|
triggerSourceCheck(sourceId) {
|
|
return this.request('POST', `/sources/${sourceId}/check`);
|
|
}
|
|
|
|
// FC-3g — extension-specific.
|
|
// 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.
|
|
// 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, { names = false } = {}) {
|
|
// Read-only existence check. Drives the content-script chip's
|
|
// 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
|
|
// update prompt. Public endpoint (no key needed, but request() sends it
|
|
// harmlessly). Returns {version, xpi_url, latest_url, sha256}.
|
|
getExtensionManifest() {
|
|
return this.request('GET', '/extension/manifest');
|
|
}
|
|
|
|
// The web/SPA root: where the Vue router (artist pages) and the served XPI
|
|
// live, NOT the JSON API. Used by OPEN_ARTIST_PAGE + the self-update check.
|
|
webRoot() {
|
|
return webRootFromApiUrl(this.baseUrl);
|
|
}
|
|
|
|
// Connection test = the cheapest read with auth.
|
|
testConnection() {
|
|
return this.request('GET', '/credentials');
|
|
}
|
|
}
|
|
|
|
const api = new FabledCuratorAPI();
|