diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 0b1aed6..a255774 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -242,7 +242,7 @@ jobs: # rather than left running beside the new mechanism (rule 22). # # Two things are still worth asserting, and this is the only lane that can: - # the extension.yml suite runs on node:24-slim, which is exactly why + # the extension-test lane runs on node:24-slim, which is exactly why # version.spec.js sticks to packaging.sh's git-free subcommands. # 1. the derivation actually resolves on this commit # 2. the derived string is one AMO will accept, checked against Mozilla's @@ -378,6 +378,76 @@ jobs: - run: npm run test:unit - run: npm run build + # The extension's lane: web-ext lint, the vitest suite, and the check that + # asks web-ext what it ACTUALLY packaged. Moved in from `extension.yml` + # (deleted) at milestone 429 — the same move `ci.yml` made on 2026-09-23 and + # for the same reason: a separate workflow cannot gate this one, so a red + # extension suite still let `sign-extension` sign and `build-web` ship the + # XPI. It is a lane in THE GATE now. Its old path filter is gone with it; a + # filter here would make the lane skip, and a skipped lane blocks the publish. + # + # The vitest suite is also the JS half of the #3093 artist-pattern mirror — + # the Python half runs in backend-lint-and-test — so until this move one half + # of that guard could fail without stopping anything. + extension-test: + runs-on: python-ci + container: + image: node:24-bookworm-slim + steps: + - uses: actions/checkout@v4 + # Not --no-save: vitest and web-ext are both real devDependencies now, + # and the suite needs vitest resolvable from node_modules. + - name: Install dev dependencies + run: cd extension && npm install --no-audit --no-fund + - name: Lint + run: cd extension && npm run lint + # Pure-logic specs over lib/url.js and lib/platforms.js plus manifest / + # package version-consistency checks. No browser, no network. + - name: Unit tests + run: cd extension && npm run test:unit + + # Everything else about packaging is asserted against our own declaration + # of what ships. This is the only check that asks web-ext what it ACTUALLY + # put in the archive. Until now that was an unverified assumption about + # glob semantics — and a fragile one: `test/**` reaches web-ext intact + # only because callers `set -f` first, so losing that quoting would + # silently start shipping dev files with no other signal. + - name: Verify XPI contents + run: | + set -eu + command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; } + cd extension + npm run build + ZIP=$(ls web-ext-artifacts/*.zip | head -1) + echo "=== packaged entries in $ZIP ===" + unzip -Z1 "$ZIP" | sort + echo "=== end ===" + ENTRIES=$(unzip -Z1 "$ZIP") + fail=0 + # Must NOT ship: repo infrastructure with no business in a user's browser. + for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do + if echo "$ENTRIES" | grep -q "^$pat"; then + echo "ERROR: '$pat' was packaged into the XPI but must not be" + fail=1 + fi + done + # Must ship: if an exclusion pattern ever over-matches, the extension + # breaks at runtime rather than at build time, so assert presence too. + for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js' 'lib/chip.js' 'lib/popup-format.js'; do + if ! echo "$ENTRIES" | grep -q "^$req$"; then + echo "ERROR: '$req' is missing from the XPI" + fail=1 + fi + done + for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do + if ! echo "$ENTRIES" | grep -q "^$dir"; then + echo "ERROR: nothing from '$dir' was packaged" + fail=1 + fi + done + [ "$fail" -eq 0 ] || exit 1 + echo "XPI contents verified." + # Single integration job — collapsed from a 3-way shard split on 2026-06-04. # The shards existed to parallelize ~8.5min of integration tests; once the # throwaway Postgres runs with fsync OFF (the durability step below) the whole @@ -521,7 +591,8 @@ jobs: # itself through a job-level `if:` that could not read `env`, and a design # where only a FAILED gate blocks would have published unverified images # while reporting success. - needs: [lint, extension-version, backend-lint-and-test, frontend-build, integration] + needs: [lint, extension-version, backend-lint-and-test, frontend-build, extension-test, + integration] # A pull_request run is the lanes and nothing else. This is the ONLY thing # separating "validate a Renovate bump" from "publish a Renovate bump", so # it is stated on each publishing job rather than inferred from a `needs` @@ -859,7 +930,7 @@ jobs: # default behaviour is exactly what we want: a failed sign skips build-web # rather than shipping an image without its XPI. needs: [sign-extension, lint, extension-version, backend-lint-and-test, - frontend-build, integration] + frontend-build, extension-test, integration] # The lanes in that list are THE GATE (2026-09-23) — see sign-extension's # copy of this comment for why, including the property that a SKIPPED lane # blocks as firmly as a failing one. They are repeated here rather than @@ -2083,7 +2154,8 @@ jobs: # itself through a job-level `if:` that could not read `env`, and a design # where only a FAILED gate blocks would have published unverified images # while reporting success. - needs: [lint, extension-version, backend-lint-and-test, frontend-build, integration] + needs: [lint, extension-version, backend-lint-and-test, frontend-build, extension-test, + integration] # What `promote` needs to publish this image once the smoke has passed: # the manifest this run built (empty on a reuse hit) and every tag it # belongs under. Same meaning as build-web's outputs of the same names. diff --git a/.forgejo/workflows/extension.yml b/.forgejo/workflows/extension.yml deleted file mode 100644 index b2e9876..0000000 --- a/.forgejo/workflows/extension.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: extension -# Lint + unit tests. Deliberately NOT a publishing lane, which is why it is -# not part of build.yml's gate. The sign-and-publish dance moved into build.yml's -# `sign-extension` job (2026-05-25) — `:latest` now always bundles the XPI -# because sign-extension runs as a build-web dependency in the SAME workflow, -# eliminating the prior race between build.yml and a separate extension.yml. -# Signed XPIs are cached in Forgejo Release Assets named `ext-`. -on: - push: - branches: [dev, main] - paths: - - 'extension/**' - - '.forgejo/workflows/extension.yml' - # test/version.spec.js asserts things ABOUT build.yml — that it does not - # inline the packaged-file set, and that build.yml derives - # the shipped version rather than reading it out of the repo. A - # workflow-only edit can therefore break this suite, so it has to trigger - # it. build.yml joined the list at milestone 271 step 5, when the spec - # started asserting against it. - - '.forgejo/workflows/build.yml' - pull_request: - branches: [main] - paths: - - 'extension/**' - - '.forgejo/workflows/build.yml' - workflow_dispatch: - -jobs: - lint: - runs-on: python-ci - container: - image: node:24-bookworm-slim - steps: - - uses: actions/checkout@v4 - # Not --no-save: vitest and web-ext are both real devDependencies now, - # and the suite needs vitest resolvable from node_modules. - - name: Install dev dependencies - run: cd extension && npm install --no-audit --no-fund - - name: Lint - run: cd extension && npm run lint - # Pure-logic specs over lib/url.js and lib/platforms.js plus manifest / - # package version-consistency checks. No browser, no network. - - name: Unit tests - run: cd extension && npm run test:unit - - # Everything else about packaging is asserted against our own declaration - # of what ships. This is the only check that asks web-ext what it ACTUALLY - # put in the archive. Until now that was an unverified assumption about - # glob semantics — and a fragile one: `test/**` reaches web-ext intact - # only because callers `set -f` first, so losing that quoting would - # silently start shipping dev files with no other signal. - - name: Verify XPI contents - run: | - set -eu - command -v unzip >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq unzip; } - cd extension - npm run build - ZIP=$(ls web-ext-artifacts/*.zip | head -1) - echo "=== packaged entries in $ZIP ===" - unzip -Z1 "$ZIP" | sort - echo "=== end ===" - ENTRIES=$(unzip -Z1 "$ZIP") - fail=0 - # Must NOT ship: repo infrastructure with no business in a user's browser. - for pat in 'test/' 'scripts/' 'vitest.config.js' 'package.json' 'package-lock.json' 'README.md' 'node_modules/' 'web-ext-artifacts/'; do - if echo "$ENTRIES" | grep -q "^$pat"; then - echo "ERROR: '$pat' was packaged into the XPI but must not be" - fail=1 - fi - done - # Must ship: if an exclusion pattern ever over-matches, the extension - # breaks at runtime rather than at build time, so assert presence too. - for req in 'manifest.json' 'lib/url.js' 'lib/api.js' 'lib/platforms.js' 'lib/cookies.js'; do - if ! echo "$ENTRIES" | grep -q "^$req$"; then - echo "ERROR: '$req' is missing from the XPI" - fail=1 - fi - done - for dir in 'background/' 'popup/' 'options/' 'content/' 'icons/'; do - if ! echo "$ENTRIES" | grep -q "^$dir"; then - echo "ERROR: nothing from '$dir' was packaged" - fail=1 - fi - done - [ "$fail" -eq 0 ] || exit 1 - echo "XPI contents verified." diff --git a/README.md b/README.md index 4699a1a..e5348c3 100644 --- a/README.md +++ b/README.md @@ -271,11 +271,12 @@ Four deployable pieces, built by `.forgejo/workflows/build.yml`: ## CI / Forgejo setup -Three workflows: `build.yml` (the five verification lanes — lint, -extension-version check, backend unit tests, frontend build, integration — and -then sign + publish), `extension.yml` (extension lint, vitest, XPI content -verification), and `release.yml`, which runs only on a `v*` tag and publishes a -changelog without building anything. +Two workflows that matter here: `build.yml` (the six verification lanes — lint, +extension-version check, backend unit tests, frontend build, extension lint + +vitest + XPI content check, integration — and then sign + publish), and +`release.yml`, which runs only on a `v*` tag and publishes a changelog without +building anything. The extension lane was its own `extension.yml` until +milestone 429, which let a red extension suite sign and ship the XPI anyway. **The lanes and the publish are one workflow on purpose.** They were two (`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which diff --git a/backend/app/api/extension.py b/backend/app/api/extension.py index d13688f..ce03206 100644 --- a/backend/app/api/extension.py +++ b/backend/app/api/extension.py @@ -19,6 +19,7 @@ from ..models import AppSetting from ..services.extension_service import ( ExtensionService, InvalidUrlError, + UnknownArtistError, UnknownPlatformError, ) from ..services.source_service import KNOWN_PLATFORMS @@ -87,10 +88,14 @@ async def probe_source(): url = (request.args.get("url") or "").strip() if not url: return _bad("invalid_body", detail="url query parameter is required") + from .credentials import _get_crypto + async with get_session() as session: if not await _ext_key_required(session): return _bad("unauthorized", status=401) - result = await ExtensionService(session).probe(url) + # crypto lets a Discord probe name the server and channel with the + # stored token; every other platform ignores it. + result = await ExtensionService(session, _get_crypto()).probe(url) return jsonify(result) @@ -102,6 +107,15 @@ async def quick_add_source(): url = body.get("url") if not isinstance(url, str) or not url.strip(): return _bad("invalid_body", detail="url is required") + # Optional: connect the new source to an existing artist (artist_id) or to + # the artist of that name (artist_name). A Discord channel names no + # creator, so the extension's Add panel always sends one of them. + artist_id = body.get("artist_id") + if artist_id is not None and (isinstance(artist_id, bool) or not isinstance(artist_id, int)): + return _bad("invalid_body", detail="artist_id must be an integer") + artist_name = body.get("artist_name") + if artist_name is not None and not isinstance(artist_name, str): + return _bad("invalid_body", detail="artist_name must be a string") from .credentials import _get_crypto @@ -111,7 +125,11 @@ async def quick_add_source(): try: # crypto lets an add resolve the artist's display name via the # stored credential (else it falls back to the URL handle). #130. - result = await ExtensionService(session, _get_crypto()).quick_add_source(url) + result = await ExtensionService(session, _get_crypto()).quick_add_source( + url, artist_id=artist_id, artist_name=artist_name, + ) + except UnknownArtistError as exc: + return _bad("not_found", detail=str(exc), status=404) except UnknownPlatformError as exc: return _bad( "unknown_platform", diff --git a/backend/app/services/discord_client.py b/backend/app/services/discord_client.py index dfa5d50..9d5b154 100644 --- a/backend/app/services/discord_client.py +++ b/backend/app/services/discord_client.py @@ -510,6 +510,24 @@ class DiscordClient: # -- verify ------------------------------------------------------------ + def describe(self, server_id: str | None, channel_id: str | None) -> dict: + """The display names behind a server/channel pair, for the browser + extension's Add panel. Best-effort per name: one that can't be read + comes back None, and the other is still returned.""" + out: dict = {"server": None, "channel": None, "parent": None} + if server_id: + try: + out["server"] = (self._get(f"/guilds/{server_id}") or {}).get("name") or None + except DiscordAPIError: + pass + if channel_id: + try: + meta = self._parse_channel(self._get(f"/channels/{channel_id}")) + out["channel"] = meta.get("channel") or None + except (DiscordAPIError, AttributeError): + pass + return out + def verify_auth(self, url: str) -> tuple[bool | None, str]: """Is the token valid, and can it see what the source names?""" try: diff --git a/backend/app/services/extension_service.py b/backend/app/services/extension_service.py index 53ac894..f2d7f5f 100644 --- a/backend/app/services/extension_service.py +++ b/backend/app/services/extension_service.py @@ -21,6 +21,9 @@ from .source_service import BACKFILL_MAX_CHUNKS log = logging.getLogger(__name__) +# The probe runs while the chip is drawing; names that take longer are skipped. +_NAME_LOOKUP_SECONDS = 6.0 + class UnknownPlatformError(Exception): """URL didn't match any platform pattern.""" @@ -30,6 +33,10 @@ class InvalidUrlError(Exception): """URL was empty or missing a scheme.""" +class UnknownArtistError(Exception): + """quick-add named an `artist_id` that does not exist.""" + + # Mirrored byte-for-byte from extension/lib/platforms.js # PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand — # reviewers catch drift. @@ -55,8 +62,39 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P[^/?#]+)", re.IGNORECASE, )), + # A Discord URL names a server or a channel, never a creator, so the slug is + # `` or `/` and the artist is chosen, not derived. + # A trailing message id (a jump link) still names its channel. DMs (`@me`) + # are not sources; thread links (`/threads/`) are left to the manual form. + ("discord", re.compile( + r"^https?://(?:www\.|ptb\.|canary\.)?discord\.com/channels/" + r"(?P\d+(?:/\d+)?)(?:/\d+)?/?(?:[?#].*)?$", + re.IGNORECASE, + )), ] +DISCORD = "discord" + + +def canonical_source_url(platform: str, url: str, slug: str) -> str: + """The URL a new source is stored under. Discord's is rebuilt from the ids + — the form the manual Add form and the ingester use — so a jump link, a + ptb/canary host or a trailing slash never makes a second source for the + same channel. Every other platform keeps the URL as given.""" + if platform == DISCORD: + return f"https://discord.com/channels/{slug}" + return url + + +def _discord_ids(url: str) -> tuple[str | None, str | None] | None: + """`(server_id, channel_id)` of a stored Discord source URL, None if it + does not parse (a DM or thread link, or an old malformed row).""" + from .discord_client import DiscordAPIError, parse_source_url + try: + return parse_source_url(url) + except DiscordAPIError: + return None + class ExtensionService: def __init__(self, session: AsyncSession, crypto=None) -> None: @@ -65,30 +103,63 @@ class ExtensionService: # add-time. None → skip resolution, fall back to the handle. self._crypto = crypto - async def quick_add_source(self, url: str) -> dict: + async def quick_add_source( + self, + url: str, + *, + artist_id: int | None = None, + artist_name: str | None = None, + ) -> dict: + """Add `url` as a source. `artist_id` connects it to an existing + artist, `artist_name` to that artist (created if new); with neither, + the artist is resolved from the platform as before.""" platform, raw_slug = self._derive(url) + url = canonical_source_url(platform, url, raw_slug) # Identity by SOURCE handle (#130): an existing (platform, url) source # keeps its artist on re-add — even if that artist was since renamed (its - # frozen slug no longer matches the current name). Only a genuinely new - # source resolves/creates an artist. - existing = (await self.session.execute( - select(Source).where(Source.platform == platform, Source.url == url) - )).scalar_one_or_none() + # frozen slug no longer matches the current name), and even when the + # add named a different artist. Only a genuinely new source + # resolves/creates an artist. + existing = await self._existing_source(platform, url) if existing is not None: artist = (await self.session.execute( select(Artist).where(Artist.id == existing.artist_id) )).scalar_one() return self._shape(existing, artist, created_source=False, created_artist=False) - # New source → name the artist properly by resolving the real display - # name from the platform (falls back to the URL handle). - name = await self._resolve_artist_name(platform, raw_slug, url) - artist, created_artist = await self._find_or_create_artist(name) + if artist_id is not None: + artist = (await self.session.execute( + select(Artist).where(Artist.id == artist_id) + )).scalar_one_or_none() + if artist is None: + raise UnknownArtistError(f"no artist with id {artist_id}") + created_artist = False + else: + name = (artist_name or "").strip() + if not name: + # Name the artist properly by resolving the real display name + # from the platform (falls back to the URL handle). + name = await self._resolve_artist_name(platform, raw_slug, url) + artist, created_artist = await self._find_or_create_artist(name) source, created_source = await self._find_or_create_source( artist_id=artist.id, platform=platform, url=url, ) return self._shape(source, artist, created_source, created_artist) + async def _existing_source(self, platform: str, url: str) -> Source | None: + """The source this URL already is, whichever artist owns it. Discord + compares ids, not strings, so a row stored before canonicalisation (a + ptb host, a trailing slash) is still found.""" + if platform != DISCORD: + return (await self.session.execute( + select(Source).where(Source.platform == platform, Source.url == url) + )).scalars().first() + want = _discord_ids(url) + rows = (await self.session.execute( + select(Source).where(Source.platform == DISCORD).order_by(Source.id) + )).scalars().all() + return next((s for s in rows if _discord_ids(s.url) == want), None) + @staticmethod def _shape(source, artist, created_source: bool, created_artist: bool) -> dict: return { @@ -117,6 +188,11 @@ class ExtensionService: platforms (and any failure — no credential, network error) fall back to the URL handle, which is already readable. The resolvers are sync, so they run in an executor.""" + if platform == DISCORD: + # The server's name: what the operator knows the community as. + server_id = raw_slug.split("/", 1)[0] + names = await self._discord_names(server_id, None) + return names.get("server") or f"Discord {server_id}" if self._crypto is None or platform not in ("patreon", "subscribestar"): return raw_slug import asyncio @@ -166,6 +242,8 @@ class ExtensionService: platform, raw_slug = self._derive(url) except (UnknownPlatformError, InvalidUrlError): return {"state": "unknown_platform"} + if platform == DISCORD: + return await self._probe_discord(raw_slug) slug = slugify(raw_slug) artist = (await self.session.execute( @@ -205,6 +283,106 @@ class ExtensionService: }, } + async def _probe_discord(self, raw_slug: str) -> dict: + """probe for a Discord server or channel. The states mean what they + mean elsewhere, but the artist is never read off the URL: + + - source_match: this channel is a source — or the whole server is + (`covered_by_server`), which already walks every channel; + - artist_match: another source on this server belongs to an artist, + the one this channel most likely belongs to too (a suggestion the + Add panel preselects, not a decision); + - new: nothing on this server yet. + + `discord` carries the ids, both canonical URLs and the display names, + read with the stored token; a name that can't be read is None.""" + server_id, _, channel_id = raw_slug.partition("/") + channel_id = channel_id or None + rows = (await self.session.execute( + select(Source, Artist) + .join(Artist, Artist.id == Source.artist_id) + .where(Source.platform == DISCORD) + .order_by(Source.id) + )).all() + exact = server_whole = on_server = None + for source, artist in rows: + ids = _discord_ids(source.url) + if ids is None or ids[0] != server_id: + continue + if ids[1] == channel_id and exact is None: + exact = (source, artist) + elif ids[1] is None and server_whole is None: + server_whole = (source, artist) + if on_server is None: + on_server = (source, artist) + + names = await self._discord_names(server_id, channel_id) + base = f"https://discord.com/channels/{server_id}" + result: dict = { + "platform": DISCORD, + "slug": raw_slug, + "discord": { + "server_id": server_id, + "channel_id": channel_id, + "server_name": names.get("server"), + "channel_name": names.get("channel"), + "server_url": base, + "channel_url": f"{base}/{channel_id}" if channel_id else None, + }, + } + hit = exact or server_whole + if hit is not None: + source, artist = hit + result.update( + state="source_match", + artist=self._artist_payload(artist), + source=self._source_payload(source), + covered_by_server=exact is None, + ) + elif on_server is not None: + result.update(state="artist_match", artist=self._artist_payload(on_server[1])) + else: + result["state"] = "new" + return result + + async def _discord_names(self, server_id: str | None, channel_id: str | None) -> dict: + """Server/channel display names via the stored Discord token. Never + raises and never waits out a rate limit: it runs while the operator + looks at a page, so a slow or missing answer just means no names.""" + if self._crypto is None: + return {} + import asyncio + + from .credential_service import CredentialService + from .discord_client import DiscordClient + try: + token = await CredentialService(self.session, self._crypto).get_token(DISCORD) + if not token: + return {} + client = DiscordClient(token, max_retries=0) + loop = asyncio.get_running_loop() + return await asyncio.wait_for( + loop.run_in_executor(None, client.describe, server_id, channel_id), + timeout=_NAME_LOOKUP_SECONDS, + ) + except Exception as exc: # names are decoration — never fail the call + log.info("Discord name lookup failed: %s", exc) + return {} + + @staticmethod + def _artist_payload(artist) -> dict: + return {"id": artist.id, "name": artist.name, "slug": artist.slug} + + @staticmethod + def _source_payload(source) -> dict: + return { + "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/ci-requirements.md b/ci-requirements.md index 90c8b7b..963be1f 100644 --- a/ci-requirements.md +++ b/ci-requirements.md @@ -15,7 +15,7 @@ git.fabledsword.com/bvandeusen/ci-python:3.14 ## Secondary runtime image -node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only. +node:24-bookworm-slim — `build.yml`'s `extension-test` job only. `.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else and installs nothing: it needs git and stdlib python, and builds no image. @@ -29,8 +29,8 @@ per `docs/process.md`'s "add deps to the image when used by >1 project". - `pip install -r requirements.txt pytest pytest-asyncio` — in `backend-lint-and-test` and `integration` jobs - `npm install --no-audit --no-fund` — in `frontend-build` job -- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest) -- `unzip` — in `extension.yml`'s "Verify XPI contents" step, installed via apt +- `npm install --no-audit --no-fund` — in `build.yml`'s `extension-test` job (web-ext + vitest) +- `unzip` — in `extension-test`'s "Verify XPI contents" step, installed via apt only when absent (`node:24-bookworm-slim` may or may not carry it). Debian package, ~2s. Not worth baking into a shared image for a single consumer, per `docs/process.md`'s ">1 project" rule. diff --git a/extension/background/background.js b/extension/background/background.js index dd5155a..7a5f36c 100644 --- a/extension/background/background.js +++ b/extension/background/background.js @@ -128,7 +128,8 @@ browser.webRequest.onBeforeSendHeaders.addListener( saveDiscordToken(auth.value); } }, - { urls: ['https://discord.com/api/*'] }, + // ptb/canary are Discord's beta clients; their API calls carry the same token. + { urls: ['https://discord.com/api/*', 'https://*.discord.com/api/*'] }, ['requestHeaders'], ); @@ -213,7 +214,17 @@ browser.runtime.onMessage.addListener(async (msg) => { if (key === 'discord') { if (!discordToken) return { error: 'Open discord.com to capture a token first.' }; await api.uploadCredentials('discord', 'token', discordToken); - return { success: true }; + // Then have FC try it against a Discord source, so a token Discord + // has already revoked shows up here rather than at the next check. + // A failed verify never undoes the upload: valid=null means FC could + // not test (no Discord source yet), not that the token is bad. + let verify = null; + try { + verify = await api.verifyCredential('discord'); + } catch (e) { + verify = { valid: null, reason: e.message }; + } + return { success: true, verify }; } return { error: 'Unsupported platform.' }; } catch (e) { @@ -256,7 +267,17 @@ browser.runtime.onMessage.addListener(async (msg) => { case 'ADD_AS_SOURCE': try { - return await api.quickAddSource(msg.url); + return await api.quickAddSource(msg.url, { + artistId: msg.artistId ?? null, + artistName: msg.artistName ?? null, + }); + } catch (e) { + return { error: e.message }; + } + + case 'SEARCH_ARTISTS': + try { + return { artists: await api.searchArtists(msg.q || '') }; } catch (e) { return { error: e.message }; } diff --git a/extension/content/content-script.css b/extension/content/content-script.css index 2818651..6ac3bdf 100644 --- a/extension/content/content-script.css +++ b/extension/content/content-script.css @@ -40,3 +40,45 @@ from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + +/* Discord Add panel — sits above the chip. Same slate/parchment palette. */ +.fc-panel { + all: revert; + position: fixed; bottom: 76px; right: 24px; z-index: 2147483647; + box-sizing: border-box; width: 320px; max-width: calc(100vw - 48px); + padding: 14px 16px; border-radius: 10px; + background: rgb(20, 23, 26); color: rgb(232, 228, 216); + border: 1px solid rgb(60, 64, 70); + font: 14px/1.4 system-ui, sans-serif; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); +} +.fc-panel__title { font-weight: 600; color: rgb(244, 186, 122); } +.fc-panel__sub { color: rgb(170, 166, 156); font-size: 12px; margin-bottom: 8px; } +.fc-panel__label { + margin: 10px 0 4px; font-size: 11px; letter-spacing: 0.06em; + text-transform: uppercase; color: rgb(170, 166, 156); +} +.fc-panel__radio { display: flex; gap: 8px; align-items: center; padding: 2px 0; cursor: pointer; } +.fc-panel__radio input { margin: 0; accent-color: rgb(244, 186, 122); } +.fc-panel__input { + all: revert; box-sizing: border-box; width: 100%; + padding: 7px 9px; border-radius: 6px; + border: 1px solid rgb(70, 74, 80); background: rgb(12, 14, 16); color: inherit; + font: inherit; +} +.fc-panel__input:focus { outline: 2px solid rgb(244, 186, 122); outline-offset: -1px; } +.fc-panel__results { display: flex; flex-direction: column; max-height: 160px; overflow-y: auto; } +.fc-panel__result { + all: revert; text-align: left; padding: 6px 9px; border: none; border-radius: 4px; + background: transparent; color: inherit; font: inherit; cursor: pointer; +} +.fc-panel__result:hover, .fc-panel__result:focus { background: rgb(36, 40, 46); } +.fc-panel__hint { margin-top: 8px; font-size: 12px; color: rgb(170, 166, 156); } +.fc-panel__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } +.fc-panel__btn { + all: revert; padding: 6px 14px; border-radius: 999px; cursor: pointer; + border: 1px solid rgb(70, 74, 80); background: transparent; color: inherit; + font: 500 13px/1.2 system-ui, sans-serif; +} +.fc-panel__btn--primary { border-color: rgb(244, 186, 122); background: rgb(244, 186, 122); color: rgb(20, 23, 26); } +.fc-panel__btn:disabled { opacity: 0.5; cursor: default; } diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 780dfb5..1d57474 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -5,42 +5,61 @@ // Cached probe result for the current URL so click-handlers know which // action to dispatch without round-tripping again. let currentProbe = null; + // Bumped on every evaluate(): a probe that answers after the operator has + // navigated on is for a page they've left, and must not repaint the chip. + let generation = 0; + let lastUrl = window.location.href; evaluate(); - const reEval = () => evaluate(); - window.addEventListener('popstate', reEval); - const origPush = history.pushState; - history.pushState = function () { origPush.apply(this, arguments); reEval(); }; + // SPA navigation. Patreon, SubscribeStar and above all Discord change + // channel/page without a reload. Patching history.pushState from here never + // worked: a content script runs in an isolated world, so the page's own + // pushState is not the function we'd replace. Polling the URL is the one + // signal that sees every navigation, and costs a string compare. + window.addEventListener('popstate', () => onUrlMaybeChanged()); + setInterval(onUrlMaybeChanged, 500); + + function onUrlMaybeChanged() { + if (window.location.href === lastUrl) return; + lastUrl = window.location.href; + closePanel(); + evaluate(); + } async function evaluate() { + const mine = ++generation; 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(); + removeButton(); 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. + // Ask the backend what state the URL is in BEFORE drawing the button, so + // the chip renders 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' }; } + if (mine !== generation) return; currentProbe = probe; if (probe?.state === 'unknown_platform') { - if (btn) btn.remove(); + removeButton(); return; } renderButton(probe); } + function removeButton() { + document.getElementById('fc-add-source-btn')?.remove(); + closePanel(); + } + function renderButton(probe) { let btn = document.getElementById('fc-add-source-btn'); if (!btn) { @@ -51,79 +70,36 @@ } // Reset state classes so re-renders (SPA navigation) don't stack. btn.className = 'fc-add-source-btn'; - btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`); - btn.textContent = labelFor(probe); + btn.classList.add(`fc-add-source-btn--${chipState(probe)}`); + btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || ''); 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; - } + 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); + return; + } + await add(btn, { url: window.location.href }); + } - btn.textContent = 'Adding…'; + async function openArtist(btn, slug) { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = 'Opening…'; try { - const r = await browser.runtime.sendMessage({ - type: 'ADD_AS_SOURCE', - url: window.location.href, - }); - 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; - } + const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug }); + if (r?.error) showToast(`Error: ${r.error}`, 'error'); } catch (e) { showToast(`Error: ${e.message}`, 'error'); } finally { @@ -132,6 +108,153 @@ } } + // One add, shared by the one-click chip and the Discord panel. Resolves + // true on success. + async function add(btn, request) { + btn.disabled = true; + const original = btn.textContent; + btn.textContent = 'Adding…'; + try { + const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', ...request }); + if (r?.error) { + showToast(`Error: ${r.error}`, 'error'); + return false; + } + const verb = r.created_source ? 'Added to' : 'Already a source for'; + showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); + // Re-probe so the chip flips green without waiting for a navigation. + evaluate(); + return true; + } catch (e) { + showToast(`Error: ${e.message}`, 'error'); + return false; + } finally { + btn.disabled = false; + btn.textContent = original; + } + } + + // ---- 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. + + function el(tag, props = {}, children = []) { + const node = document.createElement(tag); + const { class: className, text, ...rest } = props; + if (className) node.className = className; + if (text != null) node.textContent = text; + Object.assign(node, rest); + for (const c of children) node.appendChild(c); + return node; + } + + function closePanel() { + document.getElementById('fc-discord-panel')?.remove(); + } + + function openDiscordPanel(probe) { + closePanel(); + const d = probe.discord || {}; + const choice = discordPanelDefaults(probe); + let searchSeq = 0; + + const scopeRow = (value, label, disabled) => { + const input = el('input', { + type: 'radio', name: 'fc-discord-scope', value, + checked: choice.scope === value, disabled, + }); + input.addEventListener('change', () => { choice.scope = value; refresh(); }); + return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]); + }; + + const nameInput = el('input', { + type: 'text', class: 'fc-panel__input', value: choice.artistName, + placeholder: 'Artist name — search or type a new one', + autocomplete: 'off', spellcheck: false, + }); + const results = el('div', { class: 'fc-panel__results' }); + const hint = el('div', { class: 'fc-panel__hint' }); + 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), + el('div', { class: 'fc-panel__label', text: 'Artist' }), + nameInput, + results, + hint, + el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), + ]); + + function refresh() { + const req = discordAddRequest(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} in FabledCurator.`; + else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`; + } + + function showResults(rows) { + results.replaceChildren(...rows.map((a) => { + const row = el('button', { class: 'fc-panel__result', text: a.name }); + row.addEventListener('click', () => { + choice.artist = { id: a.id, name: a.name }; + choice.artistName = a.name; + nameInput.value = a.name; + results.replaceChildren(); + refresh(); + }); + return row; + })); + } + + let debounce = null; + nameInput.addEventListener('input', () => { + choice.artistName = nameInput.value; + refresh(); + clearTimeout(debounce); + const q = nameInput.value.trim(); + if (!q) { results.replaceChildren(); return; } + debounce = setTimeout(async () => { + const mine = ++searchSeq; + let r; + try { + r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q }); + } catch { + return; + } + if (mine !== searchSeq || r?.error) return; + showResults(r.artists || []); + }, 200); + }); + // Keep Discord's global shortcuts from eating keystrokes meant for us. + panel.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Escape') closePanel(); + if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click(); + }); + + cancelBtn.addEventListener('click', closePanel); + addBtn.addEventListener('click', async () => { + const req = discordAddRequest(choice); + if (!req) return; + const btn = document.getElementById('fc-add-source-btn'); + addBtn.disabled = true; + const ok = await add(btn || addBtn, req); + if (ok) closePanel(); + else refresh(); + }); + + document.body.appendChild(panel); + refresh(); + nameInput.focus(); + } + function showToast(text, kind) { const t = document.createElement('div'); t.className = `fc-toast fc-toast--${kind}`; diff --git a/extension/lib/api.js b/extension/lib/api.js index e66ea93..4ae2f19 100644 --- a/extension/lib/api.js +++ b/extension/lib/api.js @@ -80,6 +80,17 @@ class FabledCuratorAPI { 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() { @@ -90,8 +101,14 @@ class FabledCuratorAPI { } // FC-3g — extension-specific. - quickAddSource(url) { - return this.request('POST', '/extension/quick-add-source', { url }); + // 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 } = {}) { + const body = { url }; + if (artistId != null) body.artist_id = artistId; + else if (artistName) body.artist_name = artistName; + return this.request('POST', '/extension/quick-add-source', body); } probeSource(url) { // Read-only existence check. Drives the content-script chip's diff --git a/extension/lib/chip.js b/extension/lib/chip.js new file mode 100644 index 0000000..b17fddb --- /dev/null +++ b/extension/lib/chip.js @@ -0,0 +1,80 @@ +/** + * 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. + * + * `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 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. + */ +function discordPanelDefaults(probe) { + const d = probe?.discord || {}; + const suggested = probe?.state === 'artist_match' && probe.artist ? probe.artist : null; + return { + scope: d.channel_id ? 'channel' : 'server', + 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 || ''), + }; +} + +/** + * 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. + */ +function discordAddRequest(choice) { + const url = choice.scope === 'server' ? choice.serverUrl : 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 name = (choice.artistName || '').trim(); + return name ? { url, artistName: name } : null; +} diff --git a/extension/lib/platforms.js b/extension/lib/platforms.js index 86972d9..b42d613 100644 --- a/extension/lib/platforms.js +++ b/extension/lib/platforms.js @@ -57,7 +57,8 @@ const PLATFORMS = { domains: ['.discord.com', 'discord.com'], authType: 'token', color: '#5865F2', - urlPattern: /^https?:\/\/(www\.)?discord\.com/, + // ptb/canary are Discord's beta clients — same channels, same token. + urlPattern: /^https?:\/\/((www|ptb|canary)\.)?discord\.com/, note: 'Open Discord in browser to capture token', }, }; @@ -80,8 +81,23 @@ const PLATFORM_ARTIST_PATTERNS = { patreon: /^https?:\/\/(www\.)?patreon\.com\/(?:cw\/|c\/)?(?!(?:home|search|messages|notifications|library|settings|posts)(?:[\/?#]|$))[^/?#]+/i, subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i, hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/i, + // A Discord URL names a server or channel, not a creator: the backend's slug + // is `[/]` and the Add panel asks which artist it belongs + // to. A message jump link still names its channel; DMs (@me) and thread + // links don't match. Mirrors extension_service._PLATFORM_PATTERNS. + discord: /^https?:\/\/(?:www\.|ptb\.|canary\.)?discord\.com\/channels\/\d+(?:\/\d+)?(?:\/\d+)?\/?(?:[?#].*)?$/i, }; +/** + * `{serverId, channelId}` from a Discord channel/server URL the artist + * pattern accepts, else null. channelId is null for a whole-server URL. + */ +function parseDiscordUrl(url) { + if (!PLATFORM_ARTIST_PATTERNS.discord.test(url || '')) return null; + const m = /\/channels\/(\d+)(?:\/(\d+))?/.exec(url); + return m ? { serverId: m[1], channelId: m[2] || null } : null; +} + function getPlatformFromUrl(url) { for (const [key, platform] of Object.entries(PLATFORMS)) { if (platform.urlPattern.test(url)) return key; diff --git a/extension/lib/popup-format.js b/extension/lib/popup-format.js new file mode 100644 index 0000000..f056c8b --- /dev/null +++ b/extension/lib/popup-format.js @@ -0,0 +1,55 @@ +/** + * The popup's wording, kept apart from its DOM so the specs can load it + * (test/popup-format.spec.js). Classic script, like the rest of lib/. + */ + +/** + * One line of state for a source row, from /api/sources' fields, with the + * status class the popup colours it by ('ready' | 'error' | 'no-cookies' for + * a warning | '' for plain). The most actionable fact wins: an error before + * a running backfill, a backfill before the last-checked time. + */ +function sourceStatus(src, now = Date.now()) { + if (!src.enabled) return { text: 'Disabled', kind: '' }; + if (src.last_error) { + const first = String(src.last_error).split('\n')[0].trim(); + const short = first.length > 90 ? `${first.slice(0, 89)}…` : first; + return { text: `Error — ${short}`, kind: 'error' }; + } + if (src.backfill_state === 'running') { + const n = src.backfill_chunks || 0; + return { text: n ? `Backfilling — ${n} chunk${n === 1 ? '' : 's'} done` : 'Backfill queued', kind: 'ready' }; + } + if (src.backfill_state === 'stalled') return { text: 'Backfill stalled', kind: 'no-cookies' }; + if (!src.last_checked_at) return { text: 'Not checked yet', kind: '' }; + return { text: `Checked ${relativeTime(src.last_checked_at, now)}`, kind: '' }; +} + +// Same buckets and wording as the web UI's canonical formatRelative +// (frontend/src/utils/date.js, snippet #3959) — the extension can't import it (classic +// scripts, separate package), so it mirrors it: "42s ago", "5m ago", "3h ago", +// "2d ago", and "Never" for a missing or unreadable time. +function relativeTime(iso, now = Date.now()) { + const t = iso ? Date.parse(iso) : NaN; + if (Number.isNaN(t)) return 'Never'; + const abs = Math.abs(now - t) / 1000; + let body; + if (abs < 60) body = `${Math.floor(abs)}s`; + else if (abs < 3600) body = `${Math.floor(abs / 60)}m`; + else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`; + else body = `${Math.floor(abs / 86400)}d`; + return `${body} ago`; +} + +/** + * The popup message after a Discord token export, from FC's verify of the + * stored token: {text, kind} with kind 'success' | 'warning' | 'error'. + * valid=null is "FC couldn't test it" (no Discord source yet, or a network + * hiccup) — a warning with FC's reason, never a failure. + */ +function tokenExportMessage(verify) { + if (!verify) return { text: 'Discord: token exported', kind: 'success' }; + if (verify.valid === true) return { text: `Discord: token exported and verified ✓ — ${verify.reason}`, kind: 'success' }; + if (verify.valid === false) return { text: `Discord: token exported, but FC's check failed — ${verify.reason}`, kind: 'error' }; + return { text: `Discord: token exported (not verified — ${verify.reason})`, kind: 'warning' }; +} diff --git a/extension/manifest.json b/extension/manifest.json index 3227775..719d5d6 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -56,9 +56,10 @@ "*://*.patreon.com/*", "*://*.subscribestar.com/*", "*://*.subscribestar.adult/*", - "*://*.hentai-foundry.com/*" + "*://*.hentai-foundry.com/*", + "*://*.discord.com/*" ], - "js": ["lib/platforms.js", "content/content-script.js"], + "js": ["lib/platforms.js", "lib/chip.js", "content/content-script.js"], "css": ["content/content-script.css"], "run_at": "document_idle" } diff --git a/extension/popup/popup.html b/extension/popup/popup.html index e41615b..c20ae93 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -47,6 +47,7 @@ + diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 7becdba..1874c9b 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -159,7 +159,11 @@ async function exportPlatformCookies(key, card) { try { const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key }); if (r.error) showError(r.error); - else { + 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 @@ -205,7 +209,10 @@ async function loadSources() { c.appendChild(mutedNote('No sources yet.')); return; } - for (const src of r.sources) c.appendChild(createSourceRow(src)); + // 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) { @@ -215,11 +222,16 @@ function createSourceRow(src) { info.className = 'info'; const name = document.createElement('div'); name.className = 'name'; - name.textContent = `${src.platform} · #${src.id}`; + 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(url); + info.appendChild(name); info.appendChild(st); info.appendChild(url); const play = document.createElement('button'); play.className = 'play'; play.textContent = '▶'; @@ -229,7 +241,7 @@ function createSourceRow(src) { const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id }); play.disabled = false; if (r.error) showError(r.error); - else showSuccess(`Triggered check for source #${src.id}`); + else showSuccess(`Check queued for ${src.artist_name || `source #${src.id}`} (${platformName})`); }); row.appendChild(info); row.appendChild(play); return row; diff --git a/extension/test/artist-url-samples.json b/extension/test/artist-url-samples.json index 712edf4..ceb793d 100644 --- a/extension/test/artist-url-samples.json +++ b/extension/test/artist-url-samples.json @@ -134,5 +134,42 @@ { "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" }, { "url": "https://www.hentai-foundry.com/", "why": "site root" } ] + }, + + "discord": { + "match": [ + { + "url": "https://discord.com/channels/111111111111111111/222222222222222222", + "slug": "111111111111111111/222222222222222222", + "why": "a channel: the slug is server/channel -- a Discord URL names a place, not a creator, so the artist is chosen in the Add panel" + }, + { + "url": "https://discord.com/channels/111111111111111111", + "slug": "111111111111111111", + "why": "a whole server" + }, + { + "url": "https://discord.com/channels/111111111111111111/222222222222222222/333333333333333333", + "slug": "111111111111111111/222222222222222222", + "why": "a message jump link still names its channel" + }, + { + "url": "https://ptb.discord.com/channels/111111111111111111/222222222222222222", + "slug": "111111111111111111/222222222222222222", + "why": "the ptb and canary clients serve the same channels" + }, + { + "url": "https://discord.com/channels/111111111111111111/222222222222222222/", + "slug": "111111111111111111/222222222222222222", + "why": "trailing slash is tolerated" + } + ], + "no_match": [ + { "url": "https://discord.com/channels/@me", "why": "the DM list is not a source" }, + { "url": "https://discord.com/channels/@me/222222222222222222", "why": "a DM is not a source" }, + { "url": "https://discord.com/app", "why": "the app shell, no server open" }, + { "url": "https://discord.com/channels/111111111111111111/222222222222222222/threads/444444444444444444", "why": "thread links are left to the manual Add form" }, + { "url": "https://discord.com/servers/111111111111111111", "why": "a server-discovery page, not a channel" } + ] } } diff --git a/extension/test/chip.spec.js b/extension/test/chip.spec.js new file mode 100644 index 0000000..b6955d5 --- /dev/null +++ b/extension/test/chip.spec.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest' +import { loadLib } from './helpers/loadLib.js' + +const { chipState, chipLabel, discordPanelDefaults, discordAddRequest } = loadLib('chip.js', [ + 'chipState', + 'chipLabel', + 'discordPanelDefaults', + 'discordAddRequest' +]) + +const discord = (extra = {}) => ({ + platform: 'discord', + slug: '111/222', + discord: { + server_id: '111', + channel_id: '222', + server_name: 'Studio', + channel_name: 'drops', + server_url: 'https://discord.com/channels/111', + channel_url: 'https://discord.com/channels/111/222' + }, + ...extra +}) + +describe('chip state and label', () => { + it('keeps the one-click wording on creator platforms', () => { + expect(chipLabel({ state: 'new', platform: 'patreon' }, 'Patreon')).toBe('+ Add to FabledCurator') + expect( + chipLabel({ state: 'artist_match', platform: 'patreon', artist: { name: 'Atole' } }, 'Patreon') + ).toBe('+ Add Patreon source to Atole') + expect(chipLabel({ state: 'source_match', platform: 'patreon' }, 'Patreon')).toBe( + '✓ In FabledCurator · Patreon' + ) + }) + + it('offers the channel by name on Discord, whatever the suggestion', () => { + expect(chipLabel(discord({ state: 'new' }), 'Discord')).toBe('+ Add #drops to FabledCurator') + expect(chipLabel(discord({ state: 'artist_match', artist: { name: 'A' } }), 'Discord')).toBe( + '+ Add #drops to FabledCurator' + ) + }) + + it('says "this channel" when the token could not read the name', () => { + const p = discord({ state: 'new' }) + p.discord.channel_name = null + expect(chipLabel(p, 'Discord')).toBe('+ Add this channel to FabledCurator') + }) + + it('says whose source a Discord channel already is, and when the server covers it', () => { + const artist = { name: 'Tamada', slug: 'tamada' } + expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: false }), 'Discord')).toBe( + '✓ In FabledCurator · Tamada' + ) + expect(chipLabel(discord({ state: 'source_match', artist, covered_by_server: true }), 'Discord')).toBe( + '✓ Whole server in FabledCurator · Tamada' + ) + }) + + it('falls back to the generic add when the probe failed', () => { + expect(chipState({ error: 'x' })).toBe('new') + expect(chipLabel({ error: 'x' }, '')).toBe('+ Add to FabledCurator') + expect(chipState({ state: 'source_match' })).toBe('source-match') + }) +}) + +describe('Discord Add panel', () => { + it('opens on the channel with the suggested artist preselected', () => { + const d = discordPanelDefaults(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' })) + 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({ + 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' } })) + choice.artistName = 'Tamada Alt' + expect(discordAddRequest(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' })) + choice.scope = 'server' + expect(discordAddRequest(choice).url).toBe('https://discord.com/channels/111') + }) + + it('has nothing to send without an artist', () => { + const choice = discordPanelDefaults(discord({ state: 'new' })) + choice.artistName = ' ' + expect(discordAddRequest(choice)).toBe(null) + }) +}) diff --git a/extension/test/platforms.spec.js b/extension/test/platforms.spec.js index bee61ab..367419c 100644 --- a/extension/test/platforms.spec.js +++ b/extension/test/platforms.spec.js @@ -7,10 +7,14 @@ import { loadLib } from './helpers/loadLib.js' const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8')) -const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib( - 'platforms.js', - ['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS'] -) +const { getPlatformFromUrl, isArtistPage, parseDiscordUrl, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = + loadLib('platforms.js', [ + 'getPlatformFromUrl', + 'isArtistPage', + 'parseDiscordUrl', + 'PLATFORMS', + 'PLATFORM_ARTIST_PATTERNS' + ]) describe('getPlatformFromUrl', () => { it('identifies each platform from a domain URL', () => { @@ -88,8 +92,11 @@ describe('isArtistPage', () => { ) }) - it('returns false for a platform with no artist pattern (discord)', () => { + it('matches Discord server and channel pages, not DMs (milestone 429)', () => { + expect(isArtistPage('https://discord.com/channels/111/222', 'discord')).toBe(true) + expect(isArtistPage('https://discord.com/channels/111', 'discord')).toBe(true) expect(isArtistPage('https://discord.com/channels/@me', 'discord')).toBe(false) + expect(isArtistPage('https://discord.com/channels/@me/222', 'discord')).toBe(false) }) it('returns false for an unknown platform key', () => { @@ -100,8 +107,8 @@ describe('isArtistPage', () => { describe('platform table integrity', () => { it('gives every artist pattern a corresponding platform entry', () => { // A pattern keyed to a platform that no longer exists is dead code that - // silently never fires; the reverse (a platform with no pattern) is the - // legitimate discord case, so only this direction is an error. + // silently never fires; the reverse (a platform with no pattern) would be + // a platform the button never offers, a product choice rather than an error. for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) { expect(Object.keys(PLATFORMS)).toContain(key) } @@ -125,7 +132,8 @@ describe('platform table integrity', () => { const samples = { patreon: 'https://www.patreon.com/cw/Atole', subscribestar: 'https://subscribestar.adult/someone', - hentaifoundry: 'https://www.hentai-foundry.com/user/someone' + hentaifoundry: 'https://www.hentai-foundry.com/user/someone', + discord: 'https://ptb.discord.com/channels/111/222' } for (const [key, url] of Object.entries(samples)) { expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true) @@ -152,7 +160,7 @@ describe('manifest.json agrees with the platform table', () => { ) expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy() // The content script exists to draw the Add-as-source button, so a - // platform with no artist pattern (discord) has no business here. + // platform with no artist pattern has no business here. expect( PLATFORM_ARTIST_PATTERNS[owner[0]], `"${m}" injects for ${owner[0]}, which has no artist pattern` @@ -232,9 +240,8 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => { it('has samples for every platform that has an artist pattern', () => { // The guard's own coverage check: without it, deleting a platform's - // samples would make this block pass by testing less. Discord is - // deliberately in neither — it is channel-based, with no creator page to - // put a button on, so it has no artist pattern on either side. + // samples would make this block pass by testing less. Discord joined at + // milestone 429 — its slug is server/channel and the artist is chosen. expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort()) }) @@ -247,3 +254,26 @@ describe('the JS<->Py artist-pattern mirror (#3093)', () => { } }) }) + +describe('parseDiscordUrl', () => { + it('reads the server and channel ids the Add panel offers', () => { + expect(parseDiscordUrl('https://discord.com/channels/111/222')).toEqual({ + serverId: '111', + channelId: '222' + }) + expect(parseDiscordUrl('https://discord.com/channels/111/222/333')).toEqual({ + serverId: '111', + channelId: '222' + }) + expect(parseDiscordUrl('https://discord.com/channels/111')).toEqual({ + serverId: '111', + channelId: null + }) + }) + + it('returns null for anything the artist pattern rejects', () => { + expect(parseDiscordUrl('https://discord.com/channels/@me/222')).toBe(null) + expect(parseDiscordUrl('https://discord.com/app')).toBe(null) + expect(parseDiscordUrl('')).toBe(null) + }) +}) diff --git a/extension/test/popup-format.spec.js b/extension/test/popup-format.spec.js new file mode 100644 index 0000000..ceee042 --- /dev/null +++ b/extension/test/popup-format.spec.js @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { loadLib } from './helpers/loadLib.js' + +const { sourceStatus, relativeTime, tokenExportMessage } = loadLib('popup-format.js', [ + 'sourceStatus', + 'relativeTime', + 'tokenExportMessage' +]) + +const NOW = Date.parse('2026-09-25T12:00:00Z') +const src = (extra = {}) => ({ enabled: true, last_error: null, backfill_state: null, ...extra }) + +describe('sourceStatus', () => { + it('puts an error first, trimmed to its first line', () => { + const s = sourceStatus(src({ last_error: 'Discord rejected the token\nstack…', backfill_state: 'running' }), NOW) + expect(s).toEqual({ text: 'Error — Discord rejected the token', kind: 'error' }) + }) + + it('shows a running backfill and its progress', () => { + expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 0 }), NOW).text).toBe('Backfill queued') + expect(sourceStatus(src({ backfill_state: 'running', backfill_chunks: 3 }), NOW).text).toBe( + 'Backfilling — 3 chunks done' + ) + }) + + it('says when a source was last checked, or that it never was', () => { + expect(sourceStatus(src({ last_checked_at: '2026-09-25T11:55:00Z' }), NOW).text).toBe('Checked 5m ago') + expect(sourceStatus(src({ last_checked_at: null }), NOW).text).toBe('Not checked yet') + }) + + it('shows a disabled source as disabled, whatever else it carries', () => { + expect(sourceStatus(src({ enabled: false, last_error: 'x' }), NOW).text).toBe('Disabled') + }) +}) + +describe('relativeTime', () => { + it('uses the web UI formatRelative buckets', () => { + expect(relativeTime('2026-09-25T11:59:18Z', NOW)).toBe('42s ago') + expect(relativeTime('2026-09-25T09:00:00Z', NOW)).toBe('3h ago') + expect(relativeTime('2026-09-23T12:00:00Z', NOW)).toBe('2d ago') + expect(relativeTime('garbage', NOW)).toBe('Never') + }) +}) + +describe('tokenExportMessage', () => { + it('distinguishes verified, rejected and untested tokens', () => { + expect(tokenExportMessage({ valid: true, reason: 'Token valid (me)' }).kind).toBe('success') + expect(tokenExportMessage({ valid: false, reason: 'Discord rejected the token' }).kind).toBe('error') + const untested = tokenExportMessage({ valid: null, reason: 'No enabled source' }) + expect(untested.kind).toBe('warning') + expect(untested.text).toContain('No enabled source') + }) +}) diff --git a/tests/test_api_extension.py b/tests/test_api_extension.py index fee226b..03eeef2 100644 --- a/tests/test_api_extension.py +++ b/tests/test_api_extension.py @@ -355,6 +355,177 @@ async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db, assert artist_count == 1 # no duplicate created +# --- Discord: channels are added to a CHOSEN artist (milestone 429) --- + + +_GUILD = "111111111111111111" +_CHAN = "222222222222222222" +_OTHER_CHAN = "333333333333333333" + + +async def _discord_source(db, name, url): + artist = Artist(name=name, slug=name.lower(), is_subscription=True) + db.add(artist) + await db.flush() + src = Source(artist_id=artist.id, platform="discord", url=url, + enabled=True, config_overrides={}) + db.add(src) + await db.commit() + return artist, src + + +@pytest.mark.asyncio +async def test_a_discord_channel_is_added_to_the_artist_the_operator_picked( + client, ext_key, db, db_sync, +): + artist = Artist(name="Tamada", slug="tamada", is_subscription=True) + db.add(artist) + await db.commit() + resp = await client.post( + "/api/extension/quick-add-source", + # A jump link on the ptb host still names the channel. + json={"url": f"https://ptb.discord.com/channels/{_GUILD}/{_CHAN}/999", + "artist_id": artist.id}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201, await resp.get_json() + body = await resp.get_json() + assert body["artist"]["id"] == artist.id + assert body["created_artist"] is False + # Stored canonical, so the manual form and the ingester read the same URL. + assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}" + assert body["source"]["platform"] == "discord" + + +@pytest.mark.asyncio +async def test_a_discord_add_can_name_a_new_artist(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": f"https://discord.com/channels/{_GUILD}", "artist_name": "Studio Q"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201 + body = await resp.get_json() + assert body["artist"]["name"] == "Studio Q" + assert body["created_artist"] is True + assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}" + + +@pytest.mark.asyncio +async def test_a_discord_add_with_no_artist_and_no_token_names_the_server(client, ext_key): + """No stored token means no server name to read; the fallback still names + a readable artist rather than slugifying the ids.""" + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 201 + assert (await resp.get_json())["artist"]["name"] == f"Discord {_GUILD}" + + +@pytest.mark.asyncio +async def test_re_adding_a_discord_channel_keeps_its_artist(client, ext_key, db): + """Identity by source (#130), compared by ids: a row stored with a + trailing slash is the same channel, and naming another artist does not + move it.""" + owner, src = await _discord_source( + db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}/", + ) + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", + "artist_name": "Someone Else"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["source"]["id"] == src.id + assert body["artist"]["id"] == owner.id + + +@pytest.mark.asyncio +async def test_quick_add_with_a_missing_artist_id_is_404(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": 987654}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_quick_add_rejects_a_non_integer_artist_id(client, ext_key): + resp = await client.post( + "/api/extension/quick-add-source", + json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": "7"}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 400 + + +async def _probe(client, ext_key, url): + resp = await client.get( + "/api/extension/probe", query_string={"url": url}, + headers={"X-Extension-Key": ext_key}, + ) + assert resp.status_code == 200 + return await resp.get_json() + + +@pytest.mark.asyncio +async def test_probe_a_fresh_discord_channel_is_new_with_both_urls(client, ext_key): + body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") + assert body["state"] == "new" + assert body["platform"] == "discord" + d = body["discord"] + assert d["server_id"] == _GUILD and d["channel_id"] == _CHAN + assert d["server_url"] == f"https://discord.com/channels/{_GUILD}" + assert d["channel_url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}" + # No token stored → no names, and no error. + assert d["server_name"] is None and d["channel_name"] is None + + +@pytest.mark.asyncio +async def test_probe_suggests_the_artist_who_owns_another_channel_on_the_server( + client, ext_key, db, +): + owner, _ = await _discord_source( + db, "Owner", f"https://discord.com/channels/{_GUILD}/{_OTHER_CHAN}", + ) + body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") + assert body["state"] == "artist_match" + assert body["artist"]["id"] == owner.id + assert "source" not in body + + +@pytest.mark.asyncio +async def test_probe_finds_the_channel_under_any_artist(client, ext_key, db): + owner, src = await _discord_source( + db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}", + ) + body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}/555") + assert body["state"] == "source_match" + assert body["source"]["id"] == src.id + assert body["artist"]["id"] == owner.id + assert body["covered_by_server"] is False + + +@pytest.mark.asyncio +async def test_probe_a_channel_is_covered_by_a_whole_server_source(client, ext_key, db): + _, src = await _discord_source(db, "Owner", f"https://discord.com/channels/{_GUILD}") + body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}") + assert body["state"] == "source_match" + assert body["source"]["id"] == src.id + assert body["covered_by_server"] is True + + +@pytest.mark.asyncio +async def test_probe_a_discord_dm_is_not_a_source(client, ext_key): + body = await _probe(client, ext_key, f"https://discord.com/channels/@me/{_CHAN}") + assert body["state"] == "unknown_platform" + + # --- /api/extension/manifest --------------------------------------- diff --git a/tests/test_extension_artist_patterns.py b/tests/test_extension_artist_patterns.py index 68a2892..2b819bc 100644 --- a/tests/test_extension_artist_patterns.py +++ b/tests/test_extension_artist_patterns.py @@ -104,9 +104,8 @@ def test_the_sample_table_covers_every_platform_that_has_a_pattern(): samples would make this file pass by testing less — the failure mode that makes absence-based tests untrustworthy (snippet #3352). - Discord is deliberately absent from both: it has no artist pattern on - either side, because it is channel-based and has no creator page to put a - button on. + Discord is in the table since milestone 429: its "slug" is the + server/channel pair, and the Add panel picks the artist. """ from backend.app.services.extension_service import _PLATFORM_PATTERNS