diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index 5266d46..b917882 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -57,6 +57,24 @@ def _sha256(path: Path) -> str: return h.hexdigest() +@extension_bp.route("/probe", methods=["GET"]) +async def probe_source(): + """Read-only resolution of a creator-page URL: tells the extension + whether this URL is already a Source, is for an Artist that exists + but with a different URL, is brand new, or doesn't match any known + platform pattern. Drives the content-script chip's color/copy + BEFORE the operator clicks, so the button can show 'already added' + without requiring an add-attempt.""" + url = (request.args.get("url") or "").strip() + if not url: + return _bad("invalid_body", detail="url query parameter is required") + async with get_session() as session: + if not await _ext_key_required(session): + return _bad("unauthorized", status=401) + result = await ExtensionService(session).probe(url) + return jsonify(result) + + @extension_bp.route("/quick-add-source", methods=["POST"]) async def quick_add_source(): body = await request.get_json(silent=True) diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index ce34ed9..4c138de 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -86,6 +86,67 @@ class ExtensionService: "created_artist": created_artist, } + async def probe(self, url: str) -> dict: + """Read-only resolution of a creator-page URL against the FC DB. + Returns one of: + - {state: 'unknown_platform'} — URL didn't match any + platform's strict artist-page pattern + - {state: 'new', platform, slug} — would create both + artist and source on quick-add + - {state: 'artist_match', platform, slug, artist} + — artist exists, this + exact URL isn't a Source yet (collapses the sidecar-synthetic + case too — the synthetic anchor counts as an existing artist + row but not as a pollable Source for this URL) + - {state: 'source_match', platform, slug, artist, source} + — exact (artist, platform, + url) Source already exists + + Side-effect-free: two SELECTs at most. + """ + try: + platform, raw_slug = self._derive(url) + except (UnknownPlatformError, InvalidUrlError): + return {"state": "unknown_platform"} + + slug = slugify(raw_slug) + artist = (await self.session.execute( + select(Artist).where(Artist.slug == slug) + )).scalar_one_or_none() + if artist is None: + return {"state": "new", "platform": platform, "slug": slug} + + artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug} + + source = (await self.session.execute( + select(Source).where( + Source.artist_id == artist.id, + Source.platform == platform, + Source.url == url, + ) + )).scalar_one_or_none() + if source is None: + return { + "state": "artist_match", + "platform": platform, + "slug": slug, + "artist": artist_payload, + } + + return { + "state": "source_match", + "platform": platform, + "slug": slug, + "artist": artist_payload, + "source": { + "id": source.id, + "artist_id": source.artist_id, + "platform": source.platform, + "url": source.url, + "enabled": source.enabled, + }, + } + def _derive(self, url: str) -> tuple[str, str]: if not isinstance(url, str) or not url.strip(): raise InvalidUrlError("url is empty") diff --git a/extension/background/background.js b/extension/background/background.js index 988647a..a19c900 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -259,6 +259,29 @@ browser.runtime.onMessage.addListener(async (msg) => { return { error: e.message }; } + case 'PROBE_SOURCE': + try { + return await api.probeSource(msg.url); + } catch (e) { + return { error: e.message }; + } + + case 'OPEN_ARTIST_PAGE': { + // apiUrl is configured with the /api suffix (see + // options/options.html placeholder); the SPA artist route is + // /artist/:slug, served from the same origin. Strip /api so the + // browser-level URL hits the Vue router, not the JSON API. + const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, ''); + const slug = encodeURIComponent(msg.slug || ''); + if (!base || !slug) return { error: 'apiUrl or slug missing' }; + try { + await browser.tabs.create({ url: `${base}/artist/${slug}` }); + return { success: true }; + } catch (e) { + return { error: e.message }; + } + } + default: return { error: `Unknown message type: ${msg.type}` }; } diff --git a/extension/content/content-script.css b/extension/content/content-script.css index d55243f..2818651 100644 --- a/extension/content/content-script.css +++ b/extension/content/content-script.css @@ -5,11 +5,26 @@ background: rgb(20, 23, 26); color: rgb(244, 186, 122); font: 500 14px/1.2 system-ui, sans-serif; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer; - transition: transform 100ms ease; + transition: transform 100ms ease, background 150ms ease, color 150ms ease; } .fc-add-source-btn:hover { transform: translateY(-1px); } .fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; } +/* state colors map to the FC palette: parchment-on-slate base, + accent-orange for new, sage for already-subscribed, amber-warning for + artist-exists-but-source-missing. All readable on the dark base. */ +.fc-add-source-btn--new { + background: rgb(20, 23, 26); color: rgb(244, 186, 122); +} +.fc-add-source-btn--artist-match { + background: rgb(28, 23, 16); color: rgb(255, 200, 120); + border: 1px solid rgb(180, 130, 60); +} +.fc-add-source-btn--source-match { + background: rgb(18, 28, 20); color: rgb(140, 220, 160); + border: 1px solid rgb(80, 160, 100); +} + .fc-toast { all: revert; position: fixed; bottom: 84px; right: 24px; z-index: 2147483647; diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 51154b0..780dfb5 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -2,6 +2,10 @@ if (window.__fc_addsource_injected) return; window.__fc_addsource_injected = true; + // Cached probe result for the current URL so click-handlers know which + // action to dispatch without round-tripping again. + let currentProbe = null; + evaluate(); const reEval = () => evaluate(); @@ -9,38 +13,116 @@ const origPush = history.pushState; history.pushState = function () { origPush.apply(this, arguments); reEval(); }; - function evaluate() { - const platform = getPlatformFromUrl(window.location.href); - const onArtist = platform && isArtistPage(window.location.href, platform); - let btn = document.getElementById('fc-add-source-btn'); - if (onArtist && !btn) injectButton(); - else if (!onArtist && btn) btn.remove(); + async function evaluate() { + const url = window.location.href; + const platform = getPlatformFromUrl(url); + const onArtist = platform && isArtistPage(url, platform); + const btn = document.getElementById('fc-add-source-btn'); + if (!onArtist) { + if (btn) btn.remove(); + currentProbe = null; + return; + } + // On artist pages, ask the backend what state the URL is in BEFORE + // injecting the button — so the chip can render the right state on + // first paint instead of flashing the generic "Add" copy and + // updating afterwards. + let probe; + try { + probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url }); + } catch (e) { + probe = { error: e?.message || 'probe failed' }; + } + currentProbe = probe; + if (probe?.state === 'unknown_platform') { + if (btn) btn.remove(); + return; + } + renderButton(probe); } - function injectButton() { - const btn = document.createElement('button'); - btn.id = 'fc-add-source-btn'; + function renderButton(probe) { + let btn = document.getElementById('fc-add-source-btn'); + if (!btn) { + btn = document.createElement('button'); + btn.id = 'fc-add-source-btn'; + btn.addEventListener('click', onClick); + document.body.appendChild(btn); + } + // Reset state classes so re-renders (SPA navigation) don't stack. btn.className = 'fc-add-source-btn'; - btn.textContent = '+ Add to FabledCurator'; - btn.addEventListener('click', onClick); - document.body.appendChild(btn); + btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`); + btn.textContent = labelFor(probe); + btn.disabled = false; + } + + function stateModifier(probe) { + if (!probe || probe.error) return 'new'; + return ({ + source_match: 'source-match', + artist_match: 'artist-match', + new: 'new', + })[probe.state] || 'new'; + } + + function labelFor(probe) { + if (!probe || probe.error) return '+ Add to FabledCurator'; + const platformName = platformDisplayName(probe.platform); + const artistName = probe.artist?.name; + switch (probe.state) { + case 'source_match': + return `✓ In FabledCurator · ${platformName}`; + case 'artist_match': + return `+ Add ${platformName} source to ${artistName || 'artist'}`; + case 'new': + default: + return '+ Add to FabledCurator'; + } + } + + function platformDisplayName(key) { + return PLATFORMS[key]?.name || key || ''; } async function onClick() { const btn = document.getElementById('fc-add-source-btn'); + if (!btn) return; btn.disabled = true; const original = btn.textContent; + const probe = currentProbe; + + if (probe?.state === 'source_match') { + btn.textContent = 'Opening…'; + try { + const r = await browser.runtime.sendMessage({ + type: 'OPEN_ARTIST_PAGE', + slug: probe.artist?.slug, + }); + if (r?.error) showToast(`Error: ${r.error}`, 'error'); + } catch (e) { + showToast(`Error: ${e.message}`, 'error'); + } finally { + btn.disabled = false; + btn.textContent = original; + } + return; + } + btn.textContent = 'Adding…'; try { const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', url: window.location.href, }); - if (r.error) { + if (r?.error) { showToast(`Error: ${r.error}`, 'error'); } else { const verb = r.created_source ? 'Added' : 'Already a source for'; showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); + // Re-probe so the chip flips green without waiting for the next + // navigation. + evaluate(); + return; } } catch (e) { showToast(`Error: ${e.message}`, 'error'); diff --git a/extension/lib/api.js b/extension/lib/api.js index 60fc95d..37b2c74 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -83,6 +83,12 @@ class FabledCuratorAPI { quickAddSource(url) { return this.request('POST', '/extension/quick-add-source', { url }); } + probeSource(url) { + // Read-only existence check. Drives the content-script chip's + // color/copy BEFORE the operator clicks Add. + const qs = new URLSearchParams({ url }).toString(); + return this.request('GET', `/extension/probe?${qs}`); + } // Connection test = the cheapest read with auth. testConnection() { diff --git a/extension/manifest.json b/extension/manifest.json index 1bc2f47..701884e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "FabledCurator", - "version": "1.0.5", + "version": "1.0.6", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "browser_specific_settings": { diff --git a/extension/package.json b/extension/package.json index d38e933..d6151b9 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.5", + "version": "1.0.6", "private": true, "description": "Firefox extension for FabledCurator", "scripts": { diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index 9f79c0e..98c7dec 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -135,6 +135,114 @@ async def test_quick_add_source_wrong_key_401(client, ext_key): assert resp.status_code == 401 +# --- /api/extension/probe --------------------------------------------- + + +@pytest.mark.asyncio +async def test_probe_returns_new_when_nothing_exists(client, ext_key): + resp = await client.get( + "/api/extension/probe", + query_string={"url": "https://www.patreon.com/freshcreator"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["state"] == "new" + assert body["platform"] == "patreon" + assert body["slug"] == "freshcreator" + + +@pytest.mark.asyncio +async def test_probe_returns_source_match_for_already_added(client, ext_key, db): + artist = Artist(name="Alice", slug="alice", is_subscription=True) + db.add(artist) + await db.flush() + src = Source( + artist_id=artist.id, platform="patreon", + url="https://www.patreon.com/alice", enabled=True, config_overrides={}, + ) + db.add(src) + await db.commit() + + resp = await client.get( + "/api/extension/probe", + query_string={"url": "https://www.patreon.com/alice"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["state"] == "source_match" + assert body["artist"]["slug"] == "alice" + assert body["source"]["url"] == "https://www.patreon.com/alice" + assert body["source"]["platform"] == "patreon" + + +@pytest.mark.asyncio +async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists( + client, ext_key, db, +): + """Filesystem-imported artist with only a sidecar synthetic Source + for the (artist, platform) — the URL the operator's browsing isn't + yet a real Source. The probe should collapse this into artist_match + so the chip says '+ Add Patreon source to Dymkens' rather than + '+ Add to FabledCurator' (which would re-create the artist).""" + artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False) + db.add(artist) + await db.flush() + synthetic = Source( + artist_id=artist.id, platform="patreon", + url="sidecar:patreon:dymkens", enabled=False, config_overrides={}, + ) + db.add(synthetic) + await db.commit() + + resp = await client.get( + "/api/extension/probe", + query_string={"url": "https://www.patreon.com/dymkens"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["state"] == "artist_match" + assert body["artist"]["slug"] == "dymkens" + assert "source" not in body + + +@pytest.mark.asyncio +async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key): + """A patreon URL that isn't an artist page (e.g. /home, /posts/N) + shouldn't trigger the button. Sentinel 'unknown_platform' state + tells the content script to skip injection.""" + resp = await client.get( + "/api/extension/probe", + query_string={"url": "https://www.patreon.com/posts/12345"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["state"] == "unknown_platform" + + +@pytest.mark.asyncio +async def test_probe_missing_key_401(client): + resp = await client.get( + "/api/extension/probe", + query_string={"url": "https://www.patreon.com/maewix"}, + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_probe_missing_url_400(client, ext_key): + resp = await client.get( + "/api/extension/probe", + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 400 + body = await resp.get_json() + assert body["error"] == "invalid_body" + + @pytest.mark.asyncio async def test_quick_add_source_missing_body_400(client, ext_key): resp = await client.post(