diff options
Diffstat (limited to 'frontend/src/lib/Filter.ts')
-rw-r--r-- | frontend/src/lib/Filter.ts | 365 |
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; + } + } +} |