diff --git a/alembic/versions/0023_drop_meta_rating_tag_kinds.py b/alembic/versions/0023_drop_meta_rating_tag_kinds.py new file mode 100644 index 0000000..fc65ea1 --- /dev/null +++ b/alembic/versions/0023_drop_meta_rating_tag_kinds.py @@ -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')", + ) diff --git a/backend/app/api/tags.py b/backend/app/api/tags.py index 64f16bf..e18d94d 100644 --- a/backend/app/api/tags.py +++ b/backend/app/api/tags.py @@ -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: diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py index c99fc06..a74575b 100644 --- a/backend/app/models/tag.py +++ b/backend/app/models/tag.py @@ -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( diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index d137188..974427e 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -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: diff --git a/backend/app/utils/tag_prefix.py b/backend/app/utils/tag_prefix.py new file mode 100644 index 0000000..3dd15b2 --- /dev/null +++ b/backend/app/utils/tag_prefix.py @@ -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() diff --git a/extension/manifest.json b/extension/manifest.json index 217f0a0..850a6fa 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -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", diff --git a/extension/package.json b/extension/package.json index db94a39..f4c7db4 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "fabledcurator-extension", - "version": "1.0.3", + "version": "1.0.4", "private": true, "description": "Firefox extension for FabledCurator", "scripts": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5cfbdda..38a3ccf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,6 +4,7 @@ + @@ -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() diff --git a/frontend/src/components/artist/ArtistPostsTab.vue b/frontend/src/components/artist/ArtistPostsTab.vue index b575531..d628f62 100644 --- a/frontend/src/components/artist/ArtistPostsTab.vue +++ b/frontend/src/components/artist/ArtistPostsTab.vue @@ -67,7 +67,7 @@ onUnmounted(() => { diff --git a/frontend/src/components/posts/PostEmptyThumbs.vue b/frontend/src/components/posts/PostEmptyThumbs.vue new file mode 100644 index 0000000..9927424 --- /dev/null +++ b/frontend/src/components/posts/PostEmptyThumbs.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frontend/src/components/posts/PostImageGrid.vue b/frontend/src/components/posts/PostImageGrid.vue new file mode 100644 index 0000000..c77620f --- /dev/null +++ b/frontend/src/components/posts/PostImageGrid.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/frontend/src/components/posts/PostModal.vue b/frontend/src/components/posts/PostModal.vue new file mode 100644 index 0000000..3c25ccb --- /dev/null +++ b/frontend/src/components/posts/PostModal.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 1998c97..0f8665f 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -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/ 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, } }) diff --git a/frontend/src/stores/postModal.js b/frontend/src/stores/postModal.js new file mode 100644 index 0000000..64fd1c9 --- /dev/null +++ b/frontend/src/stores/postModal.js @@ -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 } +}) diff --git a/frontend/src/stores/tags.js b/frontend/src/stores/tags.js index fbb871a..5bf47d1 100644 --- a/frontend/src/stores/tags.js +++ b/frontend/src/stores/tags.js @@ -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' } diff --git a/frontend/src/utils/htmlSanitize.js b/frontend/src/utils/htmlSanitize.js new file mode 100644 index 0000000..7978fca --- /dev/null +++ b/frontend/src/utils/htmlSanitize.js @@ -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) + } +} diff --git a/frontend/src/views/TagsView.vue b/frontend/src/views/TagsView.vue index d973b20..b79b6f8 100644 --- a/frontend/src/views/TagsView.vue +++ b/frontend/src/views/TagsView.vue @@ -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() diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index a820ad8..997bfa7 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -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="

body

", + ) + 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" diff --git a/tests/test_api_tags.py b/tests/test_api_tags.py index 39b45a9..3322556 100644 --- a/tests/test_api_tags.py +++ b/tests/test_api_tags.py @@ -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" diff --git a/tests/test_migration_0002.py b/tests/test_migration_0002.py index 6b8d13c..1786731 100644 --- a/tests/test_migration_0002.py +++ b/tests/test_migration_0002.py @@ -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 diff --git a/tests/test_tag_prefix.py b/tests/test_tag_prefix.py new file mode 100644 index 0000000..192dca5 --- /dev/null +++ b/tests/test_tag_prefix.py @@ -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")