feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
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
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
This commit is contained in:
@@ -242,7 +242,7 @@ jobs:
|
|||||||
# rather than left running beside the new mechanism (rule 22).
|
# rather than left running beside the new mechanism (rule 22).
|
||||||
#
|
#
|
||||||
# Two things are still worth asserting, and this is the only lane that can:
|
# 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.
|
# version.spec.js sticks to packaging.sh's git-free subcommands.
|
||||||
# 1. the derivation actually resolves on this commit
|
# 1. the derivation actually resolves on this commit
|
||||||
# 2. the derived string is one AMO will accept, checked against Mozilla's
|
# 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 test:unit
|
||||||
- run: npm run build
|
- 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.
|
# 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
|
# The shards existed to parallelize ~8.5min of integration tests; once the
|
||||||
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
# 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
|
# 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
|
# where only a FAILED gate blocks would have published unverified images
|
||||||
# while reporting success.
|
# 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
|
# 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
|
# separating "validate a Renovate bump" from "publish a Renovate bump", so
|
||||||
# it is stated on each publishing job rather than inferred from a `needs`
|
# 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
|
# default behaviour is exactly what we want: a failed sign skips build-web
|
||||||
# rather than shipping an image without its XPI.
|
# rather than shipping an image without its XPI.
|
||||||
needs: [sign-extension, lint, extension-version, backend-lint-and-test,
|
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
|
# 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
|
# 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
|
# 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
|
# 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
|
# where only a FAILED gate blocks would have published unverified images
|
||||||
# while reporting success.
|
# 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:
|
# 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
|
# 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.
|
# belongs under. Same meaning as build-web's outputs of the same names.
|
||||||
|
|||||||
@@ -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-<version>`.
|
|
||||||
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."
|
|
||||||
@@ -271,11 +271,12 @@ Four deployable pieces, built by `.forgejo/workflows/build.yml`:
|
|||||||
|
|
||||||
## CI / Forgejo setup
|
## CI / Forgejo setup
|
||||||
|
|
||||||
Three workflows: `build.yml` (the five verification lanes — lint,
|
Two workflows that matter here: `build.yml` (the six verification lanes — lint,
|
||||||
extension-version check, backend unit tests, frontend build, integration — and
|
extension-version check, backend unit tests, frontend build, extension lint +
|
||||||
then sign + publish), `extension.yml` (extension lint, vitest, XPI content
|
vitest + XPI content check, integration — and then sign + publish), and
|
||||||
verification), and `release.yml`, which runs only on a `v*` tag and publishes a
|
`release.yml`, which runs only on a `v*` tag and publishes a changelog without
|
||||||
changelog without building anything.
|
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
|
**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
|
(`ci.yml` and `build.yml`) until 2026-09-23, on the same push trigger, which
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ..models import AppSetting
|
|||||||
from ..services.extension_service import (
|
from ..services.extension_service import (
|
||||||
ExtensionService,
|
ExtensionService,
|
||||||
InvalidUrlError,
|
InvalidUrlError,
|
||||||
|
UnknownArtistError,
|
||||||
UnknownPlatformError,
|
UnknownPlatformError,
|
||||||
)
|
)
|
||||||
from ..services.source_service import KNOWN_PLATFORMS
|
from ..services.source_service import KNOWN_PLATFORMS
|
||||||
@@ -87,10 +88,14 @@ async def probe_source():
|
|||||||
url = (request.args.get("url") or "").strip()
|
url = (request.args.get("url") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
return _bad("invalid_body", detail="url query parameter is required")
|
return _bad("invalid_body", detail="url query parameter is required")
|
||||||
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
if not await _ext_key_required(session):
|
if not await _ext_key_required(session):
|
||||||
return _bad("unauthorized", status=401)
|
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)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,6 +107,15 @@ async def quick_add_source():
|
|||||||
url = body.get("url")
|
url = body.get("url")
|
||||||
if not isinstance(url, str) or not url.strip():
|
if not isinstance(url, str) or not url.strip():
|
||||||
return _bad("invalid_body", detail="url is required")
|
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
|
from .credentials import _get_crypto
|
||||||
|
|
||||||
@@ -111,7 +125,11 @@ async def quick_add_source():
|
|||||||
try:
|
try:
|
||||||
# crypto lets an add resolve the artist's display name via the
|
# crypto lets an add resolve the artist's display name via the
|
||||||
# stored credential (else it falls back to the URL handle). #130.
|
# 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:
|
except UnknownPlatformError as exc:
|
||||||
return _bad(
|
return _bad(
|
||||||
"unknown_platform",
|
"unknown_platform",
|
||||||
|
|||||||
@@ -510,6 +510,24 @@ class DiscordClient:
|
|||||||
|
|
||||||
# -- verify ------------------------------------------------------------
|
# -- 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]:
|
def verify_auth(self, url: str) -> tuple[bool | None, str]:
|
||||||
"""Is the token valid, and can it see what the source names?"""
|
"""Is the token valid, and can it see what the source names?"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from .source_service import BACKFILL_MAX_CHUNKS
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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):
|
class UnknownPlatformError(Exception):
|
||||||
"""URL didn't match any platform pattern."""
|
"""URL didn't match any platform pattern."""
|
||||||
@@ -30,6 +33,10 @@ class InvalidUrlError(Exception):
|
|||||||
"""URL was empty or missing a scheme."""
|
"""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
|
# Mirrored byte-for-byte from extension/lib/platforms.js
|
||||||
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
# PLATFORM_ARTIST_PATTERNS. Keep these two copies in sync by hand —
|
||||||
# reviewers catch drift.
|
# reviewers catch drift.
|
||||||
@@ -55,8 +62,39 @@ _PLATFORM_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|||||||
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
r"^https?://(?:www\.)?hentai-foundry\.com/user/(?P<slug>[^/?#]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)),
|
)),
|
||||||
|
# A Discord URL names a server or a channel, never a creator, so the slug is
|
||||||
|
# `<server>` or `<server>/<channel>` 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<slug>\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:
|
class ExtensionService:
|
||||||
def __init__(self, session: AsyncSession, crypto=None) -> None:
|
def __init__(self, session: AsyncSession, crypto=None) -> None:
|
||||||
@@ -65,30 +103,63 @@ class ExtensionService:
|
|||||||
# add-time. None → skip resolution, fall back to the handle.
|
# add-time. None → skip resolution, fall back to the handle.
|
||||||
self._crypto = crypto
|
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)
|
platform, raw_slug = self._derive(url)
|
||||||
|
url = canonical_source_url(platform, url, raw_slug)
|
||||||
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
# Identity by SOURCE handle (#130): an existing (platform, url) source
|
||||||
# keeps its artist on re-add — even if that artist was since renamed (its
|
# 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
|
# frozen slug no longer matches the current name), and even when the
|
||||||
# source resolves/creates an artist.
|
# add named a different artist. Only a genuinely new source
|
||||||
existing = (await self.session.execute(
|
# resolves/creates an artist.
|
||||||
select(Source).where(Source.platform == platform, Source.url == url)
|
existing = await self._existing_source(platform, url)
|
||||||
)).scalar_one_or_none()
|
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
artist = (await self.session.execute(
|
artist = (await self.session.execute(
|
||||||
select(Artist).where(Artist.id == existing.artist_id)
|
select(Artist).where(Artist.id == existing.artist_id)
|
||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
return self._shape(existing, artist, created_source=False, created_artist=False)
|
return self._shape(existing, artist, created_source=False, created_artist=False)
|
||||||
|
|
||||||
# New source → name the artist properly by resolving the real display
|
if artist_id is not None:
|
||||||
# name from the platform (falls back to the URL handle).
|
artist = (await self.session.execute(
|
||||||
name = await self._resolve_artist_name(platform, raw_slug, url)
|
select(Artist).where(Artist.id == artist_id)
|
||||||
artist, created_artist = await self._find_or_create_artist(name)
|
)).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(
|
source, created_source = await self._find_or_create_source(
|
||||||
artist_id=artist.id, platform=platform, url=url,
|
artist_id=artist.id, platform=platform, url=url,
|
||||||
)
|
)
|
||||||
return self._shape(source, artist, created_source, created_artist)
|
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
|
@staticmethod
|
||||||
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
|
def _shape(source, artist, created_source: bool, created_artist: bool) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -117,6 +188,11 @@ class ExtensionService:
|
|||||||
platforms (and any failure — no credential, network error) fall back to
|
platforms (and any failure — no credential, network error) fall back to
|
||||||
the URL handle, which is already readable.
|
the URL handle, which is already readable.
|
||||||
The resolvers are sync, so they run in an executor."""
|
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"):
|
if self._crypto is None or platform not in ("patreon", "subscribestar"):
|
||||||
return raw_slug
|
return raw_slug
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -166,6 +242,8 @@ class ExtensionService:
|
|||||||
platform, raw_slug = self._derive(url)
|
platform, raw_slug = self._derive(url)
|
||||||
except (UnknownPlatformError, InvalidUrlError):
|
except (UnknownPlatformError, InvalidUrlError):
|
||||||
return {"state": "unknown_platform"}
|
return {"state": "unknown_platform"}
|
||||||
|
if platform == DISCORD:
|
||||||
|
return await self._probe_discord(raw_slug)
|
||||||
|
|
||||||
slug = slugify(raw_slug)
|
slug = slugify(raw_slug)
|
||||||
artist = (await self.session.execute(
|
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]:
|
def _derive(self, url: str) -> tuple[str, str]:
|
||||||
if not isinstance(url, str) or not url.strip():
|
if not isinstance(url, str) or not url.strip():
|
||||||
raise InvalidUrlError("url is empty")
|
raise InvalidUrlError("url is empty")
|
||||||
|
|||||||
+3
-3
@@ -15,7 +15,7 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
|
|||||||
|
|
||||||
## Secondary runtime image
|
## 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
|
`.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.
|
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
|
- `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 `frontend-build` job
|
||||||
- `npm install --no-audit --no-fund` — in `extension.yml`'s `lint` job (web-ext + vitest)
|
- `npm install --no-audit --no-fund` — in `build.yml`'s `extension-test` job (web-ext + vitest)
|
||||||
- `unzip` — in `extension.yml`'s "Verify XPI contents" step, installed via apt
|
- `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
|
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
|
package, ~2s. Not worth baking into a shared image for a single consumer, per
|
||||||
`docs/process.md`'s ">1 project" rule.
|
`docs/process.md`'s ">1 project" rule.
|
||||||
|
|||||||
@@ -128,7 +128,8 @@ browser.webRequest.onBeforeSendHeaders.addListener(
|
|||||||
saveDiscordToken(auth.value);
|
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'],
|
['requestHeaders'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,7 +214,17 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
if (key === 'discord') {
|
if (key === 'discord') {
|
||||||
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
if (!discordToken) return { error: 'Open discord.com to capture a token first.' };
|
||||||
await api.uploadCredentials('discord', 'token', discordToken);
|
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.' };
|
return { error: 'Unsupported platform.' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -256,7 +267,17 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
|
|
||||||
case 'ADD_AS_SOURCE':
|
case 'ADD_AS_SOURCE':
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,3 +40,45 @@
|
|||||||
from { transform: translateY(20px); opacity: 0; }
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
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; }
|
||||||
|
|||||||
@@ -5,42 +5,61 @@
|
|||||||
// Cached probe result for the current URL so click-handlers know which
|
// Cached probe result for the current URL so click-handlers know which
|
||||||
// action to dispatch without round-tripping again.
|
// action to dispatch without round-tripping again.
|
||||||
let currentProbe = null;
|
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();
|
evaluate();
|
||||||
|
|
||||||
const reEval = () => evaluate();
|
// SPA navigation. Patreon, SubscribeStar and above all Discord change
|
||||||
window.addEventListener('popstate', reEval);
|
// channel/page without a reload. Patching history.pushState from here never
|
||||||
const origPush = history.pushState;
|
// worked: a content script runs in an isolated world, so the page's own
|
||||||
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
// 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() {
|
async function evaluate() {
|
||||||
|
const mine = ++generation;
|
||||||
const url = window.location.href;
|
const url = window.location.href;
|
||||||
const platform = getPlatformFromUrl(url);
|
const platform = getPlatformFromUrl(url);
|
||||||
const onArtist = platform && isArtistPage(url, platform);
|
const onArtist = platform && isArtistPage(url, platform);
|
||||||
const btn = document.getElementById('fc-add-source-btn');
|
|
||||||
if (!onArtist) {
|
if (!onArtist) {
|
||||||
if (btn) btn.remove();
|
removeButton();
|
||||||
currentProbe = null;
|
currentProbe = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// On artist pages, ask the backend what state the URL is in BEFORE
|
// Ask the backend what state the URL is in BEFORE drawing the button, so
|
||||||
// injecting the button — so the chip can render the right state on
|
// the chip renders the right state on first paint instead of flashing the
|
||||||
// first paint instead of flashing the generic "Add" copy and
|
// generic "Add" copy and updating afterwards.
|
||||||
// updating afterwards.
|
|
||||||
let probe;
|
let probe;
|
||||||
try {
|
try {
|
||||||
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
|
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
probe = { error: e?.message || 'probe failed' };
|
probe = { error: e?.message || 'probe failed' };
|
||||||
}
|
}
|
||||||
|
if (mine !== generation) return;
|
||||||
currentProbe = probe;
|
currentProbe = probe;
|
||||||
if (probe?.state === 'unknown_platform') {
|
if (probe?.state === 'unknown_platform') {
|
||||||
if (btn) btn.remove();
|
removeButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderButton(probe);
|
renderButton(probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeButton() {
|
||||||
|
document.getElementById('fc-add-source-btn')?.remove();
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
|
||||||
function renderButton(probe) {
|
function renderButton(probe) {
|
||||||
let btn = document.getElementById('fc-add-source-btn');
|
let btn = document.getElementById('fc-add-source-btn');
|
||||||
if (!btn) {
|
if (!btn) {
|
||||||
@@ -51,79 +70,36 @@
|
|||||||
}
|
}
|
||||||
// Reset state classes so re-renders (SPA navigation) don't stack.
|
// Reset state classes so re-renders (SPA navigation) don't stack.
|
||||||
btn.className = 'fc-add-source-btn';
|
btn.className = 'fc-add-source-btn';
|
||||||
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
|
btn.classList.add(`fc-add-source-btn--${chipState(probe)}`);
|
||||||
btn.textContent = labelFor(probe);
|
btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || '');
|
||||||
btn.disabled = false;
|
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() {
|
async function onClick() {
|
||||||
const btn = document.getElementById('fc-add-source-btn');
|
const btn = document.getElementById('fc-add-source-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.disabled = true;
|
|
||||||
const original = btn.textContent;
|
|
||||||
const probe = currentProbe;
|
const probe = currentProbe;
|
||||||
|
|
||||||
if (probe?.state === 'source_match') {
|
if (probe?.state === 'source_match') {
|
||||||
btn.textContent = 'Opening…';
|
await openArtist(btn, probe.artist?.slug);
|
||||||
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;
|
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 {
|
try {
|
||||||
const r = await browser.runtime.sendMessage({
|
const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug });
|
||||||
type: 'ADD_AS_SOURCE',
|
if (r?.error) showToast(`Error: ${r.error}`, 'error');
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Error: ${e.message}`, 'error');
|
showToast(`Error: ${e.message}`, 'error');
|
||||||
} finally {
|
} 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) {
|
function showToast(text, kind) {
|
||||||
const t = document.createElement('div');
|
const t = document.createElement('div');
|
||||||
t.className = `fc-toast fc-toast--${kind}`;
|
t.className = `fc-toast fc-toast--${kind}`;
|
||||||
|
|||||||
+19
-2
@@ -80,6 +80,17 @@ class FabledCuratorAPI {
|
|||||||
getCredentials() {
|
getCredentials() {
|
||||||
return this.request('GET', '/credentials');
|
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.
|
// FC-3a — sources.
|
||||||
listSources() {
|
listSources() {
|
||||||
@@ -90,8 +101,14 @@ class FabledCuratorAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FC-3g — extension-specific.
|
// FC-3g — extension-specific.
|
||||||
quickAddSource(url) {
|
// artistId connects the source to an existing artist, artistName to the
|
||||||
return this.request('POST', '/extension/quick-add-source', { url });
|
// 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) {
|
probeSource(url) {
|
||||||
// Read-only existence check. Drives the content-script chip's
|
// Read-only existence check. Drives the content-script chip's
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -57,7 +57,8 @@ const PLATFORMS = {
|
|||||||
domains: ['.discord.com', 'discord.com'],
|
domains: ['.discord.com', 'discord.com'],
|
||||||
authType: 'token',
|
authType: 'token',
|
||||||
color: '#5865F2',
|
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',
|
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,
|
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,
|
subscribestar: /^https?:\/\/(www\.)?subscribestar\.(com|adult)\/(?!feed$|messages$|library$)[^/?#]+\/?$/i,
|
||||||
hentaifoundry: /^https?:\/\/(www\.)?hentai-foundry\.com\/user\/[^/?#]+/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 `<server>[/<channel>]` 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) {
|
function getPlatformFromUrl(url) {
|
||||||
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
for (const [key, platform] of Object.entries(PLATFORMS)) {
|
||||||
if (platform.urlPattern.test(url)) return key;
|
if (platform.urlPattern.test(url)) return key;
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -56,9 +56,10 @@
|
|||||||
"*://*.patreon.com/*",
|
"*://*.patreon.com/*",
|
||||||
"*://*.subscribestar.com/*",
|
"*://*.subscribestar.com/*",
|
||||||
"*://*.subscribestar.adult/*",
|
"*://*.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"],
|
"css": ["content/content-script.css"],
|
||||||
"run_at": "document_idle"
|
"run_at": "document_idle"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script src="../lib/platforms.js"></script>
|
<script src="../lib/platforms.js"></script>
|
||||||
|
<script src="../lib/popup-format.js"></script>
|
||||||
<script src="popup.js"></script>
|
<script src="popup.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ async function exportPlatformCookies(key, card) {
|
|||||||
try {
|
try {
|
||||||
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
|
const r = await browser.runtime.sendMessage({ type: 'EXPORT_COOKIES', platform: key });
|
||||||
if (r.error) showError(r.error);
|
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 n = r.cookieCount ?? null;
|
||||||
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
|
const verifiedSuffix = r.verified ? ' (verified ✓)' : '';
|
||||||
const msg = n !== null
|
const msg = n !== null
|
||||||
@@ -205,7 +209,10 @@ async function loadSources() {
|
|||||||
c.appendChild(mutedNote('No sources yet.'));
|
c.appendChild(mutedNote('No sources yet.'));
|
||||||
return;
|
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) {
|
function createSourceRow(src) {
|
||||||
@@ -215,11 +222,16 @@ function createSourceRow(src) {
|
|||||||
info.className = 'info';
|
info.className = 'info';
|
||||||
const name = document.createElement('div');
|
const name = document.createElement('div');
|
||||||
name.className = 'name';
|
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');
|
const url = document.createElement('div');
|
||||||
url.className = 'url';
|
url.className = 'url';
|
||||||
url.textContent = src.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');
|
const play = document.createElement('button');
|
||||||
play.className = 'play';
|
play.className = 'play';
|
||||||
play.textContent = '▶';
|
play.textContent = '▶';
|
||||||
@@ -229,7 +241,7 @@ function createSourceRow(src) {
|
|||||||
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
|
const r = await browser.runtime.sendMessage({ type: 'CHECK_SOURCE', sourceId: src.id });
|
||||||
play.disabled = false;
|
play.disabled = false;
|
||||||
if (r.error) showError(r.error);
|
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);
|
row.appendChild(info); row.appendChild(play);
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
@@ -134,5 +134,42 @@
|
|||||||
{ "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" },
|
{ "url": "https://www.hentai-foundry.com/pictures/popular", "why": "gallery listing, not a user" },
|
||||||
{ "url": "https://www.hentai-foundry.com/", "why": "site root" }
|
{ "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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,10 +7,14 @@ import { loadLib } from './helpers/loadLib.js'
|
|||||||
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
|
const manifest = JSON.parse(readFileSync(path.join(EXT_DIR, 'manifest.json'), 'utf8'))
|
||||||
|
|
||||||
const { getPlatformFromUrl, isArtistPage, PLATFORMS, PLATFORM_ARTIST_PATTERNS } = loadLib(
|
const { getPlatformFromUrl, isArtistPage, parseDiscordUrl, PLATFORMS, PLATFORM_ARTIST_PATTERNS } =
|
||||||
'platforms.js',
|
loadLib('platforms.js', [
|
||||||
['getPlatformFromUrl', 'isArtistPage', 'PLATFORMS', 'PLATFORM_ARTIST_PATTERNS']
|
'getPlatformFromUrl',
|
||||||
)
|
'isArtistPage',
|
||||||
|
'parseDiscordUrl',
|
||||||
|
'PLATFORMS',
|
||||||
|
'PLATFORM_ARTIST_PATTERNS'
|
||||||
|
])
|
||||||
|
|
||||||
describe('getPlatformFromUrl', () => {
|
describe('getPlatformFromUrl', () => {
|
||||||
it('identifies each platform from a domain URL', () => {
|
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', 'discord')).toBe(false)
|
||||||
|
expect(isArtistPage('https://discord.com/channels/@me/222', 'discord')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns false for an unknown platform key', () => {
|
it('returns false for an unknown platform key', () => {
|
||||||
@@ -100,8 +107,8 @@ describe('isArtistPage', () => {
|
|||||||
describe('platform table integrity', () => {
|
describe('platform table integrity', () => {
|
||||||
it('gives every artist pattern a corresponding platform entry', () => {
|
it('gives every artist pattern a corresponding platform entry', () => {
|
||||||
// A pattern keyed to a platform that no longer exists is dead code that
|
// 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
|
// silently never fires; the reverse (a platform with no pattern) would be
|
||||||
// legitimate discord case, so only this direction is an error.
|
// a platform the button never offers, a product choice rather than an error.
|
||||||
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
for (const key of Object.keys(PLATFORM_ARTIST_PATTERNS)) {
|
||||||
expect(Object.keys(PLATFORMS)).toContain(key)
|
expect(Object.keys(PLATFORMS)).toContain(key)
|
||||||
}
|
}
|
||||||
@@ -125,7 +132,8 @@ describe('platform table integrity', () => {
|
|||||||
const samples = {
|
const samples = {
|
||||||
patreon: 'https://www.patreon.com/cw/Atole',
|
patreon: 'https://www.patreon.com/cw/Atole',
|
||||||
subscribestar: 'https://subscribestar.adult/someone',
|
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)) {
|
for (const [key, url] of Object.entries(samples)) {
|
||||||
expect(isArtistPage(url, key), `${key} artist pattern`).toBe(true)
|
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()
|
expect(owner, `no platform claims content-script match "${m}"`).toBeTruthy()
|
||||||
// The content script exists to draw the Add-as-source button, so a
|
// 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(
|
expect(
|
||||||
PLATFORM_ARTIST_PATTERNS[owner[0]],
|
PLATFORM_ARTIST_PATTERNS[owner[0]],
|
||||||
`"${m}" injects for ${owner[0]}, which has no artist pattern`
|
`"${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', () => {
|
it('has samples for every platform that has an artist pattern', () => {
|
||||||
// The guard's own coverage check: without it, deleting a platform's
|
// The guard's own coverage check: without it, deleting a platform's
|
||||||
// samples would make this block pass by testing less. Discord is
|
// samples would make this block pass by testing less. Discord joined at
|
||||||
// deliberately in neither — it is channel-based, with no creator page to
|
// milestone 429 — its slug is server/channel and the artist is chosen.
|
||||||
// put a button on, so it has no artist pattern on either side.
|
|
||||||
expect(Object.keys(samples).sort()).toEqual(Object.keys(PLATFORM_ARTIST_PATTERNS).sort())
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
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 ---------------------------------------
|
# --- /api/extension/manifest ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
samples would make this file pass by testing less — the failure mode that
|
||||||
makes absence-based tests untrustworthy (snippet #3352).
|
makes absence-based tests untrustworthy (snippet #3352).
|
||||||
|
|
||||||
Discord is deliberately absent from both: it has no artist pattern on
|
Discord is in the table since milestone 429: its "slug" is the
|
||||||
either side, because it is channel-based and has no creator page to put a
|
server/channel pair, and the Add panel picks the artist.
|
||||||
button on.
|
|
||||||
"""
|
"""
|
||||||
from backend.app.services.extension_service import _PLATFORM_PATTERNS
|
from backend.app.services.extension_service import _PLATFORM_PATTERNS
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user