CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
Server (#4420) - extension_service gains a Discord pattern (server or channel, jump links, ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by the shared artist-url-samples.json. - probe on a Discord URL matches the source by ids under any artist, reports a whole-server source as covering the channel, suggests the artist who owns another source on the same server, and names server/channel via the stored token (best-effort, bounded, no rate-limit waits). - quick-add takes artist_id / artist_name; Discord URLs are stored canonical. Extension (#4421, #4422) - Content script on discord.com; SPA navigation by URL polling (the old pushState patch ran in the isolated world and never fired); stale probes are dropped. - Discord chip opens an Add panel: this channel or the whole server, and the suggested artist / a search / a new name. - Popup: sources show artist, platform and state; a Discord token export is verified by FC and the result shown. Token capture covers ptb/canary. - Pure logic in lib/chip.js and lib/popup-format.js, with specs. CI (#4423) - extension.yml's lane (web-ext lint, vitest, XPI contents) moves into build.yml as extension-test and joins the needs of sign-extension, build-web and build-agent. As a separate workflow it gated nothing: a red extension suite still signed and shipped the XPI (rule 177). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
const CONNECTION_TEST_INTERVAL = 2 * 60 * 1000;
|
|
|
|
// A centered muted note div — the loading / empty state shared by the platform
|
|
// and sources lists.
|
|
function mutedNote(text) {
|
|
const d = document.createElement('div');
|
|
d.style.cssText = 'text-align:center;padding:18px;color:var(--on-surface-variant);';
|
|
d.textContent = text;
|
|
return d;
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
const cfg = await browser.runtime.sendMessage({ type: 'GET_CONFIG' });
|
|
if (!cfg || !cfg.apiUrl || !cfg.apiKey) {
|
|
showSetupRequired();
|
|
return;
|
|
}
|
|
document.getElementById('setup-required').classList.add('hidden');
|
|
document.getElementById('main-content').classList.remove('hidden');
|
|
setupEventListeners();
|
|
showPlatformsLoading();
|
|
testConnectionIfNeeded();
|
|
checkForUpdate();
|
|
loadPlatformStatus().catch(e => showError(`Failed to load platforms: ${e.message}`));
|
|
} catch (e) {
|
|
showSetupRequired();
|
|
const alert = document.querySelector('#setup-required .alert');
|
|
alert.textContent = '';
|
|
const s = document.createElement('strong'); s.textContent = 'Error';
|
|
const p = document.createElement('p'); p.textContent = e.message;
|
|
alert.appendChild(s); alert.appendChild(p);
|
|
alert.classList.add('alert-error');
|
|
}
|
|
}
|
|
|
|
function showSetupRequired() {
|
|
document.getElementById('setup-required').classList.remove('hidden');
|
|
document.getElementById('main-content').classList.add('hidden');
|
|
document.getElementById('open-settings-btn').addEventListener('click', () => {
|
|
browser.runtime.openOptionsPage();
|
|
});
|
|
}
|
|
|
|
function showPlatformsLoading() {
|
|
const c = document.getElementById('platforms-list');
|
|
c.textContent = '';
|
|
c.appendChild(mutedNote('Loading platforms…'));
|
|
}
|
|
|
|
async function testConnectionIfNeeded() {
|
|
const stored = await browser.storage.local.get(['lastConnectionTest', 'lastConnectionStatus']);
|
|
const now = Date.now();
|
|
if (now - (stored.lastConnectionTest || 0) < CONNECTION_TEST_INTERVAL && stored.lastConnectionStatus !== undefined) {
|
|
updateConnectionDot(stored.lastConnectionStatus);
|
|
if (!stored.lastConnectionStatus) showError('Cannot connect to backend (cached).');
|
|
return;
|
|
}
|
|
const r = await browser.runtime.sendMessage({ type: 'TEST_CONNECTION' });
|
|
await browser.storage.local.set({ lastConnectionTest: now, lastConnectionStatus: r.connected });
|
|
updateConnectionDot(r.connected);
|
|
if (!r.connected) showError(`Cannot connect to backend: ${r.error}`);
|
|
}
|
|
|
|
function updateConnectionDot(connected) {
|
|
const d = document.getElementById('connection-status');
|
|
d.classList.toggle('connected', connected);
|
|
d.title = connected ? 'Connected to FabledCurator' : 'Disconnected';
|
|
}
|
|
|
|
// Nudge to reinstall when the configured instance publishes a newer signed XPI
|
|
// (the extension is self-hosted, so there's no Firefox auto-update). Never
|
|
// blocks the popup — a failed check just leaves the banner hidden.
|
|
async function checkForUpdate() {
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_UPDATE' });
|
|
if (r && r.updateAvailable && r.xpiUrl) showUpdateBanner(r);
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
function showUpdateBanner(r) {
|
|
// The channel names itself beside the version, never inside it (#3113).
|
|
// Absent when the instance doesn't report one, and the banner then reads
|
|
// exactly as it did before the field existed.
|
|
const channel = r.channel ? ` (${r.channel})` : '';
|
|
document.getElementById('update-text').textContent =
|
|
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
|
|
// Opening the signed XPI triggers Firefox's native install prompt.
|
|
document.getElementById('update-btn').addEventListener('click', () => {
|
|
browser.tabs.create({ url: r.xpiUrl });
|
|
});
|
|
document.getElementById('update-banner').classList.remove('hidden');
|
|
}
|
|
|
|
async function loadPlatformStatus() {
|
|
const status = await browser.runtime.sendMessage({ type: 'GET_PLATFORM_STATUS' });
|
|
const c = document.getElementById('platforms-list');
|
|
c.textContent = '';
|
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
|
c.appendChild(createPlatformCard(key, platform, status[key] || {}));
|
|
}
|
|
}
|
|
|
|
function createPlatformCard(key, platform, status) {
|
|
const card = document.createElement('div');
|
|
card.className = 'platform-card';
|
|
card.dataset.platform = key;
|
|
|
|
const isTokenOnly = platform.authType === 'token' && key !== 'discord';
|
|
const discordNeedsToken = key === 'discord' && !status.hasToken;
|
|
if (isTokenOnly || discordNeedsToken) card.classList.add('disabled');
|
|
|
|
const icon = document.createElement('div');
|
|
icon.className = 'platform-icon';
|
|
icon.style.background = platform.color;
|
|
icon.textContent = platform.name[0];
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
const name = document.createElement('div');
|
|
name.className = 'name';
|
|
name.textContent = platform.name;
|
|
const st = document.createElement('div');
|
|
st.className = `status ${statusClass(status, platform, key)}`;
|
|
st.textContent = statusText(status, platform, key);
|
|
info.appendChild(name); info.appendChild(st);
|
|
|
|
const act = document.createElement('span');
|
|
act.className = 'action-icon';
|
|
act.textContent = '↥';
|
|
|
|
card.appendChild(icon); card.appendChild(info); card.appendChild(act);
|
|
|
|
if (!isTokenOnly && !discordNeedsToken) {
|
|
card.addEventListener('click', () => exportPlatformCookies(key, card));
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function statusText(s, platform, key) {
|
|
if (key === 'discord') return s.hasToken ? 'Token captured — ready' : 'Open Discord to capture token';
|
|
if (platform.authType === 'token') return 'Manual token entry required';
|
|
if (s.error) return 'Error checking cookies';
|
|
if (!s.hasCookies || !s.cookieCount) return 'No cookies — log in first';
|
|
return `${s.cookieCount} cookies ready`;
|
|
}
|
|
function statusClass(s, platform, key) {
|
|
if (key === 'discord') return s.hasToken ? 'ready' : 'no-cookies';
|
|
if (platform.authType === 'token') return 'no-cookies';
|
|
if (s.error) return 'error';
|
|
if (!s.hasCookies || !s.cookieCount) return 'no-cookies';
|
|
return 'ready';
|
|
}
|
|
|
|
async function exportPlatformCookies(key, card) {
|
|
card.classList.add('loading'); hideStatusMessage();
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
|
|
if (r.error) showError(r.error);
|
|
else if (key === 'discord') {
|
|
const m = tokenExportMessage(r.verify);
|
|
showStatusMessage(m.text, m.kind);
|
|
await loadPlatformStatus();
|
|
} else {
|
|
const n = r.cookieCount ?? null;
|
|
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
|
|
const msg = n !== null
|
|
? `${PLATFORMS[key].name}: ${n} cookies exported${verifiedSuffix}`
|
|
: `${PLATFORMS[key].name}: token exported`;
|
|
showSuccess(msg);
|
|
await loadPlatformStatus();
|
|
}
|
|
} catch (e) { showError(e.message); }
|
|
finally { card.classList.remove('loading'); }
|
|
}
|
|
|
|
async function exportAllCookies() {
|
|
const btn = document.getElementById('export-all-btn');
|
|
btn.disabled = true; btn.textContent = 'Exporting…'; hideStatusMessage();
|
|
try {
|
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_ALL_COOKIES' });
|
|
const wins = Object.values(r).filter(x => x.success).length;
|
|
const fails = Object.values(r).filter(x => !x.success && !x.skipped).length;
|
|
if (wins && !fails) showSuccess(`Exported ${wins} platforms`);
|
|
else if (wins) showWarning(`${wins} succeeded, ${fails} failed`);
|
|
else if (fails) showError('All exports failed. Are you logged in?');
|
|
else showWarning('Nothing to export');
|
|
await loadPlatformStatus();
|
|
} catch (e) { showError(e.message); }
|
|
finally { btn.disabled = false; btn.textContent = 'Export all platforms'; }
|
|
}
|
|
|
|
async function loadSources() {
|
|
const c = document.getElementById('sources-list');
|
|
c.textContent = '';
|
|
c.appendChild(mutedNote('Loading sources…'));
|
|
const r = await browser.runtime.sendMessage({ type: 'LIST_SOURCES' });
|
|
c.textContent = '';
|
|
if (r.error) {
|
|
const e = document.createElement('div');
|
|
e.style.cssText = 'padding:12px;color:var(--error);';
|
|
e.textContent = r.error;
|
|
c.appendChild(e);
|
|
return;
|
|
}
|
|
if (!r.sources || r.sources.length === 0) {
|
|
c.appendChild(mutedNote('No sources yet.'));
|
|
return;
|
|
}
|
|
// Grouped by artist so a creator's Patreon and Discord sit together.
|
|
const sorted = [...r.sources].sort((a, b) =>
|
|
(a.artist_name || '').localeCompare(b.artist_name || '') || a.id - b.id);
|
|
for (const src of sorted) c.appendChild(createSourceRow(src));
|
|
}
|
|
|
|
function createSourceRow(src) {
|
|
const row = document.createElement('div');
|
|
row.className = 'source-row';
|
|
const info = document.createElement('div');
|
|
info.className = 'info';
|
|
const name = document.createElement('div');
|
|
name.className = 'name';
|
|
const platformName = PLATFORMS[src.platform]?.name || src.platform;
|
|
name.textContent = `${src.artist_name || `Source #${src.id}`} · ${platformName}`;
|
|
const state = sourceStatus(src);
|
|
const st = document.createElement('div');
|
|
st.className = `status ${state.kind}`;
|
|
st.textContent = state.text;
|
|
const url = document.createElement('div');
|
|
url.className = 'url';
|
|
url.textContent = src.url;
|
|
info.appendChild(name); info.appendChild(st); info.appendChild(url);
|
|
const play = document.createElement('button');
|
|
play.className = 'play';
|
|
play.textContent = '▶';
|
|
play.title = 'Check now';
|
|
play.addEventListener('click', async () => {
|
|
play.disabled = true;
|
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
|
|
play.disabled = false;
|
|
if (r.error) showError(r.error);
|
|
else showSuccess(`Check queued for ${src.artist_name || `source #${src.id}`} (${platformName})`);
|
|
});
|
|
row.appendChild(info); row.appendChild(play);
|
|
return row;
|
|
}
|
|
|
|
function setupEventListeners() {
|
|
document.getElementById('export-all-btn').addEventListener('click', exportAllCookies);
|
|
document.getElementById('settings-btn').addEventListener('click', () => browser.runtime.openOptionsPage());
|
|
for (const tab of document.querySelectorAll('.tab')) {
|
|
tab.addEventListener('click', () => {
|
|
for (const t of document.querySelectorAll('.tab')) t.classList.remove('active');
|
|
tab.classList.add('active');
|
|
for (const p of document.querySelectorAll('.tab-panel')) p.classList.add('hidden');
|
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.remove('hidden');
|
|
if (tab.dataset.tab === 'sources') loadSources();
|
|
});
|
|
}
|
|
}
|
|
|
|
function showSuccess(m) { showStatusMessage(m, 'success'); }
|
|
function showError(m) { showStatusMessage(m, 'error'); }
|
|
function showWarning(m) { showStatusMessage(m, 'warning'); }
|
|
function showStatusMessage(text, kind) {
|
|
const el = document.getElementById('status-message');
|
|
el.textContent = text;
|
|
el.className = `status-message ${kind}`;
|
|
el.classList.remove('hidden');
|
|
}
|
|
function hideStatusMessage() {
|
|
document.getElementById('status-message').classList.add('hidden');
|
|
}
|