summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src/lib/Filter.ts
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib/Filter.ts')
-rw-r--r--frontend/src/lib/Filter.ts365
1 files changed, 365 insertions, 0 deletions
diff --git a/frontend/src/lib/Filter.ts b/frontend/src/lib/Filter.ts
new file mode 100644
index 0000000..8e419f3
--- /dev/null
+++ b/frontend/src/lib/Filter.ts
@@ -0,0 +1,365 @@
+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;
+ }
+ }
+}