import { type ArchiveFilter, type ArchiveFilterInput, type ComicFilter, type ComicFilterInput, type StringFilter, type TagFilter, type TagFilterInput } from '$gql/graphql'; import { getContext, setContext } from 'svelte'; import { writable, type Writable } from 'svelte/store'; import { navigate } from './Navigation'; import { numKeys } from './Utils'; interface FilterInput { include?: T | null; exclude?: T | null; } interface BasicFilter { name?: { contains?: string | null } | null; } type FilterMode = 'any' | 'all' | 'exact'; type Key = string | number | symbol; type Filter = { [Property in K]?: T | null; }; type AssocFilter = Filter< { any?: T[] | null; all?: T[] | null; exact?: T[] | null; empty?: boolean | null; }, K >; type EnumFilter = Filter< { any?: string[] | null; empty?: boolean | null; }, K >; interface Integrateable { integrate(filter: F): void; } class ComplexMember { values: unknown[] = []; key: K; mode: FilterMode; empty?: boolean | null; constructor(key: K, mode: FilterMode) { this.key = key; this.mode = mode; } integrate(filter: AssocFilter) { if (this.values.length > 0) { filter[this.key] = { [this.mode]: this.values }; } if (this.empty) { filter[this.key] = { ...filter[this.key], empty: this.empty }; } } } export class Association extends ComplexMember { values: (string | number)[] = []; constructor(key: K, mode: FilterMode, filter?: AssocFilter | null) { super(key, mode); if (!filter) { return; } const prop = filter[key]; this.empty = prop?.empty; if (prop?.all && prop.all.length > 0) { this.mode = 'all'; this.values = prop.all; } else if (prop?.any && prop.any.length > 0) { this.mode = 'any'; this.values = prop.any; } else if (prop?.exact && prop.exact.length > 0) { this.mode = 'exact'; this.values = prop.exact; } } } export class Enum extends ComplexMember { values: string[] = []; constructor(key: K, filter?: EnumFilter | null) { super(key, 'any'); if (!filter) { return; } this.empty = filter[key]?.empty; const prop = filter[key]; if (prop?.any) { this.values = prop.any; } } } class Bool { key: K; value?: boolean = undefined; constructor(key: K, filter?: Filter | null) { this.key = key; if (filter) { this.value = filter[key] ?? undefined; } } integrate(filter: Filter) { if (this.value !== undefined) { filter[this.key] = this.value; } } } class Str { key: K; contains = ''; constructor(key: K, filter?: Filter | null) { this.key = key; if (filter) { this.contains = filter[key]?.contains ?? ''; } } integrate(filter: Filter) { if (this.contains) { filter[this.key] = { contains: this.contains }; } } } abstract class Controls { buildFilter() { const filter = {} as F; Object.values(this).forEach((v: Integrateable) => v.integrate(filter)); return filter; } } export class ArchiveFilterControls extends Controls { path: Str<'path'>; organized: Bool<'organized'>; constructor(filter: ArchiveFilter | null | undefined) { super(); this.path = new Str('path', filter); this.organized = new Bool('organized', filter); } } export class ComicFilterControls extends Controls { title: Str<'title'>; categories: Enum<'category'>; censorships: Enum<'censorship'>; ratings: Enum<'rating'>; tags: Association<'tags'>; languages: Enum<'language'>; artists: Association<'artists'>; circles: Association<'circles'>; characters: Association<'characters'>; worlds: Association<'worlds'>; favourite: Bool<'favourite'>; organized: Bool<'organized'>; bookmarked: Bool<'bookmarked'>; constructor(filter: ComicFilter | null | undefined, mode: FilterMode); constructor(filter: ComicFilter | null | undefined, mode: FilterMode); constructor(filter: ComicFilter | null | undefined, mode: FilterMode) { super(); this.title = new Str('title', filter); this.favourite = new Bool('favourite', filter); this.organized = new Bool('organized', filter); this.bookmarked = new Bool('bookmarked', filter); this.tags = new Association('tags', mode, filter); this.languages = new Enum('language', filter); this.categories = new Enum('category', filter); this.censorships = new Enum('censorship', filter); this.ratings = new Enum('rating', filter); this.artists = new Association('artists', mode, filter); this.circles = new Association('circles', mode, filter); this.characters = new Association('characters', mode, filter); this.worlds = new Association('worlds', mode, filter); } } export class BasicFilterControls extends Controls { name: Str<'name'>; constructor(filter?: BasicFilter | null) { super(); this.name = new Str('name', filter); } } export class TagFilterControls extends BasicFilterControls { namespaces: Association<'namespaces'>; constructor(filter: TagFilter | null | undefined, mode: FilterMode) { super(filter); this.namespaces = new Association('namespaces', mode, filter); } } function buildFilterInput(include?: F, exclude?: F) { const input: FilterInput = {}; if (include && Object.keys(include).length > 0) { input.include = include; } if (exclude && Object.keys(exclude).length > 0) { input.exclude = exclude; } return input; } abstract class FilterContext { include!: { controls: Controls; size: number }; exclude!: { controls: Controls; size: number }; apply(params: URLSearchParams) { navigate( { filter: buildFilterInput( this.include.controls.buildFilter(), this.exclude.controls.buildFilter() ) }, params ); } } export class ArchiveFilterContext extends FilterContext { include: { controls: ArchiveFilterControls; size: number }; exclude: { controls: ArchiveFilterControls; size: number }; private static ignore = ['organized']; constructor(filter: ArchiveFilterInput) { super(); this.include = { controls: new ArchiveFilterControls(filter.include), size: numKeys(filter.include, ArchiveFilterContext.ignore) }; this.exclude = { controls: new ArchiveFilterControls(filter.exclude), size: numKeys(filter.exclude, ArchiveFilterContext.ignore) }; } } export class ComicFilterContext extends FilterContext { include: { controls: ComicFilterControls; size: number }; exclude: { controls: ComicFilterControls; size: number }; private static ignore = ['title', 'favourite', 'organized', 'bookmarked']; constructor(filter: ComicFilterInput) { super(); this.include = { controls: new ComicFilterControls(filter.include, 'all'), size: numKeys(filter.include, ComicFilterContext.ignore) }; this.exclude = { controls: new ComicFilterControls(filter.exclude, 'any'), size: numKeys(filter.exclude, ComicFilterContext.ignore) }; } } export class BasicFilterContext extends FilterContext { include: { controls: BasicFilterControls; size: number }; exclude: { controls: BasicFilterControls; size: number }; constructor(filter: FilterInput) { super(); this.include = { controls: new BasicFilterControls(filter.include), size: numKeys(filter.include) }; this.exclude = { controls: new BasicFilterControls(), size: 0 }; } } export class TagFilterContext extends FilterContext { include: { controls: TagFilterControls; size: number }; exclude: { controls: TagFilterControls; size: number }; private static ignore = ['name']; constructor(filter: TagFilterInput) { super(); this.include = { controls: new TagFilterControls(filter.include, 'all'), size: numKeys(filter.include, TagFilterContext.ignore) }; this.exclude = { controls: new TagFilterControls(filter.exclude, 'any'), size: numKeys(filter.exclude, TagFilterContext.ignore) }; } } export function initFilterContext>() { return setContext>('filter', writable()); } export function getFilterContext>() { return getContext>('filter'); } export function cycleBooleanFilter(value: boolean | undefined, tristate = true) { if (tristate) { if (value === undefined) { return true; } else if (value) { return false; } else { return undefined; } } else { if (value) { return undefined; } else { return true; } } }