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<T> {
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<T, K extends Key> = {
[Property in K]?: T | null;
};
type AssocFilter<T, K extends Key> = Filter<
{
any?: T[] | null;
all?: T[] | null;
exact?: T[] | null;
empty?: boolean | 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[] = [];
key: K;
mode: FilterMode;
empty?: boolean | 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 };
}
if (this.empty) {
filter[this.key] = { ...filter[this.key], empty: this.empty };
}
}
}
export class Association<K extends Key> extends ComplexMember<K> {
values: (string | number)[] = [];
constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | 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<K extends Key> extends ComplexMember<K> {
values: string[] = [];
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;
}
}
}
class Bool<K extends Key> {
key: K;
value?: boolean = 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 Str<K extends Key> {
key: K;
contains = '';
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 | undefined) {
super();
this.path = new Str('path', filter);
this.organized = new Bool('organized', filter);
}
}
export class ComicFilterControls extends Controls<ComicFilter> {
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<BasicFilter> {
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<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: Controls<F>; size: number };
exclude!: { controls: Controls<F>; size: number };
apply(params: URLSearchParams) {
navigate(
{
filter: buildFilterInput(
this.include.controls.buildFilter(),
this.exclude.controls.buildFilter()
)
},
params
);
}
}
export class ArchiveFilterContext extends FilterContext<ArchiveFilter> {
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<ComicFilter> {
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<BasicFilter> {
include: { controls: BasicFilterControls; size: number };
exclude: { controls: BasicFilterControls; size: number };
constructor(filter: FilterInput<BasicFilter>) {
super();
this.include = {
controls: new BasicFilterControls(filter.include),
size: numKeys(filter.include)
};
this.exclude = {
controls: new BasicFilterControls(),
size: 0
};
}
}
export class TagFilterContext extends FilterContext<TagFilter> {
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<F extends FilterContext<unknown>>() {
return setContext<Writable<F>>('filter', writable());
}
export function getFilterContext<F extends FilterContext<unknown>>() {
return getContext<Writable<F>>('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;
}
}
}