Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf8d2ca4d | ||
|
|
94e7d20792 | ||
|
|
fb605af959 | ||
|
|
4c56cf121f | ||
|
|
b1d58bc3b8 | ||
|
|
9564d073b9 | ||
|
|
65386f02a0 | ||
|
|
f87a06a6bd | ||
|
|
5d284aae9f | ||
|
|
af7b5c95e9 | ||
|
|
667b05f14e | ||
|
|
8de7ccd07d | ||
|
|
d65f0b2091 |
@@ -242,20 +242,32 @@ jobs:
|
||||
id: tag
|
||||
run: |
|
||||
# Three trigger shapes:
|
||||
# refs/tags/v… → tag-push: publish ONLY the immutable version
|
||||
# tag (e.g. :v26.05.26.5). Don't touch :latest;
|
||||
# that already got published by the main-push
|
||||
# build for the merge commit.
|
||||
# refs/heads/main → push to main (incl. PR merge commits):
|
||||
# publish :main + :latest (floating).
|
||||
# refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD,
|
||||
# no `.N` per family release-posture rule).
|
||||
# Publish ONLY the immutable version tag;
|
||||
# don't touch :latest (the main-push build
|
||||
# for the merge commit already did that).
|
||||
# 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:`
|
||||
# config above (dev was dropped). Tag :dev to
|
||||
# surface the unexpected run in the registry.
|
||||
# config above. Tag :dev to surface the
|
||||
# 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
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -286,13 +298,19 @@ jobs:
|
||||
id: tag
|
||||
run: |
|
||||
# Mirrors build-web's three-shape logic (tag-push / main-push /
|
||||
# safety-net dev). The -ml image follows the same release cadence
|
||||
# as the web image.
|
||||
# safety-net dev) including the per-commit :c-<short_sha> tag
|
||||
# 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
|
||||
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||
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",
|
||||
),
|
||||
)
|
||||
@@ -57,6 +57,24 @@ def _sha256(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
@extension_bp.route("/probe", methods=["GET"])
|
||||
async def probe_source():
|
||||
"""Read-only resolution of a creator-page URL: tells the extension
|
||||
whether this URL is already a Source, is for an Artist that exists
|
||||
but with a different URL, is brand new, or doesn't match any known
|
||||
platform pattern. Drives the content-script chip's color/copy
|
||||
BEFORE the operator clicks, so the button can show 'already added'
|
||||
without requiring an add-attempt."""
|
||||
url = (request.args.get("url") or "").strip()
|
||||
if not url:
|
||||
return _bad("invalid_body", detail="url query parameter is required")
|
||||
async with get_session() as session:
|
||||
if not await _ext_key_required(session):
|
||||
return _bad("unauthorized", status=401)
|
||||
result = await ExtensionService(session).probe(url)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@extension_bp.route("/quick-add-source", methods=["POST"])
|
||||
async def quick_add_source():
|
||||
body = await request.get_json(silent=True)
|
||||
|
||||
@@ -9,9 +9,7 @@ ml_admin_bp = Blueprint("ml_admin", __name__, url_prefix="/api/ml")
|
||||
|
||||
|
||||
_EDITABLE = (
|
||||
"suggestion_threshold_artist",
|
||||
"suggestion_threshold_character",
|
||||
"suggestion_threshold_copyright",
|
||||
"suggestion_threshold_general",
|
||||
"centroid_similarity_threshold",
|
||||
"min_reference_images",
|
||||
@@ -28,9 +26,7 @@ async def get_settings():
|
||||
).scalar_one()
|
||||
return jsonify(
|
||||
{
|
||||
"suggestion_threshold_artist": s.suggestion_threshold_artist,
|
||||
"suggestion_threshold_character": s.suggestion_threshold_character,
|
||||
"suggestion_threshold_copyright": s.suggestion_threshold_copyright,
|
||||
"suggestion_threshold_general": s.suggestion_threshold_general,
|
||||
"centroid_similarity_threshold": s.centroid_similarity_threshold,
|
||||
"min_reference_images": s.min_reference_images,
|
||||
|
||||
@@ -15,17 +15,14 @@ class MLSettings(Base):
|
||||
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
|
||||
|
||||
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(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
suggestion_threshold_copyright: Mapped[float] = mapped_column(
|
||||
Float, nullable=False, default=0.50
|
||||
)
|
||||
# Default lowered 0.95 → 0.50 on 2026-06-01 — operator-flagged that
|
||||
# 0.95 hid most general suggestions. Operator-tunable via Settings →
|
||||
# ML if too noisy.
|
||||
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(
|
||||
Float, nullable=False, default=0.55
|
||||
|
||||
@@ -86,6 +86,67 @@ class ExtensionService:
|
||||
"created_artist": created_artist,
|
||||
}
|
||||
|
||||
async def probe(self, url: str) -> dict:
|
||||
"""Read-only resolution of a creator-page URL against the FC DB.
|
||||
Returns one of:
|
||||
- {state: 'unknown_platform'} — URL didn't match any
|
||||
platform's strict artist-page pattern
|
||||
- {state: 'new', platform, slug} — would create both
|
||||
artist and source on quick-add
|
||||
- {state: 'artist_match', platform, slug, artist}
|
||||
— artist exists, this
|
||||
exact URL isn't a Source yet (collapses the sidecar-synthetic
|
||||
case too — the synthetic anchor counts as an existing artist
|
||||
row but not as a pollable Source for this URL)
|
||||
- {state: 'source_match', platform, slug, artist, source}
|
||||
— exact (artist, platform,
|
||||
url) Source already exists
|
||||
|
||||
Side-effect-free: two SELECTs at most.
|
||||
"""
|
||||
try:
|
||||
platform, raw_slug = self._derive(url)
|
||||
except (UnknownPlatformError, InvalidUrlError):
|
||||
return {"state": "unknown_platform"}
|
||||
|
||||
slug = slugify(raw_slug)
|
||||
artist = (await self.session.execute(
|
||||
select(Artist).where(Artist.slug == slug)
|
||||
)).scalar_one_or_none()
|
||||
if artist is None:
|
||||
return {"state": "new", "platform": platform, "slug": slug}
|
||||
|
||||
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||
|
||||
source = (await self.session.execute(
|
||||
select(Source).where(
|
||||
Source.artist_id == artist.id,
|
||||
Source.platform == platform,
|
||||
Source.url == url,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if source is None:
|
||||
return {
|
||||
"state": "artist_match",
|
||||
"platform": platform,
|
||||
"slug": slug,
|
||||
"artist": artist_payload,
|
||||
}
|
||||
|
||||
return {
|
||||
"state": "source_match",
|
||||
"platform": platform,
|
||||
"slug": slug,
|
||||
"artist": artist_payload,
|
||||
"source": {
|
||||
"id": source.id,
|
||||
"artist_id": source.artist_id,
|
||||
"platform": source.platform,
|
||||
"url": source.url,
|
||||
"enabled": source.enabled,
|
||||
},
|
||||
}
|
||||
|
||||
def _derive(self, url: str) -> tuple[str, str]:
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
raise InvalidUrlError("url is empty")
|
||||
|
||||
@@ -48,11 +48,11 @@ class SuggestionService:
|
||||
).scalar_one()
|
||||
|
||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
||||
# 'artist' intentionally absent (FC-2d-vii-c) — falls through to
|
||||
# the 1.01 "never surfaces" default like any unsurfaced category.
|
||||
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||
# both fall through to the 1.01 "never surfaces" default like any
|
||||
# unsurfaced category.
|
||||
return {
|
||||
"character": s.suggestion_threshold_character,
|
||||
"copyright": s.suggestion_threshold_copyright,
|
||||
"general": s.suggestion_threshold_general,
|
||||
}.get(category, 1.01)
|
||||
|
||||
|
||||
@@ -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
|
||||
# still stored but the suggestion service filters them out.
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred. Raw predictions are still
|
||||
# stored at STORE_FLOOR but artist never surfaces.
|
||||
SURFACED_CATEGORIES = {"character", "copyright", "general"}
|
||||
# 'artist' retired in FC-2d-vii-c — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred. 'copyright' retired
|
||||
# 2026-06-01 — operator doesn't use the copyright tag-kind; fandom is
|
||||
# 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_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
|
||||
@@ -259,6 +259,29 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
||||
return { error: e.message };
|
||||
}
|
||||
|
||||
case 'PROBE_SOURCE':
|
||||
try {
|
||||
return await api.probeSource(msg.url);
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
|
||||
case 'OPEN_ARTIST_PAGE': {
|
||||
// apiUrl is configured with the /api suffix (see
|
||||
// options/options.html placeholder); the SPA artist route is
|
||||
// /artist/:slug, served from the same origin. Strip /api so the
|
||||
// browser-level URL hits the Vue router, not the JSON API.
|
||||
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||
const slug = encodeURIComponent(msg.slug || '');
|
||||
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
||||
try {
|
||||
await browser.tabs.create({ url: `${base}/artist/${slug}` });
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `Unknown message type: ${msg.type}` };
|
||||
}
|
||||
|
||||
@@ -5,11 +5,26 @@
|
||||
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
||||
font: 500 14px/1.2 system-ui, sans-serif;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
|
||||
transition: transform 100ms ease;
|
||||
transition: transform 100ms ease, background 150ms ease, color 150ms ease;
|
||||
}
|
||||
.fc-add-source-btn:hover { transform: translateY(-1px); }
|
||||
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* state colors map to the FC palette: parchment-on-slate base,
|
||||
accent-orange for new, sage for already-subscribed, amber-warning for
|
||||
artist-exists-but-source-missing. All readable on the dark base. */
|
||||
.fc-add-source-btn--new {
|
||||
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
||||
}
|
||||
.fc-add-source-btn--artist-match {
|
||||
background: rgb(28, 23, 16); color: rgb(255, 200, 120);
|
||||
border: 1px solid rgb(180, 130, 60);
|
||||
}
|
||||
.fc-add-source-btn--source-match {
|
||||
background: rgb(18, 28, 20); color: rgb(140, 220, 160);
|
||||
border: 1px solid rgb(80, 160, 100);
|
||||
}
|
||||
|
||||
.fc-toast {
|
||||
all: revert;
|
||||
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
if (window.__fc_addsource_injected) return;
|
||||
window.__fc_addsource_injected = true;
|
||||
|
||||
// Cached probe result for the current URL so click-handlers know which
|
||||
// action to dispatch without round-tripping again.
|
||||
let currentProbe = null;
|
||||
|
||||
evaluate();
|
||||
|
||||
const reEval = () => evaluate();
|
||||
@@ -9,38 +13,116 @@
|
||||
const origPush = history.pushState;
|
||||
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
||||
|
||||
function evaluate() {
|
||||
const platform = getPlatformFromUrl(window.location.href);
|
||||
const onArtist = platform && isArtistPage(window.location.href, platform);
|
||||
let btn = document.getElementById('fc-add-source-btn');
|
||||
if (onArtist && !btn) injectButton();
|
||||
else if (!onArtist && btn) btn.remove();
|
||||
async function evaluate() {
|
||||
const url = window.location.href;
|
||||
const platform = getPlatformFromUrl(url);
|
||||
const onArtist = platform && isArtistPage(url, platform);
|
||||
const btn = document.getElementById('fc-add-source-btn');
|
||||
if (!onArtist) {
|
||||
if (btn) btn.remove();
|
||||
currentProbe = null;
|
||||
return;
|
||||
}
|
||||
// On artist pages, ask the backend what state the URL is in BEFORE
|
||||
// injecting the button — so the chip can render the right state on
|
||||
// first paint instead of flashing the generic "Add" copy and
|
||||
// updating afterwards.
|
||||
let probe;
|
||||
try {
|
||||
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
|
||||
} catch (e) {
|
||||
probe = { error: e?.message || 'probe failed' };
|
||||
}
|
||||
currentProbe = probe;
|
||||
if (probe?.state === 'unknown_platform') {
|
||||
if (btn) btn.remove();
|
||||
return;
|
||||
}
|
||||
renderButton(probe);
|
||||
}
|
||||
|
||||
function injectButton() {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'fc-add-source-btn';
|
||||
function renderButton(probe) {
|
||||
let btn = document.getElementById('fc-add-source-btn');
|
||||
if (!btn) {
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'fc-add-source-btn';
|
||||
btn.addEventListener('click', onClick);
|
||||
document.body.appendChild(btn);
|
||||
}
|
||||
// Reset state classes so re-renders (SPA navigation) don't stack.
|
||||
btn.className = 'fc-add-source-btn';
|
||||
btn.textContent = '+ Add to FabledCurator';
|
||||
btn.addEventListener('click', onClick);
|
||||
document.body.appendChild(btn);
|
||||
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
|
||||
btn.textContent = labelFor(probe);
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function stateModifier(probe) {
|
||||
if (!probe || probe.error) return 'new';
|
||||
return ({
|
||||
source_match: 'source-match',
|
||||
artist_match: 'artist-match',
|
||||
new: 'new',
|
||||
})[probe.state] || 'new';
|
||||
}
|
||||
|
||||
function labelFor(probe) {
|
||||
if (!probe || probe.error) return '+ Add to FabledCurator';
|
||||
const platformName = platformDisplayName(probe.platform);
|
||||
const artistName = probe.artist?.name;
|
||||
switch (probe.state) {
|
||||
case 'source_match':
|
||||
return `✓ In FabledCurator · ${platformName}`;
|
||||
case 'artist_match':
|
||||
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
|
||||
case 'new':
|
||||
default:
|
||||
return '+ Add to FabledCurator';
|
||||
}
|
||||
}
|
||||
|
||||
function platformDisplayName(key) {
|
||||
return PLATFORMS[key]?.name || key || '';
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
const btn = document.getElementById('fc-add-source-btn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
const probe = currentProbe;
|
||||
|
||||
if (probe?.state === 'source_match') {
|
||||
btn.textContent = 'Opening…';
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({
|
||||
type: 'OPEN_ARTIST_PAGE',
|
||||
slug: probe.artist?.slug,
|
||||
});
|
||||
if (r?.error) showToast(`Error: ${r.error}`, 'error');
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
btn.textContent = 'Adding…';
|
||||
try {
|
||||
const r = await browser.runtime.sendMessage({
|
||||
type: 'ADD_AS_SOURCE',
|
||||
url: window.location.href,
|
||||
});
|
||||
if (r.error) {
|
||||
if (r?.error) {
|
||||
showToast(`Error: ${r.error}`, 'error');
|
||||
} else {
|
||||
const verb = r.created_source ? 'Added' : 'Already a source for';
|
||||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
||||
// Re-probe so the chip flips green without waiting for the next
|
||||
// navigation.
|
||||
evaluate();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(`Error: ${e.message}`, 'error');
|
||||
|
||||
@@ -83,6 +83,12 @@ class FabledCuratorAPI {
|
||||
quickAddSource(url) {
|
||||
return this.request('POST', '/extension/quick-add-source', { url });
|
||||
}
|
||||
probeSource(url) {
|
||||
// Read-only existence check. Drives the content-script chip's
|
||||
// color/copy BEFORE the operator clicks Add.
|
||||
const qs = new URLSearchParams({ url }).toString();
|
||||
return this.request('GET', `/extension/probe?${qs}`);
|
||||
}
|
||||
|
||||
// Connection test = the cheapest read with auth.
|
||||
testConnection() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,10 +48,11 @@ function onSearch(q) {
|
||||
if (!query) { results.value = []; return }
|
||||
loading.value = true
|
||||
try {
|
||||
// Scope the autocomplete to the prediction's category where it maps
|
||||
// to a tag kind. 'copyright' has no tag kind; search unscoped there.
|
||||
const kind = ['artist', 'character'].includes(props.category)
|
||||
? props.category : null
|
||||
// Scope the autocomplete to the prediction's category where it
|
||||
// maps to a tag kind. Only 'character' surfaces as both a
|
||||
// suggestion category and a tag kind now ('artist' + 'copyright'
|
||||
// retired); other categories search unscoped.
|
||||
const kind = props.category === 'character' ? 'character' : null
|
||||
const params = { q: query, limit: 20 }
|
||||
if (kind) params.kind = kind
|
||||
results.value = await api.get('/api/tags/autocomplete', { params })
|
||||
|
||||
@@ -92,7 +92,16 @@ let prevBodyOverflow = null
|
||||
// own keystrokes.
|
||||
function onKeyDown(ev) {
|
||||
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()
|
||||
emit('close')
|
||||
} else if (ev.key === 'ArrowLeft') {
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
density="compact"
|
||||
>{{ 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
|
||||
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 v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
||||
</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) }}
|
||||
</div>
|
||||
</button>
|
||||
<div class="fc-prov__meta">
|
||||
<RouterLink :to="`/artist/${e.artist.slug}`">
|
||||
by {{ e.artist.name }}
|
||||
@@ -29,12 +37,8 @@
|
||||
· {{ e.post.attachment_count }} files
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-prov__actions">
|
||||
<a href="#" @click.prevent="openPost(e.post.id)">
|
||||
View post
|
||||
</a>
|
||||
<div v-if="e.post.description_html" class="fc-prov__actions">
|
||||
<a
|
||||
v-if="e.post.description_html"
|
||||
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
||||
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@
|
||||
</RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="attachments.length" class="fc-prov__attach">
|
||||
<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}`
|
||||
}
|
||||
|
||||
function openPost(postId) {
|
||||
function openPost(postId, artistId) {
|
||||
// Land on the post in the posts feed (in context), not the gallery
|
||||
// image grid. Operator-flagged 2026-05-28.
|
||||
router.push({ path: '/posts', query: { post_id: postId } })
|
||||
// image grid. Scope the feed to this artist so the user lands in
|
||||
// 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()
|
||||
}
|
||||
</script>
|
||||
@@ -153,6 +163,18 @@ function openPost(postId) {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
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 {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
||||
@@ -163,8 +185,21 @@ function openPost(postId) {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.fc-prov__post {
|
||||
font-weight: 700; margin: 4px 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
/* Clickable title — opens the post in the artist-scoped feed
|
||||
(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 {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
<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">
|
||||
<span class="fc-suggestion__name">
|
||||
{{ suggestion.display_name }}
|
||||
<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 class="fc-suggestion__score">{{ scorePct }}</span>
|
||||
<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}`"
|
||||
@click="$emit('accept', suggestion)"
|
||||
/>
|
||||
>
|
||||
Accept
|
||||
</v-btn>
|
||||
<v-menu>
|
||||
<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>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="$emit('alias', suggestion)">
|
||||
@@ -38,17 +52,45 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||
|
||||
<style scoped>
|
||||
.fc-suggestion {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 2px 0;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
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 {
|
||||
font-size: 10px; color: rgb(var(--v-theme-accent));
|
||||
margin-left: 4px;
|
||||
display: inline-block;
|
||||
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 {
|
||||
flex: 0 0 auto; min-width: 38px; text-align: right;
|
||||
font-size: 11px;
|
||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||
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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<SuggestionsCategoryGroup
|
||||
v-if="store.byCategory.general && store.byCategory.general.length"
|
||||
label="General" :items="store.byCategory.general"
|
||||
collapsible :default-open="false"
|
||||
collapsible :default-open="true"
|
||||
@accept="onAccept" @alias="onAlias" @dismiss="store.dismiss"
|
||||
/>
|
||||
</template>
|
||||
@@ -47,7 +47,10 @@ import AliasPickerDialog from './AliasPickerDialog.vue'
|
||||
const props = defineProps({ imageId: { type: Number, required: true } })
|
||||
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 }
|
||||
|
||||
const isEmpty = computed(() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="fc-tag-autocomplete">
|
||||
<v-text-field
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||
density="compact" hide-details
|
||||
@@ -52,13 +53,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useTagStore } from '../../stores/tags.js'
|
||||
import FandomPicker from './FandomPicker.vue'
|
||||
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
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
|
||||
// backend's parse_kind_prefix lives below — kept in sync with
|
||||
// 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'
|
||||
|
||||
const store = useMLStore()
|
||||
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired as
|
||||
// suggestion categories; their threshold rows are gone.
|
||||
const fields = [
|
||||
{ key: 'suggestion_threshold_artist', label: 'Artist' },
|
||||
{ key: 'suggestion_threshold_character', label: 'Character' },
|
||||
{ key: 'suggestion_threshold_copyright', label: 'Copyright' },
|
||||
{ key: 'suggestion_threshold_general', label: 'General' },
|
||||
{ key: 'centroid_similarity_threshold', label: 'Centroid similarity' }
|
||||
]
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
:retrying-all="retryingAll"
|
||||
@retry="onRetrySource"
|
||||
@retry-all="onRetryAll"
|
||||
@view-logs="onViewFailingLogs"
|
||||
/>
|
||||
|
||||
<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) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
{{ s.last_error || 'no error message recorded' }}
|
||||
</span>
|
||||
<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
|
||||
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
||||
:loading="retryingIds.has(s.id)"
|
||||
@@ -52,9 +60,23 @@ defineProps({
|
||||
retryingIds: { type: Set, default: () => new Set() },
|
||||
retryingAll: { type: Boolean, default: false },
|
||||
})
|
||||
defineEmits(['retry', 'retry-all'])
|
||||
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
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) {
|
||||
filter.value = { ...filter.value, ...patch }
|
||||
await loadFirst()
|
||||
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
||||
return {
|
||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||
activity, failing, activeEvents,
|
||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
||||
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||
closeDetail, loadStats,
|
||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
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
|
||||
// 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
|
||||
error.value = null
|
||||
anchorId.value = null
|
||||
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
|
||||
cursorOlder.value = body.cursor_older
|
||||
cursorNewer.value = body.cursor_newer
|
||||
@@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
cursorOlder.value = body.next_cursor
|
||||
@@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => {
|
||||
loading.value = true
|
||||
try {
|
||||
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)
|
||||
cursorNewer.value = body.next_cursor
|
||||
|
||||
@@ -4,12 +4,12 @@ import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
|
||||
// Category display order: people/sources first, general last.
|
||||
export const CATEGORY_ORDER = ['artist', 'character', 'copyright', 'general']
|
||||
// Category display order: people first, general last.
|
||||
// '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 = {
|
||||
artist: 'Artist',
|
||||
character: 'Character',
|
||||
copyright: 'Copyright',
|
||||
general: 'General'
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,15 @@ function setupAroundObservers() {
|
||||
}
|
||||
async function loadAroundAndAnchor() {
|
||||
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()
|
||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||
if (el) el.scrollIntoView({ block: 'center' })
|
||||
|
||||
@@ -135,6 +135,114 @@ async def test_quick_add_source_wrong_key_401(client, ext_key):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# --- /api/extension/probe ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_returns_new_when_nothing_exists(client, ext_key):
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
query_string={"url": "https://www.patreon.com/freshcreator"},
|
||||
headers={"X-Extension-Key": ext_key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["state"] == "new"
|
||||
assert body["platform"] == "patreon"
|
||||
assert body["slug"] == "freshcreator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_returns_source_match_for_already_added(client, ext_key, db):
|
||||
artist = Artist(name="Alice", slug="alice", is_subscription=True)
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
src = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="https://www.patreon.com/alice", enabled=True, config_overrides={},
|
||||
)
|
||||
db.add(src)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
query_string={"url": "https://www.patreon.com/alice"},
|
||||
headers={"X-Extension-Key": ext_key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["state"] == "source_match"
|
||||
assert body["artist"]["slug"] == "alice"
|
||||
assert body["source"]["url"] == "https://www.patreon.com/alice"
|
||||
assert body["source"]["platform"] == "patreon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists(
|
||||
client, ext_key, db,
|
||||
):
|
||||
"""Filesystem-imported artist with only a sidecar synthetic Source
|
||||
for the (artist, platform) — the URL the operator's browsing isn't
|
||||
yet a real Source. The probe should collapse this into artist_match
|
||||
so the chip says '+ Add Patreon source to Dymkens' rather than
|
||||
'+ Add to FabledCurator' (which would re-create the artist)."""
|
||||
artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False)
|
||||
db.add(artist)
|
||||
await db.flush()
|
||||
synthetic = Source(
|
||||
artist_id=artist.id, platform="patreon",
|
||||
url="sidecar:patreon:dymkens", enabled=False, config_overrides={},
|
||||
)
|
||||
db.add(synthetic)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
query_string={"url": "https://www.patreon.com/dymkens"},
|
||||
headers={"X-Extension-Key": ext_key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["state"] == "artist_match"
|
||||
assert body["artist"]["slug"] == "dymkens"
|
||||
assert "source" not in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key):
|
||||
"""A patreon URL that isn't an artist page (e.g. /home, /posts/N)
|
||||
shouldn't trigger the button. Sentinel 'unknown_platform' state
|
||||
tells the content script to skip injection."""
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
query_string={"url": "https://www.patreon.com/posts/12345"},
|
||||
headers={"X-Extension-Key": ext_key},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["state"] == "unknown_platform"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_missing_key_401(client):
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
query_string={"url": "https://www.patreon.com/maewix"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_missing_url_400(client, ext_key):
|
||||
resp = await client.get(
|
||||
"/api/extension/probe",
|
||||
headers={"X-Extension-Key": ext_key},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "invalid_body"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_add_source_missing_body_400(client, ext_key):
|
||||
resp = await client.post(
|
||||
|
||||
@@ -19,7 +19,12 @@ async def test_get_and_patch_settings(client):
|
||||
resp = await client.get("/api/ml/settings")
|
||||
assert resp.status_code == 200
|
||||
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(
|
||||
"/api/ml/settings", json={"suggestion_threshold_general": 0.90}
|
||||
|
||||
@@ -19,9 +19,9 @@ def test_threshold_for_artist_is_unsurfaced():
|
||||
|
||||
class _S:
|
||||
suggestion_threshold_character = 0.5
|
||||
suggestion_threshold_copyright = 0.5
|
||||
suggestion_threshold_general = 0.5
|
||||
|
||||
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(), "copyright") == 1.01
|
||||
|
||||
@@ -25,10 +25,13 @@ def _img(sha: str, predictions: dict) -> ImageRecord:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
"a" * 64,
|
||||
{
|
||||
"smile": {"category": "general", "confidence": 0.80},
|
||||
"lowconf": {"category": "general", "confidence": 0.30},
|
||||
"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)
|
||||
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||
assert "sword" in names
|
||||
assert "smile" not in names
|
||||
assert "lowconf" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -17,10 +17,13 @@ from backend.app.services.ml.tagger import (
|
||||
|
||||
|
||||
def test_surfaced_categories():
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-derived
|
||||
# (image_record.artist_id), never ML-inferred.
|
||||
assert SURFACED_CATEGORIES == {"character", "copyright", "general"}
|
||||
# FC-2d-vii-c: 'artist' retired — artist identity is acquisition-
|
||||
# derived (image_record.artist_id), never ML-inferred.
|
||||
# 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 "copyright" not in SURFACED_CATEGORIES
|
||||
|
||||
|
||||
def test_store_floor_is_low():
|
||||
|
||||
Reference in New Issue
Block a user