import { Operator, type ArchiveFilter, type ArchiveFilterInput, type ComicFilter, type ComicFilterInput, type NamespaceFilter, type NamespaceFilterInput, type StringFilter, type TagFilter, type TagFilterInput } from '$gql/graphql'; import { navigate } from './Navigation'; import { numKeys, type Key } from './Utils'; interface FilterInput<T> { include?: T | null; exclude?: T | null; } interface NameFilter { name?: { contains?: string | null } | null; } interface AssociationCount { count: { value: number; operator?: Operator | null }; } interface BasicFilter extends NameFilter { comics?: AssociationCount | null; } export type FilterType = 'include' | 'exclude'; type FilterMode = 'any' | 'all' | 'exact'; type Filter<T, K extends Key> = Partial<Record<K, T | null>>; type AssocFilter<T, K extends Key> = Filter< { any?: T[] | null; all?: T[] | null; exact?: T[] | null; count?: { value: number; operator?: Operator | null } | null; }, K >; type EnumFilter<K extends Key> = Filter< { any?: string[] | null; empty?: boolean | null; }, K >; interface Integrateable<F> { integrate(filter: F): void; } class ComplexMember<K extends Key> { values: unknown[] = $state([]); key: K; mode: FilterMode = $state('all'); empty?: boolean | null = $state(null); constructor(key: K, mode: FilterMode) { this.key = key; this.mode = mode; } integrate(filter: AssocFilter<unknown, K>) { if (this.values.length > 0) { filter[this.key] = { [this.mode]: this.values }; } } } export class Association<K extends Key> extends ComplexMember<K> { values: (string | number)[] = $state([]); constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | null) { super(key, mode); if (!filter) { return; } const prop = filter[key]; this.empty = prop?.count?.value === 0 && (prop.count.operator === undefined || prop.count.operator === Operator.Equal); 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; } } integrate(filter: AssocFilter<unknown, K>) { super.integrate(filter); if (this.empty) { filter[this.key] = { ...filter[this.key], count: { value: 0, operator: Operator.Equal } }; } } } export class Enum<K extends Key> extends ComplexMember<K> { values: string[] = $state([]); constructor(key: K, filter?: EnumFilter<K> | null) { super(key, 'any'); if (!filter) { return; } this.empty = filter[key]?.empty; const prop = filter[key]; if (prop?.any) { this.values = prop.any; } } integrate(filter: EnumFilter<K>) { super.integrate(filter); if (this.empty) { filter[this.key] = { ...filter[this.key], empty: this.empty }; } } } class Bool<K extends Key> { key: K; value?: boolean = $state(undefined); constructor(key: K, filter?: Filter<boolean, K> | null) { this.key = key; if (filter) { this.value = filter[key] ?? undefined; } } integrate(filter: Filter<boolean, K>) { if (this.value !== undefined) { filter[this.key] = this.value; } } } class Orphan<K extends Key> { key: K; value?: boolean = false; constructor(key: K, filter?: Filter<AssociationCount, K> | null) { this.key = key; if (filter) { this.value = filter[key]?.count?.value === 0 && (filter[key].count.operator === undefined || filter[key].count.operator === Operator.Equal); } } integrate(filter: Filter<AssociationCount, K>) { if (this.value) { filter[this.key] = { count: { value: 0, operator: Operator.Equal } }; } } } class Str<K extends Key> { key: K; contains = $state(''); constructor(key: K, filter?: Filter<StringFilter, K> | null) { this.key = key; if (filter) { this.contains = filter[key]?.contains ?? ''; } } integrate(filter: Filter<StringFilter, K>) { if (this.contains) { filter[this.key] = { contains: this.contains }; } } } abstract class Controls<F> { buildFilter() { const filter = {} as F; Object.values(this).forEach((v: Integrateable<F>) => v.integrate(filter)); return filter; } } export class ArchiveFilterControls extends Controls<ArchiveFilter> { path: Str<'path'>; organized: Bool<'organized'>; constructor(filter?: ArchiveFilter | null) { super(); this.path = new Str('path', filter); this.organized = new Bool('organized', filter); } } export class ComicFilterControls extends Controls<ComicFilter> { title: Str<'title'>; url: Str<'url'>; 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.url = new Str('url', 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 NameFilterControls extends Controls<NameFilter> { name: Str<'name'>; constructor(filter?: NameFilter | null) { super(); this.name = new Str('name', filter); } } export class BasicFilterControls extends NameFilterControls { orphan: Orphan<'comics'>; constructor(filter?: BasicFilter | null) { super(filter); this.orphan = new Orphan('comics', 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); } } export class NamespaceFilterControls extends NameFilterControls { orphan: Orphan<'tags'>; constructor(filter?: NamespaceFilter | null) { super(filter); this.orphan = new Orphan('tags', filter); } } function buildFilterInput<F>(include?: F, exclude?: F) { const input: FilterInput<F> = {}; 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<F> { include!: Controls<F>; exclude!: Controls<F>; includes = 0; excludes = 0; apply = (params: URLSearchParams) => { navigate( { filter: buildFilterInput(this.include.buildFilter(), this.exclude.buildFilter()) }, params ); }; } export class ArchiveFilterContext extends FilterContext<ArchiveFilter> { include: ArchiveFilterControls; exclude: ArchiveFilterControls; private static ignore = ['organized']; constructor(filter: ArchiveFilterInput) { super(); this.include = new ArchiveFilterControls(filter.include); this.exclude = new ArchiveFilterControls(filter.exclude); this.includes = numKeys(filter.include, ArchiveFilterContext.ignore); this.excludes = numKeys(filter.exclude, ArchiveFilterContext.ignore); } } export class ComicFilterContext extends FilterContext<ComicFilter> { include: ComicFilterControls; exclude: ComicFilterControls; private static ignore = ['title', 'favourite', 'organized', 'bookmarked']; constructor(filter: ComicFilterInput) { super(); this.include = new ComicFilterControls(filter.include, 'all'); this.exclude = new ComicFilterControls(filter.exclude, 'any'); this.includes = numKeys(filter.include, ComicFilterContext.ignore); this.excludes = numKeys(filter.exclude, ComicFilterContext.ignore); } } export class BasicFilterContext extends FilterContext<BasicFilter> { include: BasicFilterControls; exclude: BasicFilterControls; constructor(filter: FilterInput<BasicFilter>) { super(); this.include = new BasicFilterControls(filter.include); this.exclude = new BasicFilterControls(); } } export class TagFilterContext extends FilterContext<TagFilter> { include: TagFilterControls; exclude: TagFilterControls; private static ignore = ['name', 'comics']; constructor(filter: TagFilterInput) { super(); this.include = new TagFilterControls(filter.include, 'all'); this.exclude = new TagFilterControls(filter.exclude, 'any'); this.includes = numKeys(filter.include, TagFilterContext.ignore); this.excludes = numKeys(filter.exclude, TagFilterContext.ignore); } } export class NamespaceFilterContext extends FilterContext<NamespaceFilter> { include: NamespaceFilterControls; exclude: NamespaceFilterControls; constructor(filter: NamespaceFilterInput) { super(); this.include = new NamespaceFilterControls(filter.include); this.exclude = new NamespaceFilterControls(); } } 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; } } }