/** * Shared types + URL helpers for the multi-criteria filter bar. */ export type FilterTabKey = "actresses" | "studios" | "series" | "genres" | "collections" | "tags" | "categories"; export type FilterMode = "or" | "and"; export type WatchedState = "all" | "watched" | "unwatched"; export type RatedState = "all" | "rated" | "unrated"; export type PresenceState = "all" | "has" | "missing"; export interface FilterStatus { watched: WatchedState; rated: RatedState; collection: PresenceState; tags: PresenceState; video: PresenceState; } export type MarkState = "all" | "vip" | "favorite" | "owned" | "unmarked"; export type MarkOption = "vip" | "favorite" | "owned" | "unmarked"; export const MARK_OPTIONS: MarkOption[] = ["vip", "favorite", "owned", "unmarked"]; export interface FilterCriteria { ids: Record; mode: Record; status: FilterStatus; // Empty array == "ALL" (no mark filter). Otherwise the OR of the listed marks. marks: MarkOption[]; } export const EMPTY_STATUS: FilterStatus = { watched: "all", rated: "all", collection: "all", tags: "all", video: "all", }; export const EMPTY_CRITERIA: FilterCriteria = { ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] }, mode: { actresses: "or", studios: "or", series: "or", genres: "or", collections: "or", tags: "or", categories: "or" }, status: EMPTY_STATUS, marks: [], }; export type StatusAxisKey = keyof FilterStatus; const TAB_KEYS: FilterTabKey[] = ["actresses", "studios", "series", "genres", "collections", "tags", "categories"]; // Studios and series can't AND (a cover has at most one). const SUPPORTS_AND: Record = { actresses: true, studios: false, series: false, genres: true, collections: true, tags: true, categories: true, }; export function tabSupportsAnd(k: FilterTabKey): boolean { return SUPPORTS_AND[k]; } export function parseFilterCriteria(sp: Record): FilterCriteria { const c: FilterCriteria = { ids: { actresses: [], studios: [], series: [], genres: [], collections: [], tags: [], categories: [] }, mode: { actresses: "or", studios: "or", series: "or", genres: "or", collections: "or", tags: "or", categories: "or" }, status: { ...EMPTY_STATUS }, marks: [], }; for (const k of TAB_KEYS) { const raw = sp[k]; if (typeof raw === "string" && raw.trim()) { c.ids[k] = raw.split(",").map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0); } const m = sp[`${k}_mode`]; if (typeof m === "string" && m.toLowerCase() === "and" && SUPPORTS_AND[k]) c.mode[k] = "and"; } // Watched axis. Accept both new (?watched=watched|unwatched) and legacy (?watched=1, ?unwatched=1). const watched = sp.watched; if (typeof watched === "string") { if (watched === "watched" || watched === "1") c.status.watched = "watched"; else if (watched === "unwatched") c.status.watched = "unwatched"; } if (sp.unwatched === "1") c.status.watched = "unwatched"; const rated = sp.rated; if (typeof rated === "string") { if (rated === "rated" || rated === "1") c.status.rated = "rated"; else if (rated === "unrated") c.status.rated = "unrated"; } if (sp.unrated === "1") c.status.rated = "unrated"; const collection = sp.collection; if (typeof collection === "string") { if (collection === "has") c.status.collection = "has"; else if (collection === "missing") c.status.collection = "missing"; } if (sp.uncollected === "1") c.status.collection = "missing"; const tagsStatus = sp.tags_status; if (typeof tagsStatus === "string") { if (tagsStatus === "has") c.status.tags = "has"; else if (tagsStatus === "missing") c.status.tags = "missing"; } if (sp.untagged === "1") c.status.tags = "missing"; const video = sp.video; if (typeof video === "string") { if (video === "has") c.status.video = "has"; else if (video === "missing") c.status.video = "missing"; } // marks: comma-separated list. Legacy `?mark=vip` (singular) is also honored // so old bookmarked URLs keep working. const marksRaw = (typeof sp.marks === "string" && sp.marks) || (typeof sp.mark === "string" ? sp.mark : ""); if (marksRaw) { const wanted = marksRaw.split(",").map((s) => s.trim()).filter(Boolean); const valid = new Set(MARK_OPTIONS); const out: MarkOption[] = []; const seen = new Set(); for (const w of wanted) { if (valid.has(w as MarkOption) && !seen.has(w as MarkOption)) { out.push(w as MarkOption); seen.add(w as MarkOption); } } c.marks = out; } return c; } /** Apply a `FilterCriteria` onto a `URLSearchParams`. Existing keys are overwritten. */ export function writeFilterCriteria(sp: URLSearchParams, c: FilterCriteria): void { for (const k of TAB_KEYS) { if (c.ids[k].length === 0) sp.delete(k); else sp.set(k, c.ids[k].join(",")); if (c.mode[k] === "and" && SUPPORTS_AND[k]) sp.set(`${k}_mode`, "and"); else sp.delete(`${k}_mode`); } // Always write canonical status keys; clear the legacy ones so they don't fight us. if (c.status.watched === "all") sp.delete("watched"); else sp.set("watched", c.status.watched); if (c.status.rated === "all") sp.delete("rated"); else sp.set("rated", c.status.rated); if (c.status.collection === "all") sp.delete("collection"); else sp.set("collection", c.status.collection); if (c.status.tags === "all") sp.delete("tags_status"); else sp.set("tags_status", c.status.tags); if (c.status.video === "all") sp.delete("video"); else sp.set("video", c.status.video); sp.delete("unwatched"); sp.delete("unrated"); sp.delete("uncollected"); sp.delete("untagged"); // Single canonical key: `marks=vip,favorite`. Drop legacy `mark` if present. sp.delete("mark"); if (c.marks.length === 0) sp.delete("marks"); else sp.set("marks", c.marks.join(",")); } export function totalSelected(c: FilterCriteria): number { let n = 0; for (const k of TAB_KEYS) n += c.ids[k].length; return n; } export function totalStatusActive(c: FilterCriteria): number { let n = 0; if (c.status.watched !== "all") n++; if (c.status.rated !== "all") n++; if (c.status.collection !== "all") n++; if (c.status.tags !== "all") n++; if (c.status.video !== "all") n++; return n; } export function anyActive(c: FilterCriteria): boolean { return totalSelected(c) > 0 || totalStatusActive(c) > 0 || c.marks.length > 0; } /** Translate the tri-state status into the boolean flags consumed by listImages / libraryLetterCounts. */ export function statusToFlags(s: FilterStatus): { watched?: boolean; unwatched?: boolean; rated?: boolean; unrated?: boolean; hasCollection?: boolean; uncollected?: boolean; hasTags?: boolean; untagged?: boolean; hasVideo?: boolean; noVideo?: boolean; } { return { watched: s.watched === "watched", unwatched: s.watched === "unwatched", rated: s.rated === "rated", unrated: s.rated === "unrated", hasCollection: s.collection === "has", uncollected: s.collection === "missing", hasTags: s.tags === "has", untagged: s.tags === "missing", hasVideo: s.video === "has", noVideo: s.video === "missing", }; }