Compare commits
30 Commits
v26.05.26.2
...
ext-1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e8b796a1 | |||
| 8675f105ad | |||
| 74dac6b960 | |||
| 9e19c081b0 | |||
| 3838f04c16 | |||
| 42b1340324 | |||
| 3b1e2f1ceb | |||
| 8cdf0af0e1 | |||
| ccee344099 | |||
| 0316f92e8b | |||
| 6df74683b3 | |||
| 243e536225 | |||
| 2f16699971 | |||
| a5cb684d34 | |||
| 965a953b2e | |||
| 90c176b195 | |||
| b8d89b9f2a | |||
| 07344e0843 | |||
| 42c33e44f9 | |||
| 4e82208926 | |||
| 85b640f32e | |||
| c7001f4aed | |||
| f827612930 | |||
| 3f0153cba5 | |||
| 52fff00353 | |||
| f3e8f30a8f | |||
| eee107766e | |||
| c14338cbce | |||
| 7a64730bd2 | |||
| 1803a09306 |
@@ -2,7 +2,17 @@ name: Build images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
|
||||
# merge-to-main, not from the dev branch image. Saves one full docker
|
||||
# build per dev push.
|
||||
branches: [main]
|
||||
# Tag-push triggers an immutable per-version image build (e.g.
|
||||
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
|
||||
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
|
||||
# negligible per tag. Doesn't overlap with the push-to-main build (that
|
||||
# one publishes `:main` + `:latest`; the tag-push build publishes only
|
||||
# `:<tag>`).
|
||||
tags: ['v*']
|
||||
|
||||
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
||||
@@ -158,8 +168,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download signed XPI from Forgejo release asset (main only)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: Download signed XPI from Forgejo release asset (main + tags)
|
||||
# Fires on main-push AND on tag-push. Tag-push builds re-package the
|
||||
# same source code as the preceding main-push build but with an
|
||||
# immutable version tag — they need the XPI too, otherwise the
|
||||
# versioned image ships without the signed extension.
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
@@ -200,7 +214,20 @@ jobs:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||
# 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).
|
||||
# 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.
|
||||
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"
|
||||
else
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||
@@ -231,7 +258,13 @@ jobs:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||
# 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.
|
||||
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"
|
||||
else
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+15
-66
@@ -8,8 +8,10 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# pull_request trigger intentionally absent — with branches: [dev, main]
|
||||
# above, every PR commit already fires CI via the push event on dev. Adding
|
||||
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
|
||||
# (single-operator Forgejo repo) so push coverage is complete.
|
||||
|
||||
jobs:
|
||||
backend-lint-and-test:
|
||||
@@ -24,22 +26,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache pip wheels
|
||||
# continue-on-error: act_runner's cache backend has been broken on
|
||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
||||
# but the action's JS bundle now fails to load at all
|
||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
||||
# the whole job. The install step that follows handles cold caches
|
||||
# natively (uv pip / pip install both work without it), so net
|
||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
||||
# job. Re-enable once the runner-side cache backend is fixed.
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||
# index.js" failures that tank the whole job). The cache step targeted
|
||||
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||
# benefit was marginal even when working. Cost of removal: ~30s of
|
||||
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
||||
# docker volume at the runner level (skips actions/cache entirely),
|
||||
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
||||
# pin act_runner version, etc.).
|
||||
|
||||
- name: Install Python deps
|
||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||
@@ -133,22 +130,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache pip wheels
|
||||
# continue-on-error: act_runner's cache backend has been broken on
|
||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
||||
# but the action's JS bundle now fails to load at all
|
||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
||||
# the whole job. The install step that follows handles cold caches
|
||||
# natively (uv pip / pip install both work without it), so net
|
||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
||||
# job. Re-enable once the runner-side cache backend is fixed.
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
- name: API integration shard (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -207,22 +188,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache pip wheels
|
||||
# continue-on-error: act_runner's cache backend has been broken on
|
||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
||||
# but the action's JS bundle now fails to load at all
|
||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
||||
# the whole job. The install step that follows handles cold caches
|
||||
# natively (uv pip / pip install both work without it), so net
|
||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
||||
# job. Re-enable once the runner-side cache backend is fixed.
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -281,22 +246,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache pip wheels
|
||||
# continue-on-error: act_runner's cache backend has been broken on
|
||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
||||
# but the action's JS bundle now fails to load at all
|
||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
||||
# the whole job. The install step that follows handles cold caches
|
||||
# natively (uv pip / pip install both work without it), so net
|
||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
||||
# job. Re-enable once the runner-side cache backend is fixed.
|
||||
continue-on-error: true
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-py314-
|
||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||
run: |
|
||||
set -eux
|
||||
|
||||
@@ -83,55 +83,80 @@ def upgrade() -> None:
|
||||
if not other_ids:
|
||||
continue
|
||||
|
||||
# STEP 2: PRE-merge colliding Posts BEFORE the bulk reparent.
|
||||
# Find pairs (keep, drop) where:
|
||||
# keep = a Post under the canonical source
|
||||
# drop = a Post under one of the non-canonical sources whose
|
||||
# external_post_id equals keep.external_post_id
|
||||
# If we let the bulk UPDATE try to repoint drop onto canonical,
|
||||
# uq_post_source_external_id fires row-by-row and aborts the
|
||||
# whole migration.
|
||||
colliding = conn.execute(
|
||||
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
|
||||
# across the entire (canonical + others) group, BEFORE the bulk
|
||||
# reparent. Two cases must both be handled:
|
||||
# (A) canonical has Post X with epid=N; an "other" source has
|
||||
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
|
||||
# collides with itself.
|
||||
# (B) two different "other" sources each have a Post with
|
||||
# epid=N; canonical has none → after bulk UPDATE, both
|
||||
# are repointed to (canonical, N) and the second collides.
|
||||
# The earlier version of this migration only handled (A); the
|
||||
# operator's deploy 2026-05-26 tripped (B) at line 139.
|
||||
# Fix: group ALL Posts in the (artist, platform) by epid; for
|
||||
# any group with count>1, pick the keep (prefer one already
|
||||
# under canonical; else lowest id) and merge the rest into it.
|
||||
all_posts = conn.execute(
|
||||
text("""
|
||||
SELECT keep.id AS keep_id, drop_.id AS drop_id
|
||||
FROM post AS keep
|
||||
JOIN post AS drop_
|
||||
ON drop_.external_post_id = keep.external_post_id
|
||||
AND drop_.id != keep.id
|
||||
WHERE keep.source_id = :canonical
|
||||
AND drop_.source_id = ANY(:others)
|
||||
SELECT external_post_id, id, source_id
|
||||
FROM post
|
||||
WHERE source_id = :canonical OR source_id = ANY(:others)
|
||||
ORDER BY external_post_id, id
|
||||
"""),
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
).fetchall()
|
||||
for keep_id, drop_id in colliding:
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET post_id = :keep
|
||||
WHERE post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_record SET primary_post_id = :keep
|
||||
WHERE primary_post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
# Repointed provenance may now collide on
|
||||
# uq_image_provenance_image_post (alembic 0021). Dedupe:
|
||||
# keep min(id) per (image_record_id, post_id).
|
||||
conn.execute(text("""
|
||||
DELETE FROM image_provenance ip1
|
||||
USING image_provenance ip2
|
||||
WHERE ip1.image_record_id = ip2.image_record_id
|
||||
AND ip1.post_id = ip2.post_id
|
||||
AND ip1.id > ip2.id
|
||||
"""))
|
||||
conn.execute(
|
||||
text("DELETE FROM post WHERE id = :drop_"),
|
||||
{"drop_": drop_id},
|
||||
)
|
||||
by_epid: dict = {}
|
||||
for epid, post_id, src_id in all_posts:
|
||||
by_epid.setdefault(epid, []).append((post_id, src_id))
|
||||
for _epid, posts in by_epid.items():
|
||||
if len(posts) <= 1:
|
||||
continue
|
||||
# Prefer a Post already under canonical as the keep.
|
||||
canonical_posts = [p for p in posts if p[1] == canonical_id]
|
||||
if canonical_posts:
|
||||
keep_id = canonical_posts[0][0]
|
||||
else:
|
||||
keep_id = posts[0][0] # already sorted by id ASC
|
||||
drop_ids = [p[0] for p in posts if p[0] != keep_id]
|
||||
for drop_id in drop_ids:
|
||||
# Pre-delete image_provenance rows under drop_ whose
|
||||
# image_record_id ALREADY has a provenance under keep —
|
||||
# the UPDATE below would otherwise repoint them and
|
||||
# trip uq_image_provenance_image_post (alembic 0021)
|
||||
# row-by-row before any after-the-fact dedupe could
|
||||
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
|
||||
# this at line 123.
|
||||
conn.execute(
|
||||
text("""
|
||||
DELETE FROM image_provenance
|
||||
WHERE post_id = :drop_
|
||||
AND image_record_id IN (
|
||||
SELECT image_record_id FROM image_provenance
|
||||
WHERE post_id = :keep
|
||||
)
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
# Now safe to repoint the survivors.
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_provenance SET post_id = :keep
|
||||
WHERE post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("""
|
||||
UPDATE image_record SET primary_post_id = :keep
|
||||
WHERE primary_post_id = :drop_
|
||||
"""),
|
||||
{"keep": keep_id, "drop_": drop_id},
|
||||
)
|
||||
conn.execute(
|
||||
text("DELETE FROM post WHERE id = :drop_"),
|
||||
{"drop_": drop_id},
|
||||
)
|
||||
|
||||
# STEP 3: Bulk reparent the remaining Posts off the other
|
||||
# Sources. After step 2, no collisions on
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""drop meta + rating tag kinds — operator-retired 2026-05-26
|
||||
|
||||
Revision ID: 0023
|
||||
Revises: 0022
|
||||
Create Date: 2026-05-26
|
||||
|
||||
Operator decided meta + rating aren't valid tag kinds for FC. Per-row
|
||||
behavior: DELETE existing rows (operator chose "clean break" over
|
||||
"convert to general"). All cascading FKs (image_tag, tag_alias,
|
||||
tag_allowlist, tag_reference_embedding, tag_suggestion_rejection,
|
||||
series_page) use ondelete="CASCADE" so a single DELETE on tag cleans
|
||||
the related rows in one go.
|
||||
|
||||
After the data cleanup, recreate the tag_kind ENUM without 'meta' /
|
||||
'rating' (Postgres has no `ALTER TYPE ... DROP VALUE`; standard
|
||||
rename-create-cast-drop dance). The server default 'general' is
|
||||
dropped before the type swap and restored after.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0023"
|
||||
down_revision: Union[str, None] = "0022"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Delete tags of the retired kinds. CASCADE handles related tables.
|
||||
op.execute("DELETE FROM tag WHERE kind IN ('meta', 'rating')")
|
||||
|
||||
# 2. Drop the CHECK constraint that references the enum's literal
|
||||
# values. Postgres can't resolve `kind = 'character'` across the
|
||||
# type swap below — the literal would bind to the new tag_kind
|
||||
# but the column is on tag_kind_old, producing
|
||||
# "operator does not exist: tag_kind = tag_kind_old".
|
||||
# (Operator-hit during the v26.05.26.5 deploy attempt; ck was
|
||||
# originally added by alembic 0002.) Recreated post-swap.
|
||||
op.drop_constraint(
|
||||
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||
)
|
||||
|
||||
# 3. Drop the server default — ALTER COLUMN TYPE can't carry it
|
||||
# across the type swap below.
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||
|
||||
# 4. Recreate the tag_kind enum without meta/rating.
|
||||
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||
op.execute(
|
||||
"CREATE TYPE tag_kind AS ENUM ("
|
||||
"'artist', 'character', 'fandom', 'general', "
|
||||
"'series', 'archive', 'post'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE tag "
|
||||
"ALTER COLUMN kind TYPE tag_kind "
|
||||
"USING kind::text::tag_kind"
|
||||
)
|
||||
op.execute("DROP TYPE tag_kind_old")
|
||||
|
||||
# 5. Restore the server default.
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||
|
||||
# 6. Restore the CHECK constraint (now bound to the new tag_kind).
|
||||
op.create_check_constraint(
|
||||
"ck_tag_fandom_requires_character",
|
||||
"tag",
|
||||
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add the values back to the enum so old code can boot. The deleted
|
||||
# tag rows are gone permanently — no safe restore.
|
||||
op.drop_constraint(
|
||||
"ck_tag_fandom_requires_character", "tag", type_="check"
|
||||
)
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind DROP DEFAULT")
|
||||
op.execute("ALTER TYPE tag_kind RENAME TO tag_kind_old")
|
||||
op.execute(
|
||||
"CREATE TYPE tag_kind AS ENUM ("
|
||||
"'artist', 'character', 'fandom', 'general', "
|
||||
"'series', 'archive', 'post', 'meta', 'rating'"
|
||||
")"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE tag "
|
||||
"ALTER COLUMN kind TYPE tag_kind "
|
||||
"USING kind::text::tag_kind"
|
||||
)
|
||||
op.execute("DROP TYPE tag_kind_old")
|
||||
op.execute("ALTER TABLE tag ALTER COLUMN kind SET DEFAULT 'general'")
|
||||
op.create_check_constraint(
|
||||
"ck_tag_fandom_requires_character",
|
||||
"tag",
|
||||
"(fandom_id IS NULL) OR (kind = 'character')",
|
||||
)
|
||||
+38
-1
@@ -3,13 +3,23 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart
|
||||
from quart import Quart, request
|
||||
|
||||
from .api import all_blueprints
|
||||
from .config import get_config
|
||||
from .frontend import frontend_bp
|
||||
from .services.credential_crypto import CredentialCrypto
|
||||
|
||||
# Browser-extension origins. The FabledCurator extension fetches from
|
||||
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
|
||||
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
|
||||
# 'Test connection' returned `NetworkError` because the X-Extension-Key
|
||||
# header on /api/credentials triggers a CORS preflight that our routes
|
||||
# don't handle. Whitelisting only these two schemes (not opening CORS
|
||||
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
|
||||
# without weakening the no-CORS posture for normal browser usage.
|
||||
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
|
||||
|
||||
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||||
|
||||
|
||||
@@ -35,6 +45,33 @@ def create_app() -> Quart:
|
||||
# Registered last so /api/* routes win over the SPA catch-all.
|
||||
app.register_blueprint(frontend_bp)
|
||||
|
||||
@app.before_request
|
||||
async def _extension_cors_preflight():
|
||||
# Short-circuit OPTIONS preflight from the browser extension with a
|
||||
# 204 + CORS headers (the after_request hook below adds them).
|
||||
# Without this, OPTIONS lands on routes that only declared POST/GET
|
||||
# methods and 405s before the after_request gets a chance.
|
||||
if request.method != "OPTIONS":
|
||||
return None
|
||||
origin = request.headers.get("Origin", "")
|
||||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||
return "", 204
|
||||
return None
|
||||
|
||||
@app.after_request
|
||||
async def _extension_cors_headers(response):
|
||||
origin = request.headers.get("Origin", "")
|
||||
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||
response.headers["Access-Control-Allow-Origin"] = origin
|
||||
response.headers["Access-Control-Allow-Methods"] = (
|
||||
"GET, POST, PATCH, DELETE, OPTIONS"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"Content-Type, X-Extension-Key"
|
||||
)
|
||||
response.headers["Access-Control-Max-Age"] = "86400"
|
||||
return response
|
||||
|
||||
@app.after_serving
|
||||
async def _dispose_db_engine() -> None:
|
||||
from .extensions import dispose_engine
|
||||
|
||||
+32
-5
@@ -15,6 +15,7 @@ from ..services.tag_service import (
|
||||
TagService,
|
||||
TagValidationError,
|
||||
)
|
||||
from ..utils.tag_prefix import parse_kind_prefix
|
||||
|
||||
tags_bp = Blueprint("tags", __name__, url_prefix="/api")
|
||||
|
||||
@@ -105,13 +106,39 @@ async def directory():
|
||||
|
||||
@tags_bp.route("/tags", methods=["POST"])
|
||||
async def create_tag():
|
||||
"""Create a tag. Two input shapes accepted:
|
||||
1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
|
||||
2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
|
||||
The server runs parse_kind_prefix(name) to derive kind; the colon
|
||||
and prefix are stripped from the stored tag name. If no recognized
|
||||
prefix is present, the kind defaults to `general`.
|
||||
Explicit kind ALWAYS wins (backward-compat for existing callers).
|
||||
"""
|
||||
body = await request.get_json()
|
||||
if not body or "name" not in body or "kind" not in body:
|
||||
return jsonify({"error": "name and kind required"}), 400
|
||||
if not body or "name" not in body:
|
||||
return jsonify({"error": "name required"}), 400
|
||||
name = body["name"]
|
||||
kind = _coerce_kind(body["kind"])
|
||||
if kind is None:
|
||||
return jsonify({"error": f"invalid kind {body['kind']!r}"}), 400
|
||||
explicit_kind_raw = body.get("kind")
|
||||
|
||||
if explicit_kind_raw is not None:
|
||||
# Caller provided kind — honor it; don't re-parse.
|
||||
kind = _coerce_kind(explicit_kind_raw)
|
||||
if kind is None:
|
||||
return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
|
||||
else:
|
||||
# IR-style: parse "kind:Name" from the raw name.
|
||||
parsed_kind, parsed_name = parse_kind_prefix(name)
|
||||
if parsed_kind is not None:
|
||||
name = parsed_name
|
||||
kind = _coerce_kind(parsed_kind)
|
||||
# parse_kind_prefix only returns kinds from KNOWN_KINDS which
|
||||
# are all valid TagKind members, so _coerce_kind can't return
|
||||
# None here — but defensive.
|
||||
if kind is None:
|
||||
return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
|
||||
else:
|
||||
kind = TagKind.general
|
||||
|
||||
fandom_id = body.get("fandom_id")
|
||||
|
||||
async with get_session() as session:
|
||||
|
||||
@@ -35,8 +35,10 @@ class TagKind(StrEnum):
|
||||
series = "series"
|
||||
archive = "archive"
|
||||
post = "post"
|
||||
meta = "meta"
|
||||
rating = "rating"
|
||||
# `meta` and `rating` retired by operator 2026-05-26 (alembic 0023).
|
||||
# `artist` retired in FC-2d-vii-c — artists are first-class entities
|
||||
# via Artist/Source rows now, not tags — but the enum value stays
|
||||
# to keep historic tag rows queryable.
|
||||
|
||||
|
||||
image_tag = Table(
|
||||
|
||||
@@ -109,7 +109,10 @@ class PostFeedService:
|
||||
if row is None:
|
||||
return None
|
||||
post, artist, source = row
|
||||
thumbs_map = await self._thumbnails_for([post.id])
|
||||
# Detail endpoint returns the FULL image list for PostModal's
|
||||
# masonry grid — feed query still caps at THUMBNAIL_LIMIT via
|
||||
# the default arg.
|
||||
thumbs_map = await self._thumbnails_for([post.id], limit=None)
|
||||
atts_map = await self._attachments_for([post.id])
|
||||
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
|
||||
item["description_full"] = html_to_plain(post.description)
|
||||
@@ -117,15 +120,21 @@ class PostFeedService:
|
||||
|
||||
# --- composition helpers ---------------------------------------------
|
||||
|
||||
async def _thumbnails_for(self, post_ids: list[int]) -> dict[int, dict]:
|
||||
"""post_id -> {"thumbs": [...up to 6], "more": int}.
|
||||
async def _thumbnails_for(
|
||||
self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT,
|
||||
) -> dict[int, dict]:
|
||||
"""post_id -> {"thumbs": [...up to limit], "more": int}.
|
||||
|
||||
Selects THUMBNAIL_LIMIT+1 images per post via window function so we
|
||||
can detect overflow in a single query.
|
||||
Selects up to `limit` images per post via window function so we
|
||||
can detect overflow in a single query. Pass `limit=None` to
|
||||
return ALL thumbnails per post (used by `get_post` for PostModal's
|
||||
masonry grid; the feed pass keeps the default cap so payloads
|
||||
stay small).
|
||||
"""
|
||||
if not post_ids:
|
||||
return {}
|
||||
# Rank images within each post and fetch only the top THUMBNAIL_LIMIT+1.
|
||||
# Rank images within each post; cap at `limit` rows per post when
|
||||
# limit is set, return all when limit is None.
|
||||
ranked = (
|
||||
select(
|
||||
ImageRecord.id,
|
||||
@@ -143,12 +152,13 @@ class PostFeedService:
|
||||
.where(ImageRecord.primary_post_id.in_(post_ids))
|
||||
.subquery()
|
||||
)
|
||||
rows = (await self.session.execute(
|
||||
select(
|
||||
ranked.c.id, ranked.c.primary_post_id,
|
||||
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
||||
).where(ranked.c.rn <= THUMBNAIL_LIMIT)
|
||||
)).all()
|
||||
stmt = select(
|
||||
ranked.c.id, ranked.c.primary_post_id,
|
||||
ranked.c.sha256, ranked.c.mime, ranked.c.total,
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.where(ranked.c.rn <= limit)
|
||||
rows = (await self.session.execute(stmt)).all()
|
||||
|
||||
out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
|
||||
for img_id, pid, sha, mime, total in rows:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Parse the user-facing `kind:name` shortcut used by the add-tag input.
|
||||
|
||||
Mirrors IR's app/utils/tag_prefix.py. Tag.name in FC is stored bare;
|
||||
the `kind:` prefix only exists as an input convention at user-facing
|
||||
places (image-modal add-tag input, future bulk-add forms). The parser
|
||||
is the single owner of the kind-string list — anything not in
|
||||
KNOWN_KINDS keeps its colon as literal text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Kinds the user can type as a prefix at the input boundary.
|
||||
# Exclusions:
|
||||
# - `general` is the default for un-prefixed input (never typed as prefix)
|
||||
# - `archive`, `post` are system-managed
|
||||
# - `artist` was retired in FC-2d-vii-c — artists are first-class
|
||||
# entities (Artist row + ImageRecord.artist_id), browsed via the
|
||||
# provenance axis rather than as tags. See project_provenance_separation.
|
||||
# - `meta`, `rating` retired as user-typeable per operator 2026-05-26 —
|
||||
# content classification only needs character/fandom/series.
|
||||
KNOWN_KINDS: frozenset[str] = frozenset({
|
||||
"character",
|
||||
"fandom",
|
||||
"series",
|
||||
})
|
||||
|
||||
|
||||
def parse_kind_prefix(raw: str) -> tuple[str | None, str]:
|
||||
"""Split a raw user-typed tag string into (kind, name).
|
||||
|
||||
Returns (kind, name) where kind is lowercase canonical and in
|
||||
KNOWN_KINDS, or (None, raw.strip()) if no recognized prefix is
|
||||
present. `name` is always whitespace-stripped.
|
||||
|
||||
Examples:
|
||||
parse_kind_prefix("character:Saber") -> ("character", "Saber")
|
||||
parse_kind_prefix("Character:Saber") -> ("character", "Saber")
|
||||
parse_kind_prefix("sunset") -> (None, "sunset")
|
||||
parse_kind_prefix("http://example") -> (None, "http://example")
|
||||
parse_kind_prefix("fandom: FSN ") -> ("fandom", "FSN")
|
||||
"""
|
||||
if ":" in raw:
|
||||
prefix, rest = raw.split(":", 1)
|
||||
if prefix.lower() in KNOWN_KINDS:
|
||||
return prefix.lower(), rest.strip()
|
||||
return None, raw.strip()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FabledCurator",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||
|
||||
"browser_specific_settings": {
|
||||
@@ -11,6 +11,11 @@
|
||||
}
|
||||
},
|
||||
|
||||
"content_security_policy": {
|
||||
"_comment": "Override the MV3 default CSP to OMIT upgrade-insecure-requests. FC runs over plain HTTP per the homelab posture (feedback_homelab_http), and the default MV3 CSP would silently upgrade every fetch(http://curator.../...) to https:// and fail with NS_ERROR_GENERATE_FAILURE. Operator-flagged 2026-05-26 after the 'Test connection' button errored despite a working CORS preflight on the backend.",
|
||||
"extension_pages": "script-src 'self'; object-src 'self';"
|
||||
},
|
||||
|
||||
"permissions": [
|
||||
"cookies",
|
||||
"storage",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fabledcurator-extension",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"description": "Firefox extension for FabledCurator",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
<ImageViewer v-if="modal.isOpen" @close="modal.close()" />
|
||||
<PostModal />
|
||||
<AppSnackbar ref="snackbar" />
|
||||
</v-app>
|
||||
</template>
|
||||
@@ -13,6 +14,7 @@ import { onMounted, ref } from 'vue'
|
||||
import AppShell from './components/AppShell.vue'
|
||||
import AppSnackbar from './components/AppSnackbar.vue'
|
||||
import ImageViewer from './components/modal/ImageViewer.vue'
|
||||
import PostModal from './components/posts/PostModal.vue'
|
||||
import { useModalStore } from './stores/modal.js'
|
||||
|
||||
const modal = useModalStore()
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
</v-tab>
|
||||
<v-tab value="management">Management</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- Right-side spacer: balances the left cell's flex weight so the
|
||||
centered tabs stay geometrically centered regardless of the
|
||||
artist-name length. Mirrors TopNav's 1fr | auto | 1fr layout. -->
|
||||
<div class="fc-artist-header__right" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -54,11 +59,13 @@ const stats = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Matches TopNav.vue's frosted recipe exactly — top:64px parks it under
|
||||
the 64px-tall TopNav with no visible seam. */
|
||||
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
|
||||
against TopNav's bottom edge (TopNav is 0.75rem padding + ~24px content
|
||||
= ~48px tall; operator-flagged 2026-05-26 that top:64px left a visible
|
||||
gap). The two bars now read as one continuous frosted strip. */
|
||||
.fc-artist-header {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: 48px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,6 +114,11 @@ const stats = computed(() => {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fc-artist-header__right {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fc-artist-header__tab-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -67,7 +67,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.fc-artist-posts {
|
||||
max-width: 900px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fc-artist-posts__loading,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<div class="fc-tag-autocomplete">
|
||||
<div class="d-flex" style="gap: 6px;">
|
||||
<v-select
|
||||
v-model="kind" :items="kindOptions" :item-title="(k) => k.label" :item-value="(k) => k.value"
|
||||
density="compact" hide-details style="max-width: 140px;"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="query" placeholder="Add tag…" density="compact" hide-details
|
||||
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
|
||||
@keydown.enter.prevent="onEnter" @keydown.esc="$emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
<v-list v-if="hits.length || allowCreate" density="compact" class="fc-tag-autocomplete__list">
|
||||
<v-text-field
|
||||
v-model="query"
|
||||
placeholder="Add tag (or kind:name — character/fandom/series)"
|
||||
density="compact" hide-details
|
||||
@keydown.down.prevent="moveHighlight(1)"
|
||||
@keydown.up.prevent="moveHighlight(-1)"
|
||||
@keydown.enter.prevent="onEnter"
|
||||
@keydown.esc="$emit('cancel')"
|
||||
/>
|
||||
<v-list
|
||||
v-if="hits.length || allowCreate"
|
||||
density="compact" class="fc-tag-autocomplete__list"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(h, idx) in hits" :key="h.id"
|
||||
:active="idx === highlight" @click="onPick(h)"
|
||||
@@ -26,7 +27,7 @@
|
||||
<span v-if="h.fandom_name" class="text-caption">— {{ h.fandom_name }}</span>
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption">{{ h.image_count }}</span>
|
||||
<span class="text-caption">{{ h.kind }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
@@ -34,9 +35,13 @@
|
||||
@click="onCreate"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="small" :color="store.colorFor(kind)">{{ iconFor(kind) }}</v-icon>
|
||||
<v-icon size="small" :color="store.colorFor(parsedKind)">
|
||||
{{ iconFor(parsedKind) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Create "{{ query }}" as {{ kind }}</v-list-item-title>
|
||||
<v-list-item-title>
|
||||
Create "{{ parsedName }}" as {{ parsedKind }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
@@ -54,65 +59,95 @@ import FandomPicker from './FandomPicker.vue'
|
||||
const emit = defineEmits(['pick-existing', 'pick-new', 'cancel'])
|
||||
const store = useTagStore()
|
||||
|
||||
const kind = ref('general')
|
||||
// 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
|
||||
// canonical parser; this mirror just powers the live Create-label
|
||||
// preview ("Create 'Eric' as artist") and the autocomplete query.
|
||||
const query = ref('')
|
||||
const hits = ref([])
|
||||
const highlight = ref(0)
|
||||
const fandomDialog = ref(false)
|
||||
let pendingNewName = null
|
||||
|
||||
const kindOptions = store.kindOptions()
|
||||
const KNOWN_KINDS = new Set([
|
||||
'character', 'fandom', 'series',
|
||||
])
|
||||
const KIND_ICONS = {
|
||||
general: 'mdi-tag', artist: 'mdi-palette', character: 'mdi-account-circle',
|
||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
||||
meta: 'mdi-cog-outline', rating: 'mdi-shield-check-outline'
|
||||
}
|
||||
function iconFor(k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
|
||||
|
||||
const parsed = computed(() => {
|
||||
const raw = query.value.trim()
|
||||
if (raw.includes(':')) {
|
||||
const idx = raw.indexOf(':')
|
||||
const prefix = raw.slice(0, idx).toLowerCase()
|
||||
if (KNOWN_KINDS.has(prefix)) {
|
||||
return { kind: prefix, name: raw.slice(idx + 1).trim() }
|
||||
}
|
||||
}
|
||||
return { kind: 'general', name: raw }
|
||||
})
|
||||
const parsedKind = computed(() => parsed.value.kind)
|
||||
const parsedName = computed(() => parsed.value.name)
|
||||
|
||||
let debounceId = null
|
||||
watch([query, kind], () => {
|
||||
watch(query, () => {
|
||||
highlight.value = 0
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
debounceId = setTimeout(async () => {
|
||||
const q = query.value.trim()
|
||||
const q = parsedName.value
|
||||
if (!q) { hits.value = []; return }
|
||||
hits.value = await store.autocomplete(q, kind.value, 10)
|
||||
// Autocomplete across ALL kinds. When the user typed a prefix the
|
||||
// matches list is naturally narrower because the parsed name is
|
||||
// shorter; we don't filter server-side by kind.
|
||||
hits.value = await store.autocomplete(q, null, 10)
|
||||
}, 200)
|
||||
})
|
||||
|
||||
const allowCreate = computed(() => {
|
||||
const q = query.value.trim()
|
||||
return q && !hits.value.some(h => h.name.toLowerCase() === q.toLowerCase() && h.kind === kind.value)
|
||||
const q = parsedName.value
|
||||
if (!q) return false
|
||||
return !hits.value.some(h =>
|
||||
h.name.toLowerCase() === q.toLowerCase() && h.kind === parsedKind.value,
|
||||
)
|
||||
})
|
||||
|
||||
function moveHighlight(delta) {
|
||||
function moveHighlight (delta) {
|
||||
const total = hits.value.length + (allowCreate.value ? 1 : 0)
|
||||
if (total === 0) return
|
||||
highlight.value = (highlight.value + delta + total) % total
|
||||
}
|
||||
|
||||
function onPick(hit) { emit('pick-existing', hit); reset() }
|
||||
function onPick (hit) { emit('pick-existing', hit); reset() }
|
||||
|
||||
function onCreate() {
|
||||
const name = query.value.trim()
|
||||
if (kind.value === 'character') {
|
||||
// Character requires a fandom — open the picker.
|
||||
function onCreate () {
|
||||
const name = parsedName.value
|
||||
const kind = parsedKind.value
|
||||
if (kind === 'character') {
|
||||
pendingNewName = name
|
||||
fandomDialog.value = true
|
||||
return
|
||||
}
|
||||
emit('pick-new', { name, kind: kind.value, fandom_id: null })
|
||||
// Pass explicit kind here; the backend accepts both shapes. Passing
|
||||
// it makes the parsed kind preview match the actual server outcome
|
||||
// for users who didn't use a prefix (general goes through cleanly).
|
||||
emit('pick-new', { name, kind, fandom_id: null })
|
||||
reset()
|
||||
}
|
||||
|
||||
function onFandomChosen(fandom) {
|
||||
function onFandomChosen (fandom) {
|
||||
fandomDialog.value = false
|
||||
emit('pick-new', { name: pendingNewName, kind: 'character', fandom_id: fandom.id })
|
||||
emit('pick-new', {
|
||||
name: pendingNewName, kind: 'character', fandom_id: fandom.id,
|
||||
})
|
||||
pendingNewName = null
|
||||
reset()
|
||||
}
|
||||
|
||||
function onEnter() {
|
||||
function onEnter () {
|
||||
if (highlight.value < hits.value.length) {
|
||||
onPick(hits.value[highlight.value])
|
||||
} else if (allowCreate.value) {
|
||||
@@ -120,7 +155,7 @@ function onEnter() {
|
||||
}
|
||||
}
|
||||
|
||||
function reset() { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
function reset () { query.value = ''; hits.value = []; highlight.value = 0 }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<template>
|
||||
<v-card class="fc-post-card" variant="outlined">
|
||||
<v-card
|
||||
class="fc-post-card"
|
||||
variant="outlined"
|
||||
tabindex="0"
|
||||
@click="onCardClick"
|
||||
@keydown.enter="onCardClick"
|
||||
>
|
||||
<div class="fc-post-card__head">
|
||||
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
|
||||
<RouterLink
|
||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||
class="fc-post-card__artist"
|
||||
@click.stop
|
||||
>{{ post.artist.name }}</RouterLink>
|
||||
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
|
||||
<v-spacer />
|
||||
@@ -13,72 +20,74 @@
|
||||
:href="post.post_url" target="_blank" rel="noopener"
|
||||
icon="mdi-open-in-new" size="x-small" variant="text"
|
||||
:aria-label="`open original post on ${post.source.platform}`"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 v-if="post.post_title" class="fc-post-card__title">{{ post.post_title }}</h3>
|
||||
<div class="fc-post-card__body">
|
||||
<div class="fc-post-card__media">
|
||||
<template v-if="post.thumbnails?.length">
|
||||
<div class="fc-post-card__hero">
|
||||
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
|
||||
</div>
|
||||
<div v-if="rail.length" class="fc-post-card__rail">
|
||||
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
|
||||
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
|
||||
</div>
|
||||
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
|
||||
+{{ moreCount }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<PostEmptyThumbs v-else />
|
||||
</div>
|
||||
|
||||
<div v-if="descriptionToShow" class="fc-post-card__desc">
|
||||
<span class="fc-post-card__desc-text">{{ descriptionToShow }}</span>
|
||||
<button
|
||||
v-if="post.description_truncated && !expanded"
|
||||
class="fc-post-card__more" type="button"
|
||||
@click="expand"
|
||||
>Show more</button>
|
||||
<button
|
||||
v-if="expanded"
|
||||
class="fc-post-card__more" type="button"
|
||||
@click="expanded = false"
|
||||
>Show less</button>
|
||||
</div>
|
||||
<div class="fc-post-card__text">
|
||||
<h3 v-if="post.post_title" class="fc-post-card__title">
|
||||
{{ post.post_title }}
|
||||
</h3>
|
||||
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
|
||||
Post {{ post.external_post_id }}
|
||||
</h3>
|
||||
|
||||
<div v-if="post.thumbnails.length" class="fc-post-card__thumbs">
|
||||
<RouterLink
|
||||
v-for="t in post.thumbnails"
|
||||
:key="t.image_id"
|
||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
||||
class="fc-post-card__thumb"
|
||||
>
|
||||
<v-img
|
||||
:src="t.thumbnail_url" cover :alt="`thumbnail`"
|
||||
width="96" height="96" class="fc-post-card__thumb-img"
|
||||
/>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="post.thumbnails_more > 0"
|
||||
:to="{ path: '/gallery', query: { post_id: post.id } }"
|
||||
class="fc-post-card__more-thumbs"
|
||||
>+{{ post.thumbnails_more }} more</RouterLink>
|
||||
</div>
|
||||
<p v-if="post.description_plain" class="fc-post-card__desc">
|
||||
{{ post.description_plain }}
|
||||
</p>
|
||||
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
|
||||
(no description)
|
||||
</p>
|
||||
|
||||
<div v-if="post.attachments.length" class="fc-post-card__attachments">
|
||||
<a
|
||||
v-for="att in post.attachments"
|
||||
:key="att.id"
|
||||
:href="att.download_url"
|
||||
download
|
||||
class="fc-post-card__att"
|
||||
>
|
||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||
<span class="fc-post-card__att-name">{{ att.original_filename }}</span>
|
||||
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
||||
</a>
|
||||
<div v-if="post.attachments?.length" class="fc-post-card__atts">
|
||||
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
|
||||
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePostsStore } from '../../stores/posts.js'
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { usePostModalStore } from '../../stores/postModal.js'
|
||||
import PostEmptyThumbs from './PostEmptyThumbs.vue'
|
||||
|
||||
const props = defineProps({
|
||||
post: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const postsStore = usePostsStore()
|
||||
const postModal = usePostModalStore()
|
||||
|
||||
const expanded = ref(false)
|
||||
const fullDescription = ref(null)
|
||||
const hero = computed(() => props.post.thumbnails?.[0])
|
||||
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
|
||||
const moreCount = computed(() => {
|
||||
const more = props.post.thumbnails_more || 0
|
||||
const railLen = rail.value.length
|
||||
// If feed returned more than (1 hero + 3 rail = 4), extra spills into "+N".
|
||||
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
|
||||
return more + extraShown
|
||||
})
|
||||
|
||||
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
|
||||
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
|
||||
@@ -92,25 +101,8 @@ const relativeDate = computed(() => {
|
||||
return new Date(sortDateIso.value).toLocaleDateString()
|
||||
})
|
||||
|
||||
const descriptionToShow = computed(() => {
|
||||
if (expanded.value && fullDescription.value) return fullDescription.value
|
||||
return props.post.description_plain
|
||||
})
|
||||
|
||||
async function expand() {
|
||||
if (!fullDescription.value) {
|
||||
const detail = await postsStore.getPostFull(props.post.id)
|
||||
fullDescription.value = detail?.description_full || props.post.description_plain
|
||||
}
|
||||
expanded.value = true
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n) return '0 B'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
function onCardClick () {
|
||||
postModal.open(props.post)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,13 +110,25 @@ function formatBytes(n) {
|
||||
.fc-post-card {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
container-type: inline-size;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.fc-post-card:hover {
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card:focus-visible {
|
||||
outline: 2px solid rgb(var(--v-theme-accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fc-post-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fc-post-card__artist {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
@@ -133,72 +137,118 @@ function formatBytes(n) {
|
||||
}
|
||||
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-card__date { white-space: nowrap; }
|
||||
.fc-post-card__title {
|
||||
font-size: 1.05rem;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
}
|
||||
.fc-post-card__desc {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.92rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.fc-post-card__more {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
font-size: inherit;
|
||||
}
|
||||
.fc-post-card__thumbs {
|
||||
|
||||
.fc-post-card__body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0.5rem 0;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.fc-post-card__thumb { line-height: 0; }
|
||||
.fc-post-card__thumb-img {
|
||||
border-radius: 4px;
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__body {
|
||||
flex-direction: row;
|
||||
gap: 24px;
|
||||
}
|
||||
.fc-post-card__media {
|
||||
flex: 0 0 50%;
|
||||
}
|
||||
.fc-post-card__text {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-post-card__hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.fc-post-card__more-thumbs {
|
||||
display: inline-flex;
|
||||
.fc-post-card__hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fc-post-card__rail {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.fc-post-card__rail-cell {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.fc-post-card__rail-cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.fc-post-card__rail-more {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 4px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fc-post-card__more-thumbs:hover {
|
||||
color: rgb(var(--v-theme-accent));
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
|
||||
.fc-post-card__title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-post-card__attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.6rem;
|
||||
.fc-post-card__title--missing {
|
||||
font-style: italic;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__att {
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__title {
|
||||
font-size: 20px;
|
||||
-webkit-line-clamp: 1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-post-card__desc {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
margin: 0 0 12px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-post-card__desc--missing {
|
||||
font-style: italic;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
@container (min-width: 800px) {
|
||||
.fc-post-card__desc {
|
||||
-webkit-line-clamp: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.fc-post-card__atts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 999px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-card__att:hover {
|
||||
color: rgb(var(--v-theme-accent));
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="fc-post-empty">
|
||||
<v-icon size="48" class="fc-post-empty__icon">mdi-image-off-outline</v-icon>
|
||||
<div class="fc-post-empty__text">No images attached to this post</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// No props — pure presentational placeholder.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 32px 16px;
|
||||
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 8px;
|
||||
background: rgba(20, 23, 26, 0.3);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
}
|
||||
.fc-post-empty__icon {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
opacity: 0.6;
|
||||
}
|
||||
.fc-post-empty__text {
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="fc-post-grid">
|
||||
<button
|
||||
v-for="(t, idx) in thumbnails"
|
||||
:key="t.image_id"
|
||||
type="button"
|
||||
class="fc-post-grid__cell"
|
||||
:aria-label="`Open image ${idx + 1} of ${thumbnails.length}`"
|
||||
@click="openImage(t.image_id, idx)"
|
||||
>
|
||||
<img
|
||||
:src="t.thumbnail_url"
|
||||
:alt="`thumbnail ${idx + 1}`"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useModalStore } from '../../stores/modal.js'
|
||||
|
||||
const props = defineProps({
|
||||
thumbnails: { type: Array, required: true }, // [{ image_id, thumbnail_url, ... }]
|
||||
})
|
||||
|
||||
const modal = useModalStore()
|
||||
|
||||
const imageIds = computed(() => props.thumbnails.map(t => t.image_id))
|
||||
|
||||
function openImage (id, idx) {
|
||||
modal.open(id, { postImageIds: imageIds.value, initialIndex: idx })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.fc-post-grid__cell {
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgb(var(--v-theme-background));
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.fc-post-grid__cell:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 0 2px rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-grid__cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="store.isOpen"
|
||||
max-width="1100"
|
||||
scrollable
|
||||
@update:model-value="(v) => { if (!v) close() }"
|
||||
>
|
||||
<v-card v-if="store.currentPost" class="fc-post-modal">
|
||||
<div class="fc-post-modal__head">
|
||||
<v-chip size="x-small" variant="tonal">{{ post.source.platform }}</v-chip>
|
||||
<RouterLink
|
||||
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
|
||||
class="fc-post-modal__artist"
|
||||
@click="close"
|
||||
>{{ post.artist.name }}</RouterLink>
|
||||
<span class="fc-post-modal__date" :title="absoluteDate">
|
||||
{{ relativeDate }}
|
||||
</span>
|
||||
<span v-if="post.thumbnails?.length" class="fc-post-modal__meta">
|
||||
· {{ post.thumbnails.length }} image{{ post.thumbnails.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<span v-if="post.attachments?.length" class="fc-post-modal__meta">
|
||||
· {{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="post.post_url"
|
||||
:href="post.post_url" target="_blank" rel="noopener"
|
||||
icon="mdi-open-in-new" size="small" variant="text"
|
||||
:aria-label="`open original post on ${post.source.platform}`"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-close" size="small" variant="text"
|
||||
aria-label="Close"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-card-text class="fc-post-modal__body">
|
||||
<section v-if="post.thumbnails?.length" class="fc-post-modal__sec">
|
||||
<PostImageGrid :thumbnails="post.thumbnails" />
|
||||
<div v-if="!store.detailLoaded" class="fc-post-modal__loading-hint">
|
||||
Loading full image list…
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="post.post_title" class="fc-post-modal__sec">
|
||||
<h2 class="fc-post-modal__title">{{ post.post_title }}</h2>
|
||||
</section>
|
||||
|
||||
<section v-if="descriptionHtml" class="fc-post-modal__sec">
|
||||
<div class="fc-post-modal__desc" v-html="descriptionHtml" />
|
||||
</section>
|
||||
|
||||
<section v-if="post.attachments?.length" class="fc-post-modal__sec">
|
||||
<h3 class="fc-post-modal__h3">Attachments</h3>
|
||||
<div class="fc-post-modal__atts">
|
||||
<a
|
||||
v-for="att in post.attachments" :key="att.id"
|
||||
:href="att.download_url" download
|
||||
class="fc-post-modal__att"
|
||||
>
|
||||
<v-icon size="small" class="fc-post-modal__att-icon">mdi-paperclip</v-icon>
|
||||
<span class="fc-post-modal__att-name">{{ att.original_filename }}</span>
|
||||
<span class="fc-post-modal__att-size">({{ formatBytes(att.size_bytes) }})</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { usePostModalStore } from '../../stores/postModal.js'
|
||||
import { sanitizeHtml } from '../../utils/htmlSanitize.js'
|
||||
import PostImageGrid from './PostImageGrid.vue'
|
||||
|
||||
const store = usePostModalStore()
|
||||
const post = computed(() => store.currentPost || {})
|
||||
|
||||
const sortDateIso = computed(() => post.value.post_date || post.value.downloaded_at)
|
||||
const absoluteDate = computed(() => sortDateIso.value
|
||||
? new Date(sortDateIso.value).toLocaleString()
|
||||
: '')
|
||||
const relativeDate = computed(() => {
|
||||
if (!sortDateIso.value) return ''
|
||||
const then = new Date(sortDateIso.value).getTime()
|
||||
const diff = (Date.now() - then) / 1000
|
||||
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
|
||||
return new Date(sortDateIso.value).toLocaleDateString()
|
||||
})
|
||||
|
||||
const descriptionHtml = computed(() => {
|
||||
// Detail endpoint returns description_full as plain text (the
|
||||
// existing service uses html_to_plain on the stored description).
|
||||
// Render the plain text in <p> wrappers; the sanitizer below is
|
||||
// defensive for the case where the backend ever switches to raw HTML.
|
||||
const raw = post.value.description_full || post.value.description_plain
|
||||
if (!raw) return ''
|
||||
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
|
||||
const esc = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return esc
|
||||
.split(/\n\s*\n/)
|
||||
.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('')
|
||||
})
|
||||
|
||||
function close () {
|
||||
store.close()
|
||||
}
|
||||
|
||||
function formatBytes (n) {
|
||||
if (!n) return '0 B'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-post-modal {
|
||||
background: rgb(var(--v-theme-surface));
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fc-post-modal__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
|
||||
}
|
||||
.fc-post-modal__artist {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fc-post-modal__artist:hover { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-modal__date { white-space: nowrap; }
|
||||
.fc-post-modal__meta { white-space: nowrap; }
|
||||
.fc-post-modal__body {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.fc-post-modal__sec {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.fc-post-modal__sec:last-child { margin-bottom: 0; }
|
||||
.fc-post-modal__title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-post-modal__h3 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 8px 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-post-modal__desc {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-post-modal__desc :deep(p) { margin: 0 0 12px 0; }
|
||||
.fc-post-modal__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
|
||||
.fc-post-modal__loading-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-post-modal__atts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-post-modal__att {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgb(var(--v-theme-on-surface-variant));
|
||||
border-radius: 999px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.fc-post-modal__att:hover {
|
||||
color: rgb(var(--v-theme-accent));
|
||||
border-color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-post-modal__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
</style>
|
||||
@@ -6,14 +6,31 @@ export const useModalStore = defineStore('modal', () => {
|
||||
const api = useApi()
|
||||
|
||||
const currentImageId = ref(null)
|
||||
const current = ref(null) // full image detail from API
|
||||
const current = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function open(id) {
|
||||
// Post-scoped cycle. When set, prev/next cycles within this array
|
||||
// (used by PostModal's PostImageGrid clicks). When null, prev/next
|
||||
// falls back to current.value.neighbors (the gallery-store-driven
|
||||
// /api/gallery/image/<id> neighbors).
|
||||
const postImageIds = ref(null)
|
||||
const postImageIndex = ref(0)
|
||||
|
||||
async function open (id, opts = {}) {
|
||||
currentImageId.value = id
|
||||
loading.value = true
|
||||
error.value = null
|
||||
// Update post-scoped state if caller passed it; otherwise clear so
|
||||
// the next open() from gallery context uses neighbors mode.
|
||||
if (opts.postImageIds != null) {
|
||||
postImageIds.value = opts.postImageIds
|
||||
postImageIndex.value = opts.postImageIds.indexOf(id)
|
||||
if (postImageIndex.value < 0) postImageIndex.value = 0
|
||||
} else if (opts.clearPostScope !== false && postImageIds.value != null) {
|
||||
postImageIds.value = null
|
||||
postImageIndex.value = 0
|
||||
}
|
||||
try {
|
||||
current.value = await api.get(`/api/gallery/image/${id}`)
|
||||
} catch (e) {
|
||||
@@ -24,41 +41,58 @@ export const useModalStore = defineStore('modal', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function close() {
|
||||
async function close () {
|
||||
currentImageId.value = null
|
||||
current.value = null
|
||||
error.value = null
|
||||
postImageIds.value = null
|
||||
postImageIndex.value = 0
|
||||
}
|
||||
|
||||
async function goPrev() {
|
||||
if (current.value && current.value.neighbors.prev_id) {
|
||||
async function goPrev () {
|
||||
if (postImageIds.value != null) {
|
||||
if (postImageIndex.value > 0) {
|
||||
const newIdx = postImageIndex.value - 1
|
||||
const newId = postImageIds.value[newIdx]
|
||||
postImageIndex.value = newIdx
|
||||
await open(newId, { postImageIds: postImageIds.value })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (current.value && current.value.neighbors?.prev_id) {
|
||||
await open(current.value.neighbors.prev_id)
|
||||
}
|
||||
}
|
||||
async function goNext() {
|
||||
if (current.value && current.value.neighbors.next_id) {
|
||||
|
||||
async function goNext () {
|
||||
if (postImageIds.value != null) {
|
||||
if (postImageIndex.value < postImageIds.value.length - 1) {
|
||||
const newIdx = postImageIndex.value + 1
|
||||
const newId = postImageIds.value[newIdx]
|
||||
postImageIndex.value = newIdx
|
||||
await open(newId, { postImageIds: postImageIds.value })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (current.value && current.value.neighbors?.next_id) {
|
||||
await open(current.value.neighbors.next_id)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadTags() {
|
||||
async function reloadTags () {
|
||||
if (!currentImageId.value) return
|
||||
const tags = await api.get(`/api/images/${currentImageId.value}/tags`)
|
||||
if (current.value) current.value.tags = tags
|
||||
}
|
||||
|
||||
async function removeTag(tagId) {
|
||||
async function removeTag (tagId) {
|
||||
if (!currentImageId.value) return
|
||||
// Optimistic UI: remove locally first, restore on error.
|
||||
const prev = current.value.tags
|
||||
current.value.tags = current.value.tags.filter(t => t.id !== tagId)
|
||||
try {
|
||||
// FC-2b: removal also records a per-image rejection (suggestions/dismiss
|
||||
// is the rejection-recording endpoint) so the allowlist maintenance
|
||||
// task won't re-apply this tag to this image.
|
||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
||||
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, {
|
||||
body: { tag_id: tagId }
|
||||
body: { tag_id: tagId },
|
||||
})
|
||||
} catch (e) {
|
||||
current.value.tags = prev
|
||||
@@ -67,25 +101,36 @@ export const useModalStore = defineStore('modal', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function addExistingTag(tagId) {
|
||||
async function addExistingTag (tagId) {
|
||||
if (!currentImageId.value) return
|
||||
await api.post(`/api/images/${currentImageId.value}/tags`, {
|
||||
body: { tag_id: tagId, source: 'manual' }
|
||||
body: { tag_id: tagId, source: 'manual' },
|
||||
})
|
||||
await reloadTags()
|
||||
}
|
||||
|
||||
async function createAndAdd({ name, kind, fandom_id = null }) {
|
||||
async function createAndAdd ({ name, kind, fandom_id = null }) {
|
||||
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
|
||||
await addExistingTag(tag.id)
|
||||
}
|
||||
|
||||
const isOpen = computed(() => currentImageId.value !== null)
|
||||
const canPrev = computed(() => current.value?.neighbors?.prev_id != null)
|
||||
const canNext = computed(() => current.value?.neighbors?.next_id != null)
|
||||
const canPrev = computed(() => {
|
||||
if (postImageIds.value != null) return postImageIndex.value > 0
|
||||
return current.value?.neighbors?.prev_id != null
|
||||
})
|
||||
const canNext = computed(() => {
|
||||
if (postImageIds.value != null) {
|
||||
return postImageIndex.value < (postImageIds.value.length - 1)
|
||||
}
|
||||
return current.value?.neighbors?.next_id != null
|
||||
})
|
||||
|
||||
return {
|
||||
currentImageId, current, loading, error, isOpen, canPrev, canNext,
|
||||
open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd
|
||||
currentImageId, current, loading, error,
|
||||
postImageIds, postImageIndex,
|
||||
isOpen, canPrev, canNext,
|
||||
open, close, goPrev, goNext,
|
||||
reloadTags, removeTag, addExistingTag, createAndAdd,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { usePostsStore } from './posts.js'
|
||||
|
||||
export const usePostModalStore = defineStore('postModal', () => {
|
||||
const postsStore = usePostsStore()
|
||||
|
||||
// Feed-shape on open; upgraded to detail shape (full description +
|
||||
// uncapped thumbnails) once getPostFull resolves.
|
||||
const currentPost = ref(null)
|
||||
const detailLoaded = ref(false)
|
||||
const error = ref(null)
|
||||
const isOpen = computed(() => currentPost.value !== null)
|
||||
|
||||
async function open (post) {
|
||||
currentPost.value = post
|
||||
detailLoaded.value = false
|
||||
error.value = null
|
||||
try {
|
||||
const detail = await postsStore.getPostFull(post.id)
|
||||
if (currentPost.value?.id === post.id) {
|
||||
currentPost.value = detail
|
||||
detailLoaded.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
// Keep feed-shape data; PostModal can still render title + truncated
|
||||
// description + the up-to-6 feed thumbnails. Just surface the error.
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function close () {
|
||||
currentPost.value = null
|
||||
detailLoaded.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return { currentPost, detailLoaded, error, isOpen, open, close }
|
||||
})
|
||||
@@ -2,24 +2,22 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
// `artist` retired in FC-2d-vii-c (provenance is its own axis), `meta` +
|
||||
// `rating` retired by operator 2026-05-26 (alembic 0023 drops them from
|
||||
// the enum). KIND_COLOR keeps `archive` + `post` so any legacy
|
||||
// system-managed tag still renders with a neutral color.
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'general', label: 'General', icon: 'mdi-tag' },
|
||||
{ value: 'artist', label: 'Artist', icon: 'mdi-palette' },
|
||||
{ value: 'character', label: 'Character', icon: 'mdi-account-circle' },
|
||||
{ value: 'fandom', label: 'Fandom', icon: 'mdi-book-open-page-variant' },
|
||||
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' },
|
||||
{ value: 'meta', label: 'Meta', icon: 'mdi-cog-outline' },
|
||||
{ value: 'rating', label: 'Rating', icon: 'mdi-shield-check-outline' }
|
||||
{ value: 'series', label: 'Series', icon: 'mdi-bookshelf' }
|
||||
]
|
||||
|
||||
const KIND_COLOR = {
|
||||
artist: 'accent',
|
||||
character: 'info',
|
||||
fandom: 'secondary',
|
||||
series: 'warning',
|
||||
general: 'on-surface',
|
||||
meta: 'on-surface',
|
||||
rating: 'on-surface',
|
||||
archive: 'on-surface',
|
||||
post: 'on-surface'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Whitelist-based HTML sanitizer for rendering third-party post
|
||||
// descriptions (e.g. Patreon) via v-html. Strips script/style/iframe
|
||||
// + event handlers + dangerous hrefs. Tag whitelist covers what
|
||||
// Patreon, SubscribeStar, and similar platforms ship in normal posts.
|
||||
//
|
||||
// Not a substitute for server-side sanitization in higher-stakes
|
||||
// contexts. This is for FC's single-operator homelab posture where
|
||||
// the content source is the operator's own subscriptions.
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li',
|
||||
'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup', 'u', 'ul',
|
||||
])
|
||||
|
||||
const ALLOWED_ATTRS = {
|
||||
a: new Set(['href', 'title', 'rel', 'target']),
|
||||
img: new Set(['src', 'alt', 'title', 'width', 'height']),
|
||||
// any tag → these always allowed
|
||||
'*': new Set(['class']),
|
||||
}
|
||||
|
||||
const SAFE_URL_RE = /^(https?:|mailto:|#|\/)/i
|
||||
|
||||
export function sanitizeHtml (html) {
|
||||
if (typeof html !== 'string' || !html) return ''
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
_scrubNode(doc.body)
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
function _scrubNode (node) {
|
||||
const children = Array.from(node.children)
|
||||
for (const child of children) {
|
||||
const tag = child.tagName.toLowerCase()
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
// Strip the tag but keep its text content as a fallback.
|
||||
const text = document.createTextNode(child.textContent || '')
|
||||
child.replaceWith(text)
|
||||
continue
|
||||
}
|
||||
const allowed = new Set([
|
||||
...(ALLOWED_ATTRS[tag] || []),
|
||||
...(ALLOWED_ATTRS['*'] || []),
|
||||
])
|
||||
for (const attr of Array.from(child.attributes)) {
|
||||
const name = attr.name.toLowerCase()
|
||||
if (name.startsWith('on') || !allowed.has(name)) {
|
||||
child.removeAttribute(attr.name)
|
||||
continue
|
||||
}
|
||||
if ((name === 'href' || name === 'src') && !SAFE_URL_RE.test(attr.value)) {
|
||||
child.removeAttribute(attr.name)
|
||||
}
|
||||
}
|
||||
if (tag === 'a' && child.getAttribute('target') === '_blank') {
|
||||
child.setAttribute('rel', 'noopener noreferrer')
|
||||
}
|
||||
_scrubNode(child)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</div>
|
||||
|
||||
<v-container v-else-if="store.notFound" class="py-6">
|
||||
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
|
||||
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="store.error && !store.overview" class="py-6">
|
||||
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
|
||||
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
||||
</v-container>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
:post-count="store.postCount"
|
||||
:last-added="store.lastAdded"
|
||||
/>
|
||||
<v-container fluid class="py-4">
|
||||
<v-container fluid class="pt-2 pb-4">
|
||||
<v-window v-model="tab">
|
||||
<v-window-item value="posts">
|
||||
<ArtistPostsTab
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<div class="fc-artists__controls">
|
||||
<v-text-field
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<ExtensionKeyBar />
|
||||
|
||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<FilterPills v-model="pill" />
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<Teleport to="#fc-nav-actions">
|
||||
<v-btn
|
||||
:color="sel.isSelectMode ? 'accent' : undefined"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container class="py-8">
|
||||
<v-container class="pt-3 pb-8">
|
||||
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
||||
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
||||
This surface is a placeholder. It will be implemented in
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container class="py-6" max-width="900">
|
||||
<v-container class="pt-2 pb-6" max-width="900">
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-series__head">
|
||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<Teleport to="#fc-nav-actions">
|
||||
<v-btn
|
||||
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-subs__bar">
|
||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||
Add subscription
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<div class="fc-tags__controls">
|
||||
<v-text-field
|
||||
@@ -99,10 +99,11 @@ import MergeConfirmDialog from '../components/discovery/MergeConfirmDialog.vue'
|
||||
import DestructiveConfirmModal from '../components/modal/DestructiveConfirmModal.vue'
|
||||
|
||||
// Must stay a subset of the backend TagKind enum (character, fandom,
|
||||
// general, series, archive, post, meta, rating). 'fandom' is this
|
||||
// model's copyright/franchise concept (characters link via fandom_id).
|
||||
// 'artist' retired in FC-2d-vii-c — artists are the Artist row, not a tag.
|
||||
const KINDS = ['character', 'fandom', 'general', 'series', 'meta']
|
||||
// general, series, archive, post). 'fandom' is this model's
|
||||
// copyright/franchise concept (characters link via fandom_id).
|
||||
// 'artist' retired in FC-2d-vii-c — artists are the Artist row, not
|
||||
// a tag. 'meta' + 'rating' retired by operator 2026-05-26 (alembic 0023).
|
||||
const KINDS = ['character', 'fandom', 'general', 'series']
|
||||
const store = useTagDirectoryStore()
|
||||
const router = useRouter()
|
||||
|
||||
|
||||
@@ -117,3 +117,50 @@ async def test_detail_404_for_unknown(client):
|
||||
assert resp.status_code == 404
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_returns_uncapped_thumbnails(client, db):
|
||||
"""Feed query caps thumbnails at 6 for previews; detail endpoint
|
||||
returns the full list so PostModal can render the masonry grid."""
|
||||
from backend.app.models import ImageRecord
|
||||
|
||||
a = Artist(name="yuki-api", slug="yuki-api")
|
||||
db.add(a)
|
||||
await db.flush()
|
||||
s = Source(
|
||||
artist_id=a.id, platform="patreon",
|
||||
url="https://patreon.com/cw/yuki-api", enabled=True,
|
||||
)
|
||||
db.add(s)
|
||||
await db.flush()
|
||||
p = Post(
|
||||
source_id=s.id, external_post_id="DETAIL10",
|
||||
post_title="big post", description="<p>body</p>",
|
||||
)
|
||||
db.add(p)
|
||||
await db.flush()
|
||||
# Seed 10 ImageRecord rows linked to this post via primary_post_id.
|
||||
for i in range(10):
|
||||
sha = f"y{i:x}".ljust(64, "0")[:64]
|
||||
rec = ImageRecord(
|
||||
path=f"/images/test-yuki-{i}.jpg",
|
||||
sha256=sha,
|
||||
size_bytes=1,
|
||||
mime="image/jpeg",
|
||||
width=64,
|
||||
height=64,
|
||||
origin="downloaded",
|
||||
integrity_status="unknown",
|
||||
primary_post_id=p.id,
|
||||
artist_id=a.id,
|
||||
)
|
||||
db.add(rec)
|
||||
await db.commit()
|
||||
|
||||
resp = await client.get(f"/api/posts/{p.id}")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
# Detail returns ALL 10 thumbnails (feed would return 6 + thumbnails_more).
|
||||
assert len(body["thumbnails"]) == 10
|
||||
assert body["description_full"] == "body"
|
||||
|
||||
+48
-2
@@ -55,6 +55,52 @@ async def test_create_character_with_bad_fandom_id(client):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_missing_required(client):
|
||||
resp = await client.post("/api/tags", json={"name": "Bob"})
|
||||
async def test_create_tag_missing_name_400(client):
|
||||
"""name is still required; only `kind` became optional."""
|
||||
resp = await client.post("/api/tags", json={})
|
||||
assert resp.status_code == 400
|
||||
resp = await client.post("/api/tags", json={"kind": "artist"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_name_only_defaults_to_general(client):
|
||||
"""IR-style: name without kind and without `kind:` prefix → general."""
|
||||
resp = await client.post("/api/tags", json={"name": "sunset"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "sunset"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag_with_kind_prefix(client):
|
||||
"""IR-style: `name="character:Saber"` without explicit kind → parsed as character."""
|
||||
resp = await client.post("/api/tags", json={"name": "character:Saber"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "Saber"
|
||||
assert body["kind"] == "character"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_kind_overrides_prefix_parsing(client):
|
||||
"""If caller passes explicit kind, don't re-parse the name —
|
||||
colon and prefix stay literal."""
|
||||
resp = await client.post(
|
||||
"/api/tags", json={"name": "character:Saber", "kind": "general"}
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "character:Saber"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_prefix_kept_literal(client):
|
||||
"""`http:example` — `http` not in KNOWN_KINDS → kind=general, literal name."""
|
||||
resp = await client.post("/api/tags", json={"name": "http://example.com"})
|
||||
assert resp.status_code == 201
|
||||
body = await resp.get_json()
|
||||
assert body["name"] == "http://example.com"
|
||||
assert body["kind"] == "general"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""CORS preflight + response headers for moz-extension:// + chrome-extension://.
|
||||
|
||||
Operator-flagged 2026-05-26: extension's first 'Test connection' tap
|
||||
returned `NetworkError` because /api/credentials had no OPTIONS handler
|
||||
and no Access-Control-Allow-Origin response header. Browser preflight
|
||||
failed → fetch blocked.
|
||||
|
||||
These tests pin the contract: any request from a moz-extension:// or
|
||||
chrome-extension:// origin gets the right ACL headers. Plain browser
|
||||
requests (no Origin header, or a regular https:// Origin) get nothing
|
||||
— we don't want to open CORS up generally.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app import create_app
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
app = create_app()
|
||||
async with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extension_preflight_returns_204_with_acl_headers(client):
|
||||
resp = await client.options(
|
||||
"/api/credentials",
|
||||
headers={
|
||||
"Origin": "moz-extension://abcd1234-uuid-fake",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
"Access-Control-Request-Headers": "X-Extension-Key",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
|
||||
assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"]
|
||||
assert "X-Extension-Key" in resp.headers["Access-Control-Allow-Headers"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extension_get_carries_acl_headers(client):
|
||||
# The actual response (post-preflight) also needs ACL headers — the
|
||||
# browser checks them again before exposing the response body.
|
||||
resp = await client.get(
|
||||
"/api/credentials",
|
||||
headers={"Origin": "moz-extension://abcd1234-uuid-fake"},
|
||||
)
|
||||
# 200 with empty list (no creds seeded) — what matters here is the
|
||||
# CORS header is present.
|
||||
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chrome_extension_origin_also_allowed(client):
|
||||
resp = await client.options(
|
||||
"/api/credentials",
|
||||
headers={
|
||||
"Origin": "chrome-extension://abcd1234-uuid-fake",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
assert resp.headers["Access-Control-Allow-Origin"] == "chrome-extension://abcd1234-uuid-fake"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_browser_request_gets_no_cors_headers(client):
|
||||
# A regular browser tab (https://example.com / file:// / no Origin
|
||||
# at all) should NOT get any Access-Control-Allow-* — the extension
|
||||
# whitelist is intentionally narrow.
|
||||
resp = await client.get(
|
||||
"/api/credentials",
|
||||
headers={"Origin": "https://evil.example.com"},
|
||||
)
|
||||
assert "Access-Control-Allow-Origin" not in resp.headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_origin_header_unaffected(client):
|
||||
# Same-origin requests (no Origin header) — unaffected.
|
||||
resp = await client.get("/api/credentials")
|
||||
assert "Access-Control-Allow-Origin" not in resp.headers
|
||||
@@ -25,6 +25,10 @@ def test_tag_has_kind_and_fandom_id():
|
||||
|
||||
|
||||
def test_tag_kind_enum_values():
|
||||
# Current TagKind enum after alembic 0023 dropped meta + rating
|
||||
# (operator-retired 2026-05-26). `artist` is still in the enum
|
||||
# for backward-compat with historical rows, though new artist
|
||||
# tags don't get created (Artist row is canonical per FC-2d-vii-c).
|
||||
expected = {
|
||||
"artist",
|
||||
"character",
|
||||
@@ -33,8 +37,6 @@ def test_tag_kind_enum_values():
|
||||
"series",
|
||||
"archive",
|
||||
"post",
|
||||
"meta",
|
||||
"rating",
|
||||
}
|
||||
assert {k.value for k in TagKind} == expected
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""parse_kind_prefix — IR-style `kind:name` shortcut at the input boundary."""
|
||||
|
||||
from backend.app.utils.tag_prefix import KNOWN_KINDS, parse_kind_prefix
|
||||
|
||||
|
||||
def test_recognized_kinds_match_user_input_set():
|
||||
# Excluded: archive + post (system-managed), general (default for
|
||||
# un-prefixed input), artist (retired in FC-2d-vii-c — first-class
|
||||
# entities now), meta + rating (retired as user-typeable per
|
||||
# operator 2026-05-26).
|
||||
assert KNOWN_KINDS == frozenset({
|
||||
"character", "fandom", "series",
|
||||
})
|
||||
|
||||
|
||||
def test_character_prefix_parsed():
|
||||
assert parse_kind_prefix("character:Saber") == ("character", "Saber")
|
||||
|
||||
|
||||
def test_case_insensitive_prefix():
|
||||
assert parse_kind_prefix("Character:Saber") == ("character", "Saber")
|
||||
assert parse_kind_prefix("CHARACTER:Saber") == ("character", "Saber")
|
||||
|
||||
|
||||
def test_no_prefix_returns_none_kind():
|
||||
assert parse_kind_prefix("sunset") == (None, "sunset")
|
||||
|
||||
|
||||
def test_unknown_prefix_kept_as_literal():
|
||||
# 'http' is not a known kind — preserve the literal text.
|
||||
assert parse_kind_prefix("http://example.com") == (None, "http://example.com")
|
||||
|
||||
|
||||
def test_retired_prefixes_kept_as_literal():
|
||||
# `artist:`, `meta:`, `rating:` are no longer recognized — they
|
||||
# parse as literal text so the operator's input is preserved (and
|
||||
# serves as a nudge to use the appropriate dedicated UI instead).
|
||||
assert parse_kind_prefix("artist:Eric") == (None, "artist:Eric")
|
||||
assert parse_kind_prefix("meta:wide") == (None, "meta:wide")
|
||||
assert parse_kind_prefix("rating:safe") == (None, "rating:safe")
|
||||
|
||||
|
||||
def test_whitespace_stripped():
|
||||
assert parse_kind_prefix("series: Bleach ") == ("series", "Bleach")
|
||||
assert parse_kind_prefix(" sunset ") == (None, "sunset")
|
||||
|
||||
|
||||
def test_empty_string():
|
||||
assert parse_kind_prefix("") == (None, "")
|
||||
|
||||
|
||||
def test_just_colon():
|
||||
# Empty prefix → "" not in KNOWN_KINDS → falls through to (None, "...")
|
||||
assert parse_kind_prefix(":foo") == (None, ":foo")
|
||||
Reference in New Issue
Block a user