feat(gallery): OR/exclude tag filtering — light chips + advanced builder (#6b/c/d)
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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user