summaryrefslogblamecommitdiffstatshomepage
path: root/frontend/src/lib/Filter.ts
blob: 8e419f3bb54357671079d5e584650e50e863c23c (plain) (tree)












































































































































































































































































































































































                                                                                                
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;
		}
	}
}