Compare commits

..
13 Commits
Author SHA1 Message Date
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen 94e7d20792 feat(downloads): Logs button on failing-sources rollup rows
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 16s
CI / frontend-build (push) Successful in 18s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m43s
CI / intcore (push) Successful in 8m10s
Operator-asked 2026-06-01: each row in the "X sources are failing"
rollup needs a button to surface the last run's stdout/stderr/error
without leaving the Downloads tab to find the matching event row.

Wired:
- `downloadsStore.loadLastForSource(sourceId)` two-steps via
  `GET /api/downloads?source_id=N&limit=1` (most recent event) then
  the existing `loadOne(id)` for the full detail payload. Returns
  null if no events exist (toast warns).
- `FailingSourcesCard` row: new text button `Logs` with
  `mdi-text-box-search-outline`, per-row spinner via `logLoadingIds`,
  emits `view-logs` with the source record.
- `DownloadsTab.onViewFailingLogs` is the handler — same
  `DownloadDetailModal` instance the event-row clicks use.
2026-06-01 10:04:22 -04:00
bvandeusen fb605af959 fix(posts): anchored view scroll inherits artist_id/platform filters
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
Operator-flagged 2026-06-01: clicking a Provenance post title from the
view modal opens the post in the posts feed scoped to that artist
(`/posts?post_id=N&artist_id=A`), but the older/newer infinite-scroll
loaded UNFILTERED global posts instead of staying in the artist's
stream. Root cause: the posts store's loadAround/loadOlder/loadNewer
sent only `{around, cursor, direction}` — never the `artist_id` /
`platform` filters. Backend `/api/posts` accepts them on every path
(post_feed_service.scroll filters via `Source.artist_id`); the
frontend just wasn't passing them.

Fix:
- `posts.js` `loadAround(postId, filters)` now takes the filter
  snapshot and stores it. `_aroundParams(extra)` mixes the active
  filters into around/cursor calls.
- `PostsView.vue` `loadAroundAndAnchor` passes `artist_id` +
  `platform` from the route query.
2026-06-01 08:24:43 -04:00
bvandeusen 4c56cf121f fix(modal): Esc closes from tag input; Provenance cards scroll at ~2.5
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / intimp (push) Successful in 3m54s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m16s
Two operator-flagged UX gaps from 2026-06-01:

1. **Esc trapped inside the tag-entry field**
   The autofocused TagAutocomplete input made Esc-to-close unreachable
   because ImageViewer's keydown handler bailed early on
   `isTextEntry(ev.target)` for every key including Escape. Now Escape
   closes the modal from text inputs too — except when a Vuetify
   overlay is open (FandomPicker, autocomplete dropdown, suggestion
   3-dot menu), in which case that overlay's own Esc-handling fires
   instead of closing the whole modal mid-interaction. Detected via
   `.v-overlay--active`. Arrow keys still gate on isTextEntry so the
   tag input handles typing without navigating images.

2. **Provenance cards expanded the side panel unbounded**
   When an image had many ImageProvenance entries the cards stack
   pushed the Tags section below the fold. Wrapped the cards in a
   `.fc-prov__cards` container with max-height 270px (≈2.5 cards) and
   thin overflow scrollbar. Title stays anchored at top; Tags panel
   sits at a consistent position below regardless of provenance count.
2026-06-01 08:17:54 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 9564d073b9 fix(ci): POSIX-safe SHORT_SHA in build.yml (runner uses dash, not bash)
CI / backend-lint-and-test (push) Successful in 24s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m26s
CI / intcore (push) Successful in 8m3s
`${GITHUB_SHA:0:7}` substring expansion is bash-only; the runner
executes the step via dash/BusyBox sh and errored with
`Bad substitution` (act_runner workflow shell, observed on the
65386f0 main-push run). Switched to
`SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)` which works in
both shells. Both build-web and build-ml tag-determination steps
updated.
2026-06-01 07:53:33 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen f87a06a6bd feat(modal): post title is the primary click (artist-scoped) + suggestion rows look like buttons
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 16s
CI / intimp (push) Successful in 4m16s
CI / intapi (push) Successful in 8m16s
CI / intcore (push) Successful in 8m41s
Two operator-asked modal UX changes (Scribe plan #514):

1. **Post title click → artist-scoped posts feed**
   - The bold post title in ProvenancePanel is now the primary click
     target. Click navigates to /posts?post_id=N&artist_id=A; the
     PostsView already filters on `artist_id`, so the user lands in
     that creator's stream, not the global one.
   - The redundant "View post" link is removed. "Show description"
     stays as the only action link below the meta line.
   - Title is styled as a button-link: accent color, hover underline,
     focus ring for keyboard nav (family rule 24 — UI quality bar).

2. **Suggestion rows look like buttons**
   - SuggestionItem becomes a chip-card: visible border, hover/focus
     background, unified container for name + score + actions
     (operator's "nothing visual to unify the buttons to their object"
     complaint).
   - Accept is an explicit tonal pill button labeled "Accept"
     (operator's pick over whole-row-clickable). 3-dot menu retains
     alias/dismiss, now `variant="outlined"` so it reads as a button.
   - Score is a fixed-width monospace pill on the right.
   - "+ new" badge upgraded to a pill chip with accent border so
     it's visibly an annotation, not part of the tag name.
2026-06-01 02:21:59 -04:00
bvandeusen 5d284aae9f fix(test): unpin general-threshold test from old 0.95 default (alembic 0029)
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m55s
CI / intcore (push) Successful in 8m46s
2026-06-01 02:18:54 -04:00
bvandeusen af7b5c95e9 feat(modal): autofocus tag input, expand general suggestions, retire copyright/artist categories
CI / backend-lint-and-test (push) Successful in 17s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m46s
CI / intcore (push) Failing after 8m18s
Four coupled operator-asked changes to the view modal (Scribe plan #509):

1. **Autofocus tag entry on modal open** — TagAutocomplete grabs focus
   in onMounted/nextTick so the caret is in the input the moment the
   modal renders. No click needed to start typing.

2. **General suggestions expanded by default** — SuggestionsPanel's
   general-category group now mounts with `:default-open="true"`.
   Operator can collapse if too noisy, but the v1 frame shows them.

3. **Lower general threshold default 0.95 → 0.50** — MLSettings.
   suggestion_threshold_general default matches character. Alembic
   0029 also bumps the existing singleton row's value if it's still
   at the old 0.95. Operator can re-tune from Settings → ML.

4. **Retire `copyright` + `artist` as ML suggestion categories** —
   neither feeds a Tag.kind (`artist` retired in FC-2d-vii-c, never
   really existed as a copyright tag-kind). They were surfaced in the
   suggestions pipeline + threshold settings UI but had no follow-
   through. Drop from SURFACED_CATEGORIES, suggestions._threshold_for,
   ml_admin GET/PATCH allowlist, MLSettings columns (alembic 0029
   drops the two columns), frontend CATEGORY_ORDER + CATEGORY_LABELS,
   SuggestionsPanel.peopleCats, AliasPickerDialog kind-check, and
   MLThresholdSliders rows.

Out of scope (intentional): `tag_kind` Postgres enum still includes
`artist` for historic Tag row queryability (per the model comment);
no operator pain reported, no enum-shrink needed.

Tests:
- test_surfaced_categories asserts {character, general}, excludes
  artist + copyright.
- test_threshold_for_artist_is_unsurfaced extended to cover copyright.
- test_get_and_patch_settings asserts new 0.50 default and the absent
  artist + copyright keys in the GET payload.
2026-06-01 02:08:10 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 8de7ccd07d build(ci): per-commit :c-<short_sha> tag on main-push per family rule #46
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m40s
CI / intcore (push) Successful in 8m15s
extension / lint (pull_request) Successful in 16s
CI / lint (push) Successful in 2s
Adds an immutable per-commit docker tag to every main-push build:
`git.fabledsword.com/bvandeusen/fabledcurator{,-ml}:c-<short_sha>`,
alongside the existing floating `:main` + `:latest`. Implements the
new family release-posture rule "Tags are milestones, not gates —
commit-SHA images are the rollback unit" so rollback to any commit
on main is `docker pull …:c-<sha>` with no release ceremony required.

Behavior change summary:
- main-push: was {:main, :latest} → now {:main, :latest, :c-<short_sha>}
- tag-push (opt-in vYY.MM.DD only, no .N): unchanged
- safety-net dev: unchanged

No code changes; the rule is about how the tag list is constructed.
Tag-push workflows stay as-is — vYY.MM.DD milestone cuts can still
fire them when the operator wants a labeled checkpoint.
2026-06-01 01:28:55 -04:00
bvandeusen d65f0b2091 feat(extension): probe shows current state before click; v1.0.6
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
Operator-asked 2026-05-31 (during sidecar synthetic anchor cleanup):
"the add source/subscription button idea to the firefox extension so
it can tell me if a source/artist is added or not and offer an option
to add it if it isn't." Plan tracked in Scribe task #507.

## Backend

- `ExtensionService.probe(url)` — read-only resolution. Reuses
  `_derive` for platform+slug, then 2 SELECTs. Returns one of:
  - `source_match` (exact (artist, platform, url) Source exists)
  - `artist_match` (artist exists, this URL isn't a Source yet;
     collapses the sidecar-synthetic-only case from v26.06.01.0)
  - `new` (neither exists)
  - `unknown_platform` (URL didn't match any artist-page regex)
- `GET /api/extension/probe?url=...` route with `X-Extension-Key`
  auth posture matching `/quick-add-source`. Read-only, side-effect
  free.
- 6 backend tests in tests/test_api_extension.py covering each state
  + auth + invalid URL.

## Extension

- `api.js`: `probeSource(url)` mirroring `quickAddSource` shape.
- `background.js`: `PROBE_SOURCE` + `OPEN_ARTIST_PAGE` handlers. The
  latter strips the `/api` suffix from configured `apiUrl` (placeholder
  format per options.html) and opens `${base}/artist/{slug}` in a new
  tab via `browser.tabs.create`.
- `content-script.js`: probe-first render — on page-load and SPA
  navigation, asks the backend for the URL's state and renders the
  chip in the matching color/copy on FIRST paint instead of flashing
  generic "Add" and updating after. Click handler branches:
  `source_match` → OPEN_ARTIST_PAGE; `artist_match`/`new` → existing
  ADD_AS_SOURCE flow (then re-probes so the chip flips green
  immediately, no wait for next nav).
- `content-script.css`: three state-color modifiers
  (--new, --artist-match, --source-match) on the FC parchment-on-slate
  palette. Sage for already-added, amber for artist-exists, accent
  orange for new.

## Versioning

- `extension/manifest.json` + `extension/package.json` → 1.0.6.
  build.yml's sign-extension job will fire on push to main since no
  `ext-1.0.6` Forgejo/Gitea release exists yet — exercises the
  regenerated AMO keys end-to-end.

## Behavior on the sidecar-synthetic case

Filesystem-imported "Dymkens"-style artist with only a sidecar
synthetic Source: probe returns `artist_match` (not `new`), so the
chip reads "+ Add Patreon source to Dymkens" rather than offering to
recreate the artist. Clicking adds the real Source; existing
`_source_for_sidecar` preference logic (v26.06.01.0) routes future
gallery-dl Posts to the real one.
2026-06-01 00:41:17 -04:00
32 changed files with 697 additions and 98 deletions
+30 -12
View File
@@ -242,20 +242,32 @@ jobs:
id: tag id: tag
run: | run: |
# Three trigger shapes: # Three trigger shapes:
# refs/tags/v… → tag-push: publish ONLY the immutable version # refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
# tag (e.g. :v26.05.26.5). Don't touch :latest; # no `.N` per family release-posture rule).
# that already got published by the main-push # Publish ONLY the immutable version tag;
# build for the merge commit. # don't touch :latest (the main-push build
# refs/heads/main → push to main (incl. PR merge commits): # for the merge commit already did that).
# publish :main + :latest (floating). # refs/heads/main → push to main: publish :main + :latest
# (floating) AND :c-<short_sha> (immutable
# per-commit rollback substrate, per family
# release-posture rule "Tags are milestones,
# not gates — commit-SHA images are the
# rollback unit"). Rollback to any commit
# becomes `docker pull …:c-<sha>` without a
# release ceremony.
# anything else → safety net; shouldn't fire given the `on:` # anything else → safety net; shouldn't fire given the `on:`
# config above (dev was dropped). Tag :dev to # config above. Tag :dev to surface the
# surface the unexpected run in the registry. # unexpected run in the registry.
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
fi fi
@@ -286,13 +298,19 @@ jobs:
id: tag id: tag
run: | run: |
# Mirrors build-web's three-shape logic (tag-push / main-push / # Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev). The -ml image follows the same release cadence # safety-net dev) including the per-commit :c-<short_sha> tag
# as the web image. # on main-push per the family release-posture rule. The -ml
# image follows the same release cadence as the web image.
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
fi fi
@@ -0,0 +1,71 @@
"""drop artist + copyright ml thresholds; lower general default to 0.50
Revision ID: 0029
Revises: 0028
Create Date: 2026-06-01
Operator-flagged 2026-06-01: the view modal's Suggestions panel hides
most general-category predictions because the default threshold is
0.95. Lowering the default to 0.50 (matches character) so general
suggestions surface more aggressively; the value remains tunable in
Settings → ML.
Same change retires two ML suggestion categories whose Tag.kind
surfaces are unused:
- `artist`: retired in FC-2d-vii-c — artist identity is acquisition-
derived (image_record.artist_id), never ML-inferred. The threshold
column was a leftover from before that retirement.
- `copyright`: retired 2026-06-01 — the app uses `fandom` for the
franchise/copyright concept (per TagsView.vue's doc comment); no
Tag rows of kind=copyright exist, and the threshold column never
fed anything user-visible.
Both columns are dropped from ml_settings; the existing row's
suggestion_threshold_general value is bumped from 0.95 to 0.50 iff
it's still at the old default, so deployed installs pick up the new
UX without overriding any operator tuning.
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
revision: str = "0029"
down_revision: Union[str, None] = "0028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Bump the general threshold for installs still at the old default.
op.execute(text(
"UPDATE ml_settings "
"SET suggestion_threshold_general = 0.50 "
"WHERE id = 1 AND suggestion_threshold_general = 0.95"
))
op.drop_column("ml_settings", "suggestion_threshold_artist")
op.drop_column("ml_settings", "suggestion_threshold_copyright")
def downgrade() -> None:
# Restore the columns with their prior defaults. The bump from
# 0.95 → 0.50 isn't reversible without remembering whether the
# operator had explicitly set 0.95 (unlikely — that was just the
# default) so we leave the current general value as-is.
from sqlalchemy import Column, Float
op.add_column(
"ml_settings",
Column(
"suggestion_threshold_artist",
Float, nullable=False, server_default="0.30",
),
)
op.add_column(
"ml_settings",
Column(
"suggestion_threshold_copyright",
Float, nullable=False, server_default="0.50",
),
)
+18
View File
@@ -57,6 +57,24 @@ def _sha256(path: Path) -> str:
return h.hexdigest() return h.hexdigest()
@extension_bp.route("/probe", methods=["GET"])
async def probe_source():
"""Read-only resolution of a creator-page URL: tells the extension
whether this URL is already a Source, is for an Artist that exists
but with a different URL, is brand new, or doesn't match any known
platform pattern. Drives the content-script chip's color/copy
BEFORE the operator clicks, so the button can show 'already added'
without requiring an add-attempt."""
url = (request.args.get("url") or "").strip()
if not url:
return _bad("invalid_body", detail="url query parameter is required")
async with get_session() as session:
if not await _ext_key_required(session):
return _bad("unauthorized", status=401)
result = await ExtensionService(session).probe(url)
return jsonify(result)
@extension_bp.route("/quick-add-source", methods=["POST"]) @extension_bp.route("/quick-add-source", methods=["POST"])
async def quick_add_source(): async def quick_add_source():
body = await request.get_json(silent=True) body = await request.get_json(silent=True)
-4
View File
@@ -9,9 +9,7 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
_EDITABLE = ( _EDITABLE = (
"suggestion_threshold_artist",
"suggestion_threshold_character", "suggestion_threshold_character",
"suggestion_threshold_copyright",
"suggestion_threshold_general", "suggestion_threshold_general",
"centroid_similarity_threshold", "centroid_similarity_threshold",
"min_reference_images", "min_reference_images",
@@ -28,9 +26,7 @@ async def get_settings():
).scalar_one() ).scalar_one()
return jsonify( return jsonify(
{ {
"suggestion_threshold_artist": s.suggestion_threshold_artist,
"suggestion_threshold_character": s.suggestion_threshold_character, "suggestion_threshold_character": s.suggestion_threshold_character,
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
"suggestion_threshold_general": s.suggestion_threshold_general, "suggestion_threshold_general": s.suggestion_threshold_general,
"centroid_similarity_threshold": s.centroid_similarity_threshold, "centroid_similarity_threshold": s.centroid_similarity_threshold,
"min_reference_images": s.min_reference_images, "min_reference_images": s.min_reference_images,
+4 -7
View File
@@ -15,17 +15,14 @@ class MLSettings(Base):
__table_args__ = (CheckConstraint("id = 1", name="singleton"),) __table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
suggestion_threshold_artist: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
)
suggestion_threshold_character: Mapped[float] = mapped_column( suggestion_threshold_character: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50 Float, nullable=False, default=0.50
) )
suggestion_threshold_copyright: Mapped[float] = mapped_column( # Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
Float, nullable=False, default=0.50 # 0.95 hid most general suggestions. Operator-tunable via Settings →
) # ML if too noisy.
suggestion_threshold_general: Mapped[float] = mapped_column( suggestion_threshold_general: Mapped[float] = mapped_column(
Float, nullable=False, default=0.95 Float, nullable=False, default=0.50
) )
centroid_similarity_threshold: Mapped[float] = mapped_column( centroid_similarity_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.55 Float, nullable=False, default=0.55
+61
View File
@@ -86,6 +86,67 @@ class ExtensionService:
"created_artist": created_artist, "created_artist": created_artist,
} }
async def probe(self, url: str) -> dict:
"""Read-only resolution of a creator-page URL against the FC DB.
Returns one of:
- {state: 'unknown_platform'} — URL didn't match any
platform's strict artist-page pattern
- {state: 'new', platform, slug} — would create both
artist and source on quick-add
- {state: 'artist_match', platform, slug, artist}
— artist exists, this
exact URL isn't a Source yet (collapses the sidecar-synthetic
case too — the synthetic anchor counts as an existing artist
row but not as a pollable Source for this URL)
- {state: 'source_match', platform, slug, artist, source}
— exact (artist, platform,
url) Source already exists
Side-effect-free: two SELECTs at most.
"""
try:
platform, raw_slug = self._derive(url)
except (UnknownPlatformError, InvalidUrlError):
return {"state": "unknown_platform"}
slug = slugify(raw_slug)
artist = (await self.session.execute(
select(Artist).where(Artist.slug == slug)
)).scalar_one_or_none()
if artist is None:
return {"state": "new", "platform": platform, "slug": slug}
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
source = (await self.session.execute(
select(Source).where(
Source.artist_id == artist.id,
Source.platform == platform,
Source.url == url,
)
)).scalar_one_or_none()
if source is None:
return {
"state": "artist_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
}
return {
"state": "source_match",
"platform": platform,
"slug": slug,
"artist": artist_payload,
"source": {
"id": source.id,
"artist_id": source.artist_id,
"platform": source.platform,
"url": source.url,
"enabled": source.enabled,
},
}
def _derive(self, url: str) -> tuple[str, str]: 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
View File
@@ -48,11 +48,11 @@ class SuggestionService:
).scalar_one() ).scalar_one()
def _threshold_for(self, s: MLSettings, category: str) -> float: def _threshold_for(self, s: MLSettings, category: str) -> float:
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to # 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
# the 1.01 "never surfaces" default like any unsurfaced category. # both fall through to the 1.01 "never surfaces" default like any
# unsurfaced category.
return { return {
"character": s.suggestion_threshold_character, "character": s.suggestion_threshold_character,
"copyright": s.suggestion_threshold_copyright,
"general": s.suggestion_threshold_general, "general": s.suggestion_threshold_general,
}.get(category, 1.01) }.get(category, 1.01)
+7 -4
View File
@@ -38,10 +38,13 @@ STORE_FLOOR = float(os.environ.get("TAGGER_STORE_FLOOR", "0.05"))
# The categories FC-2b surfaces in the UI. Others (meta/rating/year) are # The categories FC-2b surfaces in the UI. Others (meta/rating/year) are
# still stored but the suggestion service filters them out. # still stored but the suggestion service filters them out.
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived # 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
# (image_record.artist_id), never ML-inferred. Raw predictions are still # (image_record.artist_id), never ML-inferred. 'copyright' retired
# stored at STORE_FLOOR but artist never surfaces. # 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
SURFACED_CATEGORIES = {"character", "copyright", "general"} # this app's franchise/series concept (per TagsView.vue's doc comment).
# Raw predictions for both categories still get stored at STORE_FLOOR but
# don't surface in suggestions.
SURFACED_CATEGORIES = {"character", "general"}
# ImageNet preprocessing constants (per Camie v2 onnx_inference.py). # ImageNet preprocessing constants (per Camie v2 onnx_inference.py).
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) _IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
+23
View File
@@ -259,6 +259,29 @@ browser.runtime.onMessage.addListener(async (msg) => {
return { error: e.message }; return { error: e.message };
} }
case 'PROBE_SOURCE':
try {
return await api.probeSource(msg.url);
} catch (e) {
return { error: e.message };
}
case 'OPEN_ARTIST_PAGE': {
// apiUrl is configured with the /api suffix (see
// options/options.html placeholder); the SPA artist route is
// /artist/:slug, served from the same origin. Strip /api so the
// browser-level URL hits the Vue router, not the JSON API.
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
const slug = encodeURIComponent(msg.slug || '');
if (!base || !slug) return { error: 'apiUrl or slug missing' };
try {
await browser.tabs.create({ url: `${base}/artist/${slug}` });
return { success: true };
} catch (e) {
return { error: e.message };
}
}
default: default:
return { error: `Unknown message type: ${msg.type}` }; return { error: `Unknown message type: ${msg.type}` };
} }
+16 -1
View File
@@ -5,11 +5,26 @@
background: rgb(20, 23, 26); color: rgb(244, 186, 122); background: rgb(20, 23, 26); color: rgb(244, 186, 122);
font: 500 14px/1.2 system-ui, sans-serif; font: 500 14px/1.2 system-ui, sans-serif;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
transition: transform 100ms ease; transition: transform 100ms ease, background 150ms ease, color 150ms ease;
} }
.fc-add-source-btn:hover { transform: translateY(-1px); } .fc-add-source-btn:hover { transform: translateY(-1px); }
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; } .fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
/* state colors map to the FC palette: parchment-on-slate base,
accent-orange for new, sage for already-subscribed, amber-warning for
artist-exists-but-source-missing. All readable on the dark base. */
.fc-add-source-btn--new {
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
}
.fc-add-source-btn--artist-match {
background: rgb(28, 23, 16); color: rgb(255, 200, 120);
border: 1px solid rgb(180, 130, 60);
}
.fc-add-source-btn--source-match {
background: rgb(18, 28, 20); color: rgb(140, 220, 160);
border: 1px solid rgb(80, 160, 100);
}
.fc-toast { .fc-toast {
all: revert; all: revert;
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647; position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
+93 -11
View File
@@ -2,6 +2,10 @@
if (window.__fc_addsource_injected) return; if (window.__fc_addsource_injected) return;
window.__fc_addsource_injected = true; window.__fc_addsource_injected = true;
// Cached probe result for the current URL so click-handlers know which
// action to dispatch without round-tripping again.
let currentProbe = null;
evaluate(); evaluate();
const reEval = () => evaluate(); const reEval = () => evaluate();
@@ -9,38 +13,116 @@
const origPush = history.pushState; const origPush = history.pushState;
history.pushState = function () { origPush.apply(this, arguments); reEval(); }; history.pushState = function () { origPush.apply(this, arguments); reEval(); };
function evaluate() { async function evaluate() {
const platform = getPlatformFromUrl(window.location.href); const url = window.location.href;
const onArtist = platform && isArtistPage(window.location.href, platform); const platform = getPlatformFromUrl(url);
let btn = document.getElementById('fc-add-source-btn'); const onArtist = platform && isArtistPage(url, platform);
if (onArtist && !btn) injectButton(); const btn = document.getElementById('fc-add-source-btn');
else if (!onArtist && btn) btn.remove(); if (!onArtist) {
if (btn) btn.remove();
currentProbe = null;
return;
}
// On artist pages, ask the backend what state the URL is in BEFORE
// injecting the button — so the chip can render the right state on
// first paint instead of flashing the generic "Add" copy and
// updating afterwards.
let probe;
try {
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
} catch (e) {
probe = { error: e?.message || 'probe failed' };
}
currentProbe = probe;
if (probe?.state === 'unknown_platform') {
if (btn) btn.remove();
return;
}
renderButton(probe);
} }
function injectButton() { function renderButton(probe) {
const btn = document.createElement('button'); let btn = document.getElementById('fc-add-source-btn');
if (!btn) {
btn = document.createElement('button');
btn.id = 'fc-add-source-btn'; btn.id = 'fc-add-source-btn';
btn.className = 'fc-add-source-btn';
btn.textContent = '+ Add to FabledCurator';
btn.addEventListener('click', onClick); btn.addEventListener('click', onClick);
document.body.appendChild(btn); document.body.appendChild(btn);
} }
// Reset state classes so re-renders (SPA navigation) don't stack.
btn.className = 'fc-add-source-btn';
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
btn.textContent = labelFor(probe);
btn.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;
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
const probe = currentProbe;
if (probe?.state === 'source_match') {
btn.textContent = 'Opening…';
try {
const r = await browser.runtime.sendMessage({
type: 'OPEN_ARTIST_PAGE',
slug: probe.artist?.slug,
});
if (r?.error) showToast(`Error: ${r.error}`, 'error');
} catch (e) {
showToast(`Error: ${e.message}`, 'error');
} finally {
btn.disabled = false;
btn.textContent = original;
}
return;
}
btn.textContent = 'Adding…'; btn.textContent = 'Adding…';
try { try {
const r = await browser.runtime.sendMessage({ const r = await browser.runtime.sendMessage({
type: 'ADD_AS_SOURCE', type: 'ADD_AS_SOURCE',
url: window.location.href, url: window.location.href,
}); });
if (r.error) { if (r?.error) {
showToast(`Error: ${r.error}`, 'error'); showToast(`Error: ${r.error}`, 'error');
} else { } else {
const verb = r.created_source ? 'Added' : 'Already a source for'; const verb = r.created_source ? 'Added' : 'Already a source for';
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); 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');
+6
View File
@@ -83,6 +83,12 @@ class FabledCuratorAPI {
quickAddSource(url) { quickAddSource(url) {
return this.request('POST', '/extension/quick-add-source', { url }); return this.request('POST', '/extension/quick-add-source', { url });
} }
probeSource(url) {
// Read-only existence check. Drives the content-script chip's
// color/copy BEFORE the operator clicks Add.
const qs = new URLSearchParams({ url }).toString();
return this.request('GET', `/extension/probe?${qs}`);
}
// Connection test = the cheapest read with auth. // Connection test = the cheapest read with auth.
testConnection() { testConnection() {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "FabledCurator", "name": "FabledCurator",
"version": "1.0.5", "version": "1.0.6",
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.", "description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
"browser_specific_settings": { "browser_specific_settings": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "fabledcurator-extension", "name": "fabledcurator-extension",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"description": "Firefox extension for FabledCurator", "description": "Firefox extension for FabledCurator",
"scripts": { "scripts": {
@@ -48,10 +48,11 @@ function onSearch(q) {
if (!query) { results.value = []; return } if (!query) { results.value = []; return }
loading.value = true loading.value = true
try { try {
// Scope the autocomplete to the prediction's category where it maps // Scope the autocomplete to the prediction's category where it
// to a tag kind. 'copyright' has no tag kind; search unscoped there. // maps to a tag kind. Only 'character' surfaces as both a
const kind = ['artist', 'character'].includes(props.category) // suggestion category and a tag kind now ('artist' + 'copyright'
? props.category : null // retired); other categories search unscoped.
const kind = props.category === 'character' ? 'character' : null
const params = { q: query, limit: 20 } const params = { q: query, limit: 20 }
if (kind) params.kind = kind if (kind) params.kind = kind
results.value = await api.get('/api/tags/autocomplete', { params }) results.value = await api.get('/api/tags/autocomplete', { params })
+10 -1
View File
@@ -92,7 +92,16 @@ let prevBodyOverflow = null
// own keystrokes. // own keystrokes.
function onKeyDown(ev) { function onKeyDown(ev) {
if (ev.key === 'Escape') { if (ev.key === 'Escape') {
if (isTextEntry(ev.target)) return // Escape closes the modal even from inside a text input — that's
// the universal "get me out of here" expectation, and the
// autofocused tag-entry field would otherwise trap focus with no
// visible escape (operator-flagged 2026-06-01). EXCEPTION: when a
// nested Vuetify overlay is open (v-menu autocomplete dropdown,
// FandomPicker v-dialog, per-suggestion 3-dot menu), let that
// overlay's own Esc handling fire instead of closing the whole
// modal mid-interaction. Vuetify marks open overlays with
// `.v-overlay--active`.
if (document.querySelector('.v-overlay--active')) return
ev.preventDefault() ev.preventDefault()
emit('close') emit('close')
} else if (ev.key === 'ArrowLeft') { } else if (ev.key === 'ArrowLeft') {
@@ -10,7 +10,11 @@
density="compact" density="compact"
>{{ state.error }}</v-alert> >{{ state.error }}</v-alert>
<template v-else> <!-- Cards scroll independently of the section title + attachments
below them. Cap at ~2.5 cards visible (operator-asked 2026-06-01:
keeps the Tags section anchored below at a consistent position;
the half-visible third card hints there's more). -->
<div v-else class="fc-prov__cards">
<article <article
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card" v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
> >
@@ -18,9 +22,13 @@
<span class="fc-prov__platform">{{ e.source.platform }}</span> <span class="fc-prov__platform">{{ e.source.platform }}</span>
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span> <span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
</div> </div>
<div class="fc-prov__post"> <button
type="button" class="fc-prov__post"
:title="`Open ${postTitle(e)} in the posts feed for ${e.artist.name}`"
@click="openPost(e.post.id, e.artist.id)"
>
{{ postTitle(e) }} {{ postTitle(e) }}
</div> </button>
<div class="fc-prov__meta"> <div class="fc-prov__meta">
<RouterLink :to="`/artist/${e.artist.slug}`"> <RouterLink :to="`/artist/${e.artist.slug}`">
by {{ e.artist.name }} by {{ e.artist.name }}
@@ -29,12 +37,8 @@
· {{ e.post.attachment_count }} files · {{ e.post.attachment_count }} files
</span> </span>
</div> </div>
<div class="fc-prov__actions"> <div v-if="e.post.description_html" class="fc-prov__actions">
<a href="#" @click.prevent="openPost(e.post.id)">
View post
</a>
<a <a
v-if="e.post.description_html"
href="#" @click.prevent="toggleDesc(e.provenance_id)" href="#" @click.prevent="toggleDesc(e.provenance_id)"
>{{ expanded[e.provenance_id] ? 'Hide description ' : 'Show description ' }}</a> >{{ expanded[e.provenance_id] ? 'Hide description ' : 'Show description ' }}</a>
</div> </div>
@@ -58,7 +62,7 @@
</RouterLink> </RouterLink>
</div> </div>
</article> </article>
</template> </div>
<div v-if="attachments.length" class="fc-prov__attach"> <div v-if="attachments.length" class="fc-prov__attach">
<h4 class="fc-prov__attach-title">Attachments</h4> <h4 class="fc-prov__attach-title">Attachments</h4>
@@ -137,10 +141,16 @@ function postTitle(e) {
return toPlainText(e.post.title) || `Post ${e.post.external_post_id}` return toPlainText(e.post.title) || `Post ${e.post.external_post_id}`
} }
function openPost(postId) { function openPost(postId, artistId) {
// Land on the post in the posts feed (in context), not the gallery // Land on the post in the posts feed (in context), not the gallery
// image grid. Operator-flagged 2026-05-28. // image grid. Scope the feed to this artist so the user lands in
router.push({ path: '/posts', query: { post_id: postId } }) // that creator's stream, not the global one — operator-flagged
// 2026-06-01. PostsView reads `artist_id` from the query string
// (PostsView.vue line ~92) and filters via post_feed_service.
router.push({
path: '/posts',
query: { post_id: postId, artist_id: artistId },
})
modal.close() modal.close()
} }
</script> </script>
@@ -153,6 +163,18 @@ function openPost(postId) {
color: rgb(var(--v-theme-on-surface)); color: rgb(var(--v-theme-on-surface));
margin-bottom: 12px; margin-bottom: 12px;
} }
.fc-prov__cards {
/* 2.5 cards-worth at the typical collapsed card height (~108px each
incl. 10px gap). Slightly under to ensure the third card's bottom
edge is clipped — the visual cue that there's more below. */
max-height: 270px;
overflow-y: auto;
/* Hairline scrollbar that doesn't compete with content. */
scrollbar-width: thin;
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
/* Pad-right so the scrollbar gutter doesn't squeeze card borders. */
padding-right: 4px;
}
.fc-prov__card { .fc-prov__card {
border: 1px solid rgb(var(--v-theme-surface-light)); border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px; border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
@@ -163,8 +185,21 @@ function openPost(postId) {
text-transform: lowercase; text-transform: lowercase;
} }
.fc-prov__post { .fc-prov__post {
font-weight: 700; margin: 4px 0; /* Clickable title — opens the post in the artist-scoped feed
color: rgb(var(--v-theme-on-surface)); (operator-flagged 2026-06-01: title IS the primary action, the
prior "View post" link was redundant). Styled as a button-link:
accent color, underline on hover, focus ring for keyboard nav. */
display: block; width: 100%; text-align: left;
background: none; border: none; padding: 0;
font: inherit; font-weight: 700;
margin: 4px 0;
color: rgb(var(--v-theme-accent));
cursor: pointer;
}
.fc-prov__post:hover { text-decoration: underline; }
.fc-prov__post:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px; border-radius: 3px;
} }
.fc-prov__meta { .fc-prov__meta {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant)); font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
@@ -1,19 +1,33 @@
<template> <template>
<!-- Chip-card row: visible border + hover/focus state unifies the
name, score, and action buttons as one "object" (operator-asked
2026-06-01). The row itself is informational; the explicit
Accept button + 3-dot menu are the action affordances. -->
<div class="fc-suggestion"> <div class="fc-suggestion">
<span class="fc-suggestion__name"> <span class="fc-suggestion__name">
{{ suggestion.display_name }} {{ suggestion.display_name }}
<span v-if="suggestion.creates_new_tag" class="fc-suggestion__new" <span v-if="suggestion.creates_new_tag" class="fc-suggestion__new"
title="No matching tag yet — accepting creates it">new</span> title="No matching tag yet — accepting creates it">+ new</span>
</span> </span>
<span class="fc-suggestion__score">{{ scorePct }}</span> <span class="fc-suggestion__score">{{ scorePct }}</span>
<v-btn <v-btn
icon="mdi-plus" size="x-small" variant="text" color="accent" class="fc-suggestion__accept"
size="small" variant="tonal" color="accent"
density="compact" rounded="pill"
:aria-label="`Accept ${suggestion.display_name}`" :aria-label="`Accept ${suggestion.display_name}`"
@click="$emit('accept', suggestion)" @click="$emit('accept', suggestion)"
/> >
Accept
</v-btn>
<v-menu> <v-menu>
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn icon="mdi-dots-vertical" size="x-small" variant="text" v-bind="props" /> <v-btn
class="fc-suggestion__menu"
icon="mdi-dots-vertical" size="small"
variant="outlined" density="compact"
:aria-label="`More actions for ${suggestion.display_name}`"
v-bind="props"
/>
</template> </template>
<v-list density="compact"> <v-list density="compact">
<v-list-item @click="$emit('alias', suggestion)"> <v-list-item @click="$emit('alias', suggestion)">
@@ -38,17 +52,45 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
<style scoped> <style scoped>
.fc-suggestion { .fc-suggestion {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 8px;
padding: 2px 0; padding: 6px 10px; margin-bottom: 4px;
background: rgb(var(--v-theme-surface));
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
transition: background 120ms ease, border-color 120ms ease;
}
.fc-suggestion:hover {
background: rgb(var(--v-theme-surface-light));
border-color: rgb(var(--v-theme-accent), 0.4);
}
.fc-suggestion__name {
flex: 1; min-width: 0;
font-size: 14px;
color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.fc-suggestion__name { flex: 1; min-width: 0; }
.fc-suggestion__new { .fc-suggestion__new {
font-size: 10px; color: rgb(var(--v-theme-accent)); display: inline-block;
margin-left: 4px; font-size: 10px; font-weight: 600;
color: rgb(var(--v-theme-accent));
background: rgba(var(--v-theme-accent), 0.12);
border: 1px solid rgb(var(--v-theme-accent), 0.4);
padding: 1px 6px; border-radius: 999px;
margin-left: 6px;
text-transform: uppercase; letter-spacing: 0.04em;
} }
.fc-suggestion__score { .fc-suggestion__score {
flex: 0 0 auto; min-width: 38px; text-align: right;
font-size: 11px; font-size: 11px;
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface))); color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
} }
/* Vuetify's compact density doesn't shrink the tonal button enough
for a tight row; clamp the min-width so Accept stays compact. */
.fc-suggestion__accept :deep(.v-btn__content) {
font-size: 12px; letter-spacing: 0.02em;
}
.fc-suggestion__menu {
flex: 0 0 auto;
}
</style> </style>
@@ -22,7 +22,7 @@
<SuggestionsCategoryGroup <SuggestionsCategoryGroup
v-if="store.byCategory.general && store.byCategory.general.length" v-if="store.byCategory.general && store.byCategory.general.length"
label="General" :items="store.byCategory.general" label="General" :items="store.byCategory.general"
collapsible :default-open="false" collapsible :default-open="true"
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss" @accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
/> />
</template> </template>
@@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue'
const props = defineProps({ imageId: { type: Number, required: true } }) const props = defineProps({ imageId: { type: Number, required: true } })
const store = useSuggestionsStore() const store = useSuggestionsStore()
const peopleCats = ['artist', 'character', 'copyright'] // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
// suggestion categories. Only 'character' remains as a people-style
// category alongside the general bucket.
const peopleCats = ['character']
function labelFor(c) { return CATEGORY_LABELS[c] || c } function labelFor(c) { return CATEGORY_LABELS[c] || c }
const isEmpty = computed(() => const isEmpty = computed(() =>
@@ -1,6 +1,7 @@
<template> <template>
<div class="fc-tag-autocomplete"> <div class="fc-tag-autocomplete">
<v-text-field <v-text-field
ref="inputRef"
v-model="query" v-model="query"
placeholder="Add tag (or kind:name — character/fandom/series)" placeholder="Add tag (or kind:name — character/fandom/series)"
density="compact" hide-details density="compact" hide-details
@@ -52,13 +53,21 @@
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useTagStore } from '../../stores/tags.js' import { useTagStore } from '../../stores/tags.js'
import FandomPicker from './FandomPicker.vue' import FandomPicker from './FandomPicker.vue'
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel']) const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
const store = useTagStore() const store = useTagStore()
// Autofocus on modal open so the operator can type the moment the view
// modal renders, no extra click required (operator-asked 2026-06-01).
// Vuetify's v-text-field exposes .focus() on the component instance;
// nextTick waits for the modal's mount to finish so the inner <input>
// element exists.
const inputRef = ref(null)
onMounted(() => { nextTick(() => inputRef.value?.focus?.()) })
// Single text input; no kind dropdown. Client-side mirror of the // Single text input; no kind dropdown. Client-side mirror of the
// backend's parse_kind_prefix lives below — kept in sync with // backend's parse_kind_prefix lives below — kept in sync with
// KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the // KNOWN_KINDS in backend/app/utils/tag_prefix.py. The backend is the
@@ -22,10 +22,10 @@ import { reactive, watch } from 'vue'
import { useMLStore } from '../../stores/ml.js' import { useMLStore } from '../../stores/ml.js'
const store = useMLStore() const store = useMLStore()
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
// suggestion categories; their threshold rows are gone.
const fields = [ const fields = [
{ key: 'suggestion_threshold_artist', label: 'Artist' },
{ key: 'suggestion_threshold_character', label: 'Character' }, { key: 'suggestion_threshold_character', label: 'Character' },
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
{ key: 'suggestion_threshold_general', label: 'General' }, { key: 'suggestion_threshold_general', label: 'General' },
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' } { key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
] ]
@@ -58,6 +58,7 @@
:retrying-all="retryingAll" :retrying-all="retryingAll"
@retry="onRetrySource" @retry="onRetrySource"
@retry-all="onRetryAll" @retry-all="onRetryAll"
@view-logs="onViewFailingLogs"
/> />
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading"> <div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
@@ -364,6 +365,23 @@ watch(filterModel, async (m) => {
async function openDetail(id) { async function openDetail(id) {
await store.loadOne(id) await store.loadOne(id)
} }
async function onViewFailingLogs(source) {
// Find and open the most recent DownloadEvent for this source.
// Reuses the existing DownloadDetailModal — same stdout/stderr/error
// surface the row-click in the events feed shows.
try {
const ev = await store.loadLastForSource(source.id)
if (!ev) {
toast({
text: `No download events recorded for ${source.artist_name || source.platform} yet.`,
type: 'warning',
})
}
} catch (e) {
toast({ text: `Failed to load logs: ${e.message}`, type: 'error' })
}
}
</script> </script>
<style scoped> <style scoped>
@@ -29,6 +29,14 @@
{{ s.last_error || 'no error message recorded' }} {{ s.last_error || 'no error message recorded' }}
</span> </span>
<v-spacer /> <v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-text-box-search-outline"
:loading="logLoadingIds.has(s.id)"
@click="onViewLogs(s)"
title="Show the most recent download event's stdout/stderr/error"
>
Logs
</v-btn>
<v-btn <v-btn
size="x-small" variant="text" prepend-icon="mdi-refresh" size="x-small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingIds.has(s.id)" :loading="retryingIds.has(s.id)"
@@ -52,9 +60,23 @@ defineProps({
retryingIds: { type: Set, default: () => new Set() }, retryingIds: { type: Set, default: () => new Set() },
retryingAll: { type: Boolean, default: false }, retryingAll: { type: Boolean, default: false },
}) })
defineEmits(['retry', 'retry-all']) const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
const open = ref(true) const open = ref(true)
// Per-row loading flag so the spinner lives on the row whose Logs
// button was clicked, not on every row.
const logLoadingIds = ref(new Set())
async function onViewLogs(s) {
if (logLoadingIds.value.has(s.id)) return
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
try {
await emit('view-logs', s)
} finally {
const next = new Set(logLoadingIds.value)
next.delete(s.id)
logLoadingIds.value = next
}
}
</script> </script>
<style scoped> <style scoped>
+17 -1
View File
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
return selected.value return selected.value
} }
// Open the detail modal for the most recent DownloadEvent of a given
// source. Used by the failing-sources rollup's "Logs" button so the
// operator can troubleshoot without leaving the Downloads tab to find
// the row (operator-flagged 2026-06-01).
async function loadLastForSource(sourceId) {
const events = await api.get('/api/downloads', {
params: { source_id: sourceId, limit: 1 },
})
if (!events.length) {
selected.value = null
return null
}
return await loadOne(events[0].id)
}
async function applyFilter(patch) { async function applyFilter(patch) {
filter.value = { ...filter.value, ...patch } filter.value = { ...filter.value, ...patch }
await loadFirst() await loadFirst()
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
return { return {
events, cursor, hasMore, filter, selected, loading, error, stats, events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing, activeEvents, activity, failing, activeEvents,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
closeDetail, loadStats,
loadActivity, loadFailing, loadActive, recoverStalled, loadActivity, loadFailing, loadActive, recoverStalled,
} }
}) })
+31 -4
View File
@@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => {
return await api.get(`/api/posts/${id}`) return await api.get(`/api/posts/${id}`)
} }
// Filter overlay for the around/older/newer (in-context anchored)
// path. Keep this distinct from `filters.value` (the down-only feed)
// so a normal-feed filter change doesn't leak into an active anchored
// view (or vice versa). Caller of loadAround passes the snapshot; the
// subsequent loadOlder/loadNewer use it verbatim.
function _aroundParams(extra) {
const p = { ...extra }
if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id
if (filters.value.platform) p.platform = filters.value.platform
return p
}
// Load a window centered on `postId`: newer posts above, the post, older // Load a window centered on `postId`: newer posts above, the post, older
// posts below. Sets both directional cursors for subsequent scrolling. // posts below. Sets both directional cursors for subsequent scrolling.
async function loadAround(postId) { // Accepts the same filter shape as loadInitial so the anchored view
// stays artist/platform-scoped (operator-flagged 2026-06-01: clicking a
// post title from the modal's Provenance card opens the post in the
// posts feed; without this the older/newer scroll loaded unfiltered
// global posts instead of staying in the artist's stream).
async function loadAround(postId, newFilters) {
filters.value = {
artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null,
}
loading.value = true loading.value = true
error.value = null error.value = null
anchorId.value = null anchorId.value = null
try { try {
const body = await api.get('/api/posts', { params: { around: postId } }) const body = await api.get('/api/posts', {
params: _aroundParams({ around: postId }),
})
items.value = body.items items.value = body.items
cursorOlder.value = body.cursor_older cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer cursorNewer.value = body.cursor_newer
@@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => {
loading.value = true loading.value = true
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: { cursor: cursorOlder.value, direction: 'older' }, params: _aroundParams({
cursor: cursorOlder.value, direction: 'older',
}),
}) })
items.value.push(...body.items) items.value.push(...body.items)
cursorOlder.value = body.next_cursor cursorOlder.value = body.next_cursor
@@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => {
loading.value = true loading.value = true
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: { cursor: cursorNewer.value, direction: 'newer' }, params: _aroundParams({
cursor: cursorNewer.value, direction: 'newer',
}),
}) })
items.value.unshift(...body.items) items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor cursorNewer.value = body.next_cursor
+4 -4
View File
@@ -4,12 +4,12 @@ import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
// Category display order: people/sources first, general last. // Category display order: people first, general last.
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general'] // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
// character and general surface as suggestion categories now.
export const CATEGORY_ORDER = ['character', 'general']
export const CATEGORY_LABELS = { export const CATEGORY_LABELS = {
artist: 'Artist',
character: 'Character', character: 'Character',
copyright: 'Copyright',
general: 'General' general: 'General'
} }
+9 -1
View File
@@ -161,7 +161,15 @@ function setupAroundObservers() {
} }
async function loadAroundAndAnchor() { async function loadAroundAndAnchor() {
teardownFeed() teardownFeed()
await store.loadAround(postIdFilter.value) // Pass artist_id + platform through so the anchored view stays
// scoped — the older/newer infinite scrolls then read these filters
// back via the store's _aroundParams (operator-flagged 2026-06-01:
// post-title click from the modal landed scoped but the scroll then
// pulled unfiltered global posts).
await store.loadAround(postIdFilter.value, {
artist_id: artistFilter.value,
platform: platformFilter.value,
})
await nextTick() await nextTick()
const el = document.getElementById(`fc-post-${store.anchorId}`) const el = document.getElementById(`fc-post-${store.anchorId}`)
if (el) el.scrollIntoView({ block: 'center' }) if (el) el.scrollIntoView({ block: 'center' })
+108
View File
@@ -135,6 +135,114 @@ async def test_quick_add_source_wrong_key_401(client, ext_key):
assert resp.status_code == 401 assert resp.status_code == 401
# --- /api/extension/probe ---------------------------------------------
@pytest.mark.asyncio
async def test_probe_returns_new_when_nothing_exists(client, ext_key):
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/freshcreator"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "new"
assert body["platform"] == "patreon"
assert body["slug"] == "freshcreator"
@pytest.mark.asyncio
async def test_probe_returns_source_match_for_already_added(client, ext_key, db):
artist = Artist(name="Alice", slug="alice", is_subscription=True)
db.add(artist)
await db.flush()
src = Source(
artist_id=artist.id, platform="patreon",
url="https://www.patreon.com/alice", enabled=True, config_overrides={},
)
db.add(src)
await db.commit()
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/alice"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "source_match"
assert body["artist"]["slug"] == "alice"
assert body["source"]["url"] == "https://www.patreon.com/alice"
assert body["source"]["platform"] == "patreon"
@pytest.mark.asyncio
async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists(
client, ext_key, db,
):
"""Filesystem-imported artist with only a sidecar synthetic Source
for the (artist, platform) — the URL the operator's browsing isn't
yet a real Source. The probe should collapse this into artist_match
so the chip says '+ Add Patreon source to Dymkens' rather than
'+ Add to FabledCurator' (which would re-create the artist)."""
artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False)
db.add(artist)
await db.flush()
synthetic = Source(
artist_id=artist.id, platform="patreon",
url="sidecar:patreon:dymkens", enabled=False, config_overrides={},
)
db.add(synthetic)
await db.commit()
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/dymkens"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "artist_match"
assert body["artist"]["slug"] == "dymkens"
assert "source" not in body
@pytest.mark.asyncio
async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key):
"""A patreon URL that isn't an artist page (e.g. /home, /posts/N)
shouldn't trigger the button. Sentinel 'unknown_platform' state
tells the content script to skip injection."""
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/posts/12345"},
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["state"] == "unknown_platform"
@pytest.mark.asyncio
async def test_probe_missing_key_401(client):
resp = await client.get(
"/api/extension/probe",
query_string={"url": "https://www.patreon.com/maewix"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_probe_missing_url_400(client, ext_key):
resp = await client.get(
"/api/extension/probe",
headers={"X-Extension-Key": ext_key},
)
assert resp.status_code == 400
body = await resp.get_json()
assert body["error"] == "invalid_body"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_quick_add_source_missing_body_400(client, ext_key): async def test_quick_add_source_missing_body_400(client, ext_key):
resp = await client.post( resp = await client.post(
+6 -1
View File
@@ -19,7 +19,12 @@ async def test_get_and_patch_settings(client):
resp = await client.get("/api/ml/settings") resp = await client.get("/api/ml/settings")
assert resp.status_code == 200 assert resp.status_code == 200
body = await resp.get_json() body = await resp.get_json()
assert body["suggestion_threshold_general"] == pytest.approx(0.95) # Default lowered 0.95 → 0.50 on 2026-06-01 (alembic 0029) — 0.95
# hid most general suggestions in the view modal.
assert body["suggestion_threshold_general"] == pytest.approx(0.50)
# Retired threshold columns must not appear in the payload.
assert "suggestion_threshold_artist" not in body
assert "suggestion_threshold_copyright" not in body
resp = await client.patch( resp = await client.patch(
"/api/ml/settings", json={"suggestion_threshold_general": 0.90} "/api/ml/settings", json={"suggestion_threshold_general": 0.90}
+2 -2
View File
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
class _S: class _S:
suggestion_threshold_character = 0.5 suggestion_threshold_character = 0.5
suggestion_threshold_copyright = 0.5
suggestion_threshold_general = 0.5 suggestion_threshold_general = 0.5
svc = SuggestionService.__new__(SuggestionService) svc = SuggestionService.__new__(SuggestionService)
# 'artist' must fall through to the 1.01 "never surfaces" default # 'artist' and 'copyright' both retired — fall through to 1.01
assert svc._threshold_for(_S(), "artist") == 1.01 assert svc._threshold_for(_S(), "artist") == 1.01
assert svc._threshold_for(_S(), "copyright") == 1.01
+5 -2
View File
@@ -25,10 +25,13 @@ def _img(sha: str, predictions: dict) -> ImageRecord:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_threshold_filters_low_confidence_general(db): async def test_threshold_filters_low_confidence_general(db):
# Default general threshold is 0.50 (alembic 0029 lowered it from
# 0.95). Use 0.30/0.60 to keep the test asserting threshold behavior
# rather than the exact cutoff number.
img = _img( img = _img(
"a" * 64, "a" * 64,
{ {
"smile": {"category": "general", "confidence": 0.80}, "lowconf": {"category": "general", "confidence": 0.30},
"sword": {"category": "general", "confidence": 0.97}, "sword": {"category": "general", "confidence": 0.97},
}, },
) )
@@ -37,7 +40,7 @@ async def test_threshold_filters_low_confidence_general(db):
sl = await SuggestionService(db).for_image(img.id) sl = await SuggestionService(db).for_image(img.id)
names = [s.display_name for s in sl.by_category.get("general", [])] names = [s.display_name for s in sl.by_category.get("general", [])]
assert "sword" in names assert "sword" in names
assert "smile" not in names assert "lowconf" not in names
@pytest.mark.asyncio @pytest.mark.asyncio
+6 -3
View File
@@ -17,10 +17,13 @@ from backend.app.services.ml.tagger import (
def test_surfaced_categories(): def test_surfaced_categories():
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived # FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
# (image_record.artist_id), never ML-inferred. # derived (image_record.artist_id), never ML-inferred.
assert SURFACED_CATEGORIES == {"character", "copyright", "general"} # 2026-06-01: 'copyright' retired — fandom serves as the franchise/
# copyright concept; operator doesn't use a separate copyright kind.
assert SURFACED_CATEGORIES == {"character", "general"}
assert "artist" not in SURFACED_CATEGORIES assert "artist" not in SURFACED_CATEGORIES
assert "copyright" not in SURFACED_CATEGORIES
def test_store_floor_is_low(): def test_store_floor_is_low():