feat(gallery): OR/exclude tag filtering — light chips + advanced builder (#6b/c/d)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m15s

Cluster A, milestone #97. Completes the frontend of the structured tag filter
(backend landed in 23fab98).

#6b store: gallery.filter gains tag_or (OR-groups) + tag_exclude; one model,
serialized via tag_or (repeated key) + tag_not across activeFilterParam/
filterToQuery/applyFilterFromQuery/cloneFilter/loadSimilar; _resolveLabels
resolves names for every referenced id. useApi now appends array param values
as a repeated key (tag_or=a&tag_or=b).

#6c light editor (GalleryFilterBar): autocomplete pick → include chip; click a
chip body to flip include↔exclude (exclude = red minus); ✕ removes. An
"N OR-groups" chip + an Advanced button open the builder.

#6d advanced editor (TagQueryBuilder.vue + common/TagPicker.vue): AND-of-OR
group builder + NOT list. Unifies includes+OR-groups into one groups view,
splits back to tag_ids/tag_or on Apply so the URL stays compact. Writes the
same model the light chips edit.

Store serialization round-trip tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
2026-06-23 01:19:53 -04:00
parent 23fab983a0
commit 73dd301dbb
6 changed files with 500 additions and 3 deletions
+44 -2
View File
@@ -24,6 +24,10 @@ export const useGalleryStore = defineStore('gallery', () => {
const filter = ref({
tag_ids: [], artist_id: null, media_type: null,
sort: 'newest', post_id: null,
// #6 structured tag filter (AND-of-OR + exclude). tag_ids are the AND
// "include" singletons (light editor + back-compat); tag_or is a list of
// OR-groups (each group ORs, groups AND); tag_exclude is the NOT set.
tag_or: [], tag_exclude: [],
// Phase-2 faceted refine params.
platform: null, untagged: false, no_artist: false,
date_from: null, date_to: null,
@@ -102,6 +106,9 @@ export const useGalleryStore = defineStore('gallery', () => {
const f = filter.value
const params = { similar_to: f.similar_to, limit: 100 }
if (f.tag_ids.length) params.tag_id = f.tag_ids.join(',')
const orParam = _orGroupsParam(f.tag_or)
if (orParam.length) params.tag_or = orParam
if (f.tag_exclude.length) params.tag_not = f.tag_exclude.join(',')
if (f.artist_id) params.artist_id = f.artist_id
if (f.media_type) params.media = f.media_type
if (f.platform) params.platform = f.platform
@@ -142,6 +149,9 @@ export const useGalleryStore = defineStore('gallery', () => {
if (filter.value.post_id) return { post_id: filter.value.post_id }
const p = {}
if (filter.value.tag_ids.length) p.tag_id = filter.value.tag_ids.join(',')
const orParam = _orGroupsParam(filter.value.tag_or)
if (orParam.length) p.tag_or = orParam // array → repeated tag_or= key
if (filter.value.tag_exclude.length) p.tag_not = filter.value.tag_exclude.join(',')
if (filter.value.artist_id) p.artist_id = filter.value.artist_id
if (filter.value.media_type) p.media = filter.value.media_type
if (filter.value.sort && filter.value.sort !== 'newest') p.sort = filter.value.sort
@@ -177,6 +187,8 @@ export const useGalleryStore = defineStore('gallery', () => {
async function applyFilterFromQuery(q) {
filter.value = {
tag_ids: _parseIds(q.tag_id),
tag_or: _parseGroups(q.tag_or),
tag_exclude: _parseIds(q.tag_not),
artist_id: _toId(q.artist_id),
media_type: ['image', 'video'].includes(q.media) ? q.media : null,
sort: q.sort === 'oldest' ? 'oldest' : 'newest',
@@ -211,6 +223,14 @@ export const useGalleryStore = defineStore('gallery', () => {
if (!raw) return []
return String(raw).split(',').map(Number).filter((n) => Number.isInteger(n) && n > 0)
}
// tag_or arrives from the URL as a string (one group) or string[] (many);
// each value is a comma-separated OR-group. Normalize to ids-of-ids, dropping
// empties so a stray `tag_or=` can't become a match-nothing group.
function _parseGroups(raw) {
if (raw == null) return []
const values = Array.isArray(raw) ? raw : [raw]
return values.map(_parseIds).filter((g) => g.length)
}
// Pre-seed a label so a freshly-picked chip shows its name without a
// round-trip; the bar calls these before pushing the new URL.
@@ -218,7 +238,14 @@ export const useGalleryStore = defineStore('gallery', () => {
function noteArtistLabel(name) { artistLabel.value = name || null }
async function _resolveLabels() {
for (const id of filter.value.tag_ids) {
// Every tag id referenced anywhere in the structured filter needs a chip
// label — includes, every OR-group member, and excludes.
const allTagIds = new Set([
...filter.value.tag_ids,
...filter.value.tag_or.flat(),
...filter.value.tag_exclude,
])
for (const id of allTagIds) {
if (tagLabels.value[id]) continue
try {
const t = await api.get(`/api/tags/${id}`)
@@ -252,7 +279,10 @@ export const useGalleryStore = defineStore('gallery', () => {
// the exclusive post-detail view.
export function cloneFilter(f) {
return {
tag_ids: [...f.tag_ids], artist_id: f.artist_id, media_type: f.media_type,
tag_ids: [...f.tag_ids],
tag_or: (f.tag_or || []).map((g) => [...g]), // deep-clone the groups
tag_exclude: [...(f.tag_exclude || [])],
artist_id: f.artist_id, media_type: f.media_type,
sort: f.sort, platform: f.platform, untagged: f.untagged,
no_artist: f.no_artist, date_from: f.date_from, date_to: f.date_to,
similar_to: f.similar_to,
@@ -262,6 +292,9 @@ export function cloneFilter(f) {
export function filterToQuery(f) {
const q = {}
if (f.tag_ids?.length) q.tag_id = f.tag_ids.join(',')
const orParam = _orGroupsParam(f.tag_or)
if (orParam.length) q.tag_or = orParam // array → repeated query key
if (f.tag_exclude?.length) q.tag_not = f.tag_exclude.join(',')
if (f.artist_id) q.artist_id = String(f.artist_id)
if (f.media_type) q.media = f.media_type
if (f.sort && f.sort !== 'newest') q.sort = f.sort
@@ -274,6 +307,15 @@ export function filterToQuery(f) {
return q
}
// Serialize OR-groups to repeated-param form: [[1,2],[3]] → ['1,2','3'],
// dropping empty groups. Shared by the store's activeFilterParam and the
// router-facing filterToQuery so both write tag_or the same way.
function _orGroupsParam(groups) {
return (groups || [])
.map((g) => (g || []).join(','))
.filter((s) => s.length)
}
function mergeGroups(existing, incoming) {
// Merge sequential groups with the same (year, month) instead of duplicating.
const merged = [...existing]