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 { 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 = Partial>; type AssocFilter = Filter< { any?: T[] | null; all?: T[] | null; exact?: T[] | null; count?: { value: number; operator?: Operator | null } | null; }, K >; type EnumFilter = Filter< { any?: string[] | null; empty?: boolean | null; }, K >; interface Integrateable { integrate(filter: F): void; } class ComplexMember { 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) { if (this.values.length > 0) { filter[this.key] = { [this.mode]: this.values }; } } } export class Association extends ComplexMember { values: (string | number)[] = $state([]); constructor(key: K, mode: FilterMode, filter?: AssocFilter | 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) { super.integrate(filter); if (this.empty) { filter[this.key] = { ...filter[this.key], count: { value: 0, operator: Operator.Equal } }; } } } export class Enum extends ComplexMember { values: string[] = $state([]); 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; } } integrate(filter: EnumFilter) { super.integrate(filter); if (this.empty) { filter[this.key] = { ...filter[this.key], empty: this.empty }; } } } class Bool { key: K; value?: boolean = $state(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 Orphan { key: K; value?: boolean = false; constructor(key: K, filter?: Filter | 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) { if (this.value) { filter[this.key] = { count: { value: 0, operator: Operator.Equal } }; } } } class Str { key: K; contains = $state(''); 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) { super(); this.path = new Str('path', filter); this.organized = new Bool('organized', filter); } } export class ComicFilterControls extends Controls { 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 { 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(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; exclude!: Controls; includes = 0; excludes = 0; apply = (params: URLSearchParams) => { navigate( { filter: buildFilterInput(this.include.buildFilter(), this.exclude.buildFilter()) }, params ); }; } export class ArchiveFilterContext extends FilterContext { 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 { 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 { include: BasicFilterControls; exclude: BasicFilterControls; constructor(filter: FilterInput) { super(); this.include = new BasicFilterControls(filter.include); this.exclude = new BasicFilterControls(); } } export class TagFilterContext extends FilterContext { 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 { 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; } } }